diff --git a/ai_project/.claude/agents/demand-assessor.md b/ai_project/.claude/agents/demand-assessor.md new file mode 100644 index 0000000..bfc0466 --- /dev/null +++ b/ai_project/.claude/agents/demand-assessor.md @@ -0,0 +1,203 @@ +你是一个精通 Java 微服务架构的资深技术负责人。 + +请基于以下统一模型评估需求的工作量。 + +============================== +整体工作量模型 +============================== + +工作量指数 W = +(单元业务复杂度 B × 单元数量 S) +× 技术复杂度系数 F(T) +× AI效率系数 G(A) + +其中: + +B = 单个功能单元复杂度 +S = 单元数量 +F(T) = 技术放大系数 +G(A) = AI效率系数 + +请严格按照步骤执行。 + + +==================================================== +第一部分:识别功能单元(规模 S) +==================================================== + +1. 识别最小可重复功能单元 + (例如:接口、页面、报表、定时任务等) + +2. 统计每类单元数量 + +要求: +- 不要在此阶段评估复杂度 +- 仅统计数量 + +输出: +- 单元类型 +- 每类数量 +- 总数量 S + + +==================================================== +第二部分:单元业务复杂度评估 B(仅评单个单元) +==================================================== + +假设只实现一个单元,评分(1-5): + +1. 业务规则数量 +2. 状态流转复杂度 +3. 异常处理复杂度 +4. 边界场景复杂度 +5. 需求不确定性 + +计算: + +B = 五项平均值(保留1位小数) + +注意: +禁止在此阶段考虑单元数量。 + +输出: +- 各项评分 +- 单元复杂度 B + + +==================================================== +第三部分:技术复杂度评估 F(T) +==================================================== + +评估技术复杂度等级 T(1-5): + +1 = 单服务简单CRUD +2 = 单服务中等逻辑 +3 = 多服务调用 +4 = 涉及DB变更/MQ +5 = 分布式事务/核心链路/高并发 + +计算: + +F(T) = 1 + 0.2 × (T - 1) + +输出: +- T值 +- 技术复杂度系数 F(T) +- 技术风险说明 + + +==================================================== +第四部分:AI效率系数 G(A) 计算 +==================================================== + +步骤A:正向评分(1-5分) + +1.需求清晰 +2.规则明确度 +3.可验证性 +4.代码结构清晰度 + +P = 三项得分之和 + + +步骤B:架构抽象复杂度(计数制) + +每触发一项 +1: + +- 策略/模板动态组合 +- 插件或SPI机制 +- 规则引擎/表达式驱动 +- AOP深度影响核心逻辑 +- Saga/TCC/补偿机制 +- 多租户/动态数据源 + +记为 N1 + + +步骤C:变更影响范围(计数制) + +每触发一项 +1: + +- 老服务接口变更 +- 数据库老表变更 +- 核心业务链路变更 +- 公共组件修改 +- 权限安全机制修改 +- 分布式事务/MQ变更 +- 低测试覆盖区域 + +记为 N2 + + +步骤D:历史耦合程度(计数制) + +每触发一项 +1: + +- 巨型类 +- 高复杂度方法 +- 隐式调用链 +- 模块边界不清晰 +- 测试覆盖<30% +- 多服务共享数据库 +- 无维护人/无文档 + +记为 N3 + + +步骤E:计算 + +N = N1 + N2 + N3 + +A = P - N + + +==================================================== +第五部分:安全门检查 +==================================================== + +若满足任一: + +- 修改核心结算逻辑 +- 修改分布式事务核心 +- 修改权限核心鉴权 +- 单测覆盖率<20% +- 无法清晰定位影响范围 + +标记为【高风险变更】 + + + +==================================================== +第六部分:AI效率系数映射 +==================================================== + +若非高风险变更: + +A ≥ 10 → G(A) = 0.55 +8 ≤ A < 10 → G(A) = 0.65 +6 ≤ A < 8 → G(A) = 0.75 +3 ≤ A < 6 → G(A) = 0.85 +1 ≤ A < 3 → G(A) = 0.95 +A ≤ 0 → G(A) = 1.0 + + +==================================================== +第七部分:最终工作量计算 +==================================================== + +计算: + +基础工作量 = B × S +技术放大后 = 基础工作量 × F(T) +最终工作量指数 W = 技术放大后 × G(A) + +请输出: + +1. 单元数量 S +2. 单元复杂度 B +3. 技术系数 F(T) +4. AI效率系数 G(A) +5. 最终工作量指数 W +6. 综合风险等级(低/中/高) +7. 是否建议拆分需求 +8. AI建议参与方式 \ No newline at end of file diff --git a/cc-center/FreeSWITCH技术架构与ESL开发指南.txt b/cc-center/FreeSWITCH技术架构与ESL开发指南.txt new file mode 100644 index 0000000..8187089 --- /dev/null +++ b/cc-center/FreeSWITCH技术架构与ESL开发指南.txt @@ -0,0 +1,3598 @@ +## 目录 + +1. [FreeSWITCH技术架构](#第一部分-freeswitch技术架构) +2. [核心模块详解](#第二部分-核心模块详解) +3. [ESL开发指南](#第三部分-esl开发指南) +4. [呼叫中心场景开发实践](#第四部分-呼叫中心场景开发实践) +5. [高可用部署方案](#第五部分-高可用部署方案) +6. [性能优化指南](#第六部分-性能优化指南) + +--- + +## 第一部分 FreeSWITCH技术架构 + +### 1.1 整体架构概览 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 外部系统/应用层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ CTI中间件 │ │ Web应用 │ │ 业务系统 │ │ 监控系统 │ │ 第三方App│ │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ │ │ +│ └─────────────┴─────────────┴──────┬──────┴─────────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ ESL接口 │ │ +│ │ (TCP:8021) │ │ +│ └──────┬──────┘ │ +└───────────────────────────────────────────┼─────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ FreeSWITCH 核心层 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Core(核心引擎) │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ 状态机管理 │ │ 内存管理 │ │ 线程池管理 │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ 事件系统 │ │ 数据库抽象 │ │ 日志系统 │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Module Interface(模块接口层) │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Endpoint │ │ Application │ │ Codec │ │ Dialplan │ │ │ +│ │ │ 终端模块 │ │ 应用模块 │ │ 编解码 │ │ 拨号计划 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Event │ │ Format │ │ Logger │ │ XML │ │ │ +│ │ │ 事件模块 │ │ 文件格式 │ │ 日志模块 │ │ XML处理 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ 协议/媒体层 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ mod_sofia │ │ mod_verto │ │ mod_rtmp │ │ mod_skinny │ │ +│ │ (SIP协议) │ │ (WebRTC) │ │ (RTMP) │ │ (Cisco) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ 媒体处理引擎 │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ RTP处理 │ │ 编解码转换 │ │ 录音处理 │ │ DTMF检测 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ 网络层 │ +│ SIP/UDP/TCP RTP/SRTP WebSocket HTTP │ +│ :5060 :16384-32768 :8081 :8080 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心概念 + +#### 1.2.1 Channel(通道) + +Channel是FreeSWITCH中最核心的概念,代表一路通话的端点。 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 一通电话 │ +│ │ +│ ┌─────────────┐ Bridge ┌─────────────┐ │ +│ │ Channel A │◄─────────────────────────►│ Channel B │ │ +│ │ (主叫方) │ │ (被叫方) │ │ +│ │ │ │ │ │ +│ │ UUID: xxx-a │ │ UUID: xxx-b │ │ +│ │ State: CS_ │ │ State: CS_ │ │ +│ │ Variables │ │ Variables │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Channel状态机:** + +``` + ┌──────────────────────────────────────────┐ + │ │ + ▼ │ +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──┴──────┐ +│ NEW │───►│ INIT │───►│ ROUTING │───►│ EXECUTE │───►│ HANGUP │ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ + │ │ + ┌────▼────┐ ┌────▼────┐ + │ CONSUME │ │ EXCHANGE│ + │ MEDIA │ │ MEDIA │ + └─────────┘ └─────────┘ + +Channel状态说明: +├── CS_NEW : 新建通道 +├── CS_INIT : 初始化 +├── CS_ROUTING : 路由中(执行dialplan) +├── CS_EXECUTE : 执行应用中 +├── CS_EXCHANGE_MEDIA : 媒体交换中(bridged) +├── CS_CONSUME_MEDIA : 媒体消费中 +├── CS_HIBERNATE : 休眠状态 +├── CS_HANGUP : 挂断中 +└── CS_DESTROY : 销毁 +``` + +#### 1.2.2 Session(会话) + +Session包含Channel及其相关上下文信息: + +```c +// Session结构概念图 +Session { + ├── Channel // 通道信息 + │ ├── UUID // 唯一标识 + │ ├── State // 当前状态 + │ ├── Direction // 呼叫方向(inbound/outbound) + │ └── Variables // 通道变量 + │ + ├── Caller Profile // 主叫信息 + │ ├── caller_id_name + │ ├── caller_id_number + │ ├── destination_number + │ └── ... + │ + ├── Codec // 编解码信息 + ├── RTP Session // RTP会话 + └── Bridge Session // 桥接会话(如果有) +} +``` + +#### 1.2.3 Event(事件) + +FreeSWITCH基于事件驱动架构,所有状态变化都产生事件: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FreeSWITCH Event System │ +│ │ +│ ┌───────────────┐ │ +│ │ Event Producer│ ──────┐ │ +│ │ (模块/核心) │ │ │ +│ └───────────────┘ │ ┌─────────────────────────┐ │ +│ ├─────►│ Event Dispatcher │ │ +│ ┌───────────────┐ │ │ (事件分发器) │ │ +│ │ Event Producer│ ──────┤ └───────────┬─────────────┘ │ +│ │ (模块/核心) │ │ │ │ +│ └───────────────┘ │ ┌───────────┴─────────────┐ │ +│ │ │ │ │ │ +│ ┌───────────────┐ │ ▼ ▼ ▼ │ +│ │ Event Producer│ ──────┘ ┌───────┐ ┌───────┐ ┌───────┐│ +│ │ (模块/核心) │ │ ESL │ │Internal│ │ Lua/ ││ +│ └───────────────┘ │Consumer│ │Handler │ │ JS ││ +│ └───────┘ └───────┘ └───────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +**常用事件类型:** + +| 事件名称 | 触发时机 | 用途 | +|---------|---------|------| +| CHANNEL_CREATE | 通道创建 | 新呼叫进入 | +| CHANNEL_ANSWER | 通话接听 | 通话建立 | +| CHANNEL_BRIDGE | 通道桥接 | 双方接通 | +| CHANNEL_UNBRIDGE | 取消桥接 | 桥接断开 | +| CHANNEL_HANGUP | 挂断 | 通话结束 | +| CHANNEL_HANGUP_COMPLETE | 挂断完成 | 通话完全结束 | +| CHANNEL_STATE | 状态变化 | 状态监控 | +| DTMF | 按键检测 | IVR交互 | +| RECORD_START | 开始录音 | 录音控制 | +| RECORD_STOP | 停止录音 | 录音控制 | +| CUSTOM | 自定义事件 | 业务扩展 | + +### 1.3 目录结构 + +``` +/usr/local/freeswitch/ +├── bin/ # 可执行文件 +│ └── freeswitch # 主程序 +│ +├── conf/ # 配置文件目录 +│ ├── freeswitch.xml # 主配置文件 +│ ├── vars.xml # 全局变量 +│ ├── sip_profiles/ # SIP配置 +│ │ ├── internal.xml # 内部Profile(坐席注册) +│ │ └── external.xml # 外部Profile(中继对接) +│ ├── dialplan/ # 拨号计划 +│ │ ├── default.xml # 默认拨号计划 +│ │ └── public.xml # 公共拨号计划(呼入) +│ ├── directory/ # 用户目录(分机注册) +│ │ └── default/ +│ │ └── 1000.xml # 分机1000配置 +│ ├── autoload_configs/ # 模块自动加载配置 +│ │ ├── modules.conf.xml # 模块加载列表 +│ │ ├── sofia.conf.xml # SIP模块配置 +│ │ ├── event_socket.conf.xml# ESL配置 +│ │ └── ... +│ └── lang/ # 语音提示 +│ +├── lib/ # 库文件 +├── mod/ # 模块文件(.so) +├── log/ # 日志目录 +├── db/ # 数据库文件 +├── recordings/ # 录音文件 +├── sounds/ # 语音文件 +├── scripts/ # 脚本目录 +└── storage/ # 存储目录 +``` + +--- + +## 第二部分 核心模块详解 + +### 2.1 mod_sofia(SIP模块) + +mod_sofia是FreeSWITCH的SIP协议栈实现,基于Sofia-SIP库。 + +#### 2.1.1 架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ mod_sofia │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Sofia-SIP Stack │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ NUA │ │ NTA │ │ TPORT │ │ SDP │ │ │ +│ │ │ User Agent│ │Transaction│ │ Transport │ │ Parser │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Profile Management │ │ +│ │ │ │ +│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ +│ │ │ Internal Profile │ │ External Profile │ │ │ +│ │ │ (坐席/分机注册) │ │ (中继/网关) │ │ │ +│ │ │ UDP:5060 │ │ UDP:5080 │ │ │ +│ │ └─────────────────────┘ └─────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Gateway Management │ │ │ +│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ +│ │ │ │Gateway A│ │Gateway B│ │Gateway C│ ... │ │ │ +│ │ │ │(运营商1) │ │(运营商2) │ │(备用) │ │ │ │ +│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 2.1.2 配置示例 + +**Internal Profile(坐席注册):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**External Profile(中继对接):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 2.2 Dialplan(拨号计划) + +拨号计划定义了呼叫的路由和处理逻辑。 + +#### 2.2.1 执行流程 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Dialplan 执行流程 │ +│ │ +│ 来电进入 │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Context 选择 ││ +│ │ (根据Profile配置的context) ││ +│ │ 例如: public (呼入) / default (内部) ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Extension 匹配 ││ +│ │ (按顺序匹配condition) ││ +│ │ ││ +│ │ Extension 1: ──┐ ││ +│ │ Condition ───┼─► 不匹配 ───► 下一个Extension ││ +│ │ │ ││ +│ │ └─► 匹配 ───► 执行Actions ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Action 执行 ││ +│ │ (按顺序执行应用) ││ +│ │ ││ +│ │ action: answer ││ +│ │ action: playback ││ +│ │ action: bridge ││ +│ │ ... ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +#### 2.2.2 配置示例 + +**呼入路由(public.xml):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**IVR和呼叫处理(default.xml):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 2.3 mod_event_socket(ESL模块) + +ESL是FreeSWITCH对外提供的控制接口,是开发呼叫中心的核心。 + +#### 2.3.1 工作模式 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ESL 工作模式 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Inbound 模式 │ │ +│ │ (客户端主动连接) │ │ +│ │ │ │ +│ │ ┌───────────┐ TCP:8021 ┌──────────────────┐ │ │ +│ │ │ CTI中间件 │ ──────────────────────► │ FreeSWITCH │ │ │ +│ │ │ (Client) │ ◄────────────────────── │ (Server) │ │ │ +│ │ └───────────┘ Event Stream └──────────────────┘ │ │ +│ │ │ │ +│ │ 特点: │ │ +│ │ - 客户端主动连接FreeSWITCH │ │ +│ │ - 可以订阅事件、发送命令 │ │ +│ │ - 适合集中控制、监控场景 │ │ +│ │ - 推荐呼叫中心使用此模式 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Outbound 模式 │ │ +│ │ (FreeSWITCH主动连接) │ │ +│ │ │ │ +│ │ ┌──────────────────┐ TCP ┌───────────────┐ │ │ +│ │ │ FreeSWITCH │ ──────────────────►│ 应用服务器 │ │ │ +│ │ │ (每个呼叫) │ ◄────────────────── │ (Server) │ │ │ +│ │ └──────────────────┘ └───────────────┘ │ │ +│ │ │ │ +│ │ 特点: │ │ +│ │ - 每个呼叫FreeSWITCH主动连接应用 │ │ +│ │ - 在dialplan中使用socket应用触发 │ │ +│ │ - 适合简单IVR场景 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 2.3.2 ESL配置 + +```xml + + + + + + + + + + + + + + + +``` + +--- + +## 第三部分 ESL开发指南 + +### 3.1 ESL协议详解 + +#### 3.1.1 协议格式 + +ESL使用类HTTP的文本协议: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ESL 消息格式 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 请求格式: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ command [arguments] │ │ │ +│ │ │ Header-Name: header-value │ │ │ +│ │ │ Header-Name: header-value │ │ │ +│ │ │ │ │ │ +│ │ │ [body if Content-Length > 0] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 响应格式: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Content-Type: text/event-plain │ │ │ +│ │ │ Content-Length: 1234 │ │ │ +│ │ │ Reply-Text: +OK [message] │ │ │ +│ │ │ │ │ │ +│ │ │ [body] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.1.2 常用命令 + +| 命令类型 | 命令 | 说明 | +|---------|------|------| +| **认证** | `auth ` | 连接认证 | +| **事件订阅** | `event plain ` | 订阅事件 | +| | `event plain ALL` | 订阅所有事件 | +| | `event plain CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_HANGUP` | 订阅指定事件 | +| | `nixevent ` | 取消订阅 | +| | `noevents` | 取消所有订阅 | +| **API命令** | `api [args]` | 同步执行API | +| | `bgapi [args]` | 异步执行API | +| **过滤器** | `filter
` | 过滤特定事件 | +| | `filter Unique-ID ` | 只接收指定UUID事件 | +| **呼叫控制** | `sendmsg ` | 向指定通道发送消息 | +| | `execute` | 在通道上执行应用 | +| **其他** | `log ` | 设置日志级别 | +| | `linger` | 保持连接 | +| | `nolinger` | 取消保持 | +| | `exit` | 断开连接 | + +### 3.2 Java ESL开发 + +#### 3.2.1 项目结构 + +``` +callcenter-cti/ +├── pom.xml +├── src/main/java/ +│ └── com/callcenter/ +│ ├── Application.java +│ ├── esl/ +│ │ ├── EslConnectionManager.java # ESL连接管理 +│ │ ├── EslEventHandler.java # 事件处理 +│ │ ├── EslCommandExecutor.java # 命令执行 +│ │ └── InboundClient.java # Inbound客户端 +│ ├── service/ +│ │ ├── AgentService.java # 坐席服务 +│ │ ├── CallService.java # 呼叫服务 +│ │ ├── QueueService.java # 排队服务 +│ │ └── AcdService.java # 话务分配服务 +│ ├── model/ +│ │ ├── Agent.java # 坐席模型 +│ │ ├── Call.java # 呼叫模型 +│ │ └── Queue.java # 队列模型 +│ ├── api/ +│ │ ├── AgentController.java # 坐席API +│ │ └── CallController.java # 呼叫API +│ └── websocket/ +│ └── EventPushHandler.java # 事件推送 +└── src/main/resources/ + └── application.yml +``` + +#### 3.2.2 Maven依赖 + +```xml + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.freeswitch.esl.client + org.freeswitch.esl.client + 0.9.2 + + + + + io.netty + netty-all + 4.1.86.Final + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.alibaba + fastjson + 2.0.23 + + + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + + +``` + +#### 3.2.3 ESL连接管理 + +```java +package com.callcenter.esl; + +import lombok.extern.slf4j.Slf4j; +import org.freeswitch.esl.client.inbound.Client; +import org.freeswitch.esl.client.inbound.InboundConnectionFailure; +import org.freeswitch.esl.client.transport.event.EslEvent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * ESL连接管理器 + * 负责与FreeSWITCH建立和维护连接 + */ +@Slf4j +@Component +public class EslConnectionManager { + + @Value("${freeswitch.esl.host:127.0.0.1}") + private String eslHost; + + @Value("${freeswitch.esl.port:8021}") + private int eslPort; + + @Value("${freeswitch.esl.password:ClueCon}") + private String eslPassword; + + @Value("${freeswitch.esl.timeout:30}") + private int timeout; + + private Client eslClient; + private final AtomicBoolean connected = new AtomicBoolean(false); + private final AtomicBoolean reconnecting = new AtomicBoolean(false); + + private ScheduledExecutorService scheduler; + private ExecutorService eventExecutor; + + private EslEventHandler eventHandler; + + @PostConstruct + public void init() { + // 初始化线程池 + scheduler = Executors.newSingleThreadScheduledExecutor( + r -> new Thread(r, "esl-reconnect")); + eventExecutor = new ThreadPoolExecutor( + 4, 16, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(10000), + r -> new Thread(r, "esl-event-handler"), + new ThreadPoolExecutor.CallerRunsPolicy() + ); + + // 建立连接 + connect(); + + // 启动心跳检测 + startHeartbeat(); + } + + /** + * 建立ESL连接 + */ + public synchronized void connect() { + if (connected.get()) { + log.info("ESL已连接,无需重复连接"); + return; + } + + try { + log.info("正在连接FreeSWITCH ESL: {}:{}", eslHost, eslPort); + + eslClient = new Client(); + eslClient.connect(eslHost, eslPort, eslPassword, timeout); + + // 订阅事件 + subscribeEvents(); + + // 设置事件监听器 + eslClient.setEventSubscriptions("plain", "all"); + eslClient.addEventListener((ctx, event) -> { + handleEvent(event); + }); + + connected.set(true); + reconnecting.set(false); + log.info("ESL连接成功"); + + } catch (InboundConnectionFailure e) { + log.error("ESL连接失败: {}", e.getMessage()); + connected.set(false); + scheduleReconnect(); + } + } + + /** + * 订阅所需事件 + */ + private void subscribeEvents() { + String events = String.join(" ", + "CHANNEL_CREATE", + "CHANNEL_ANSWER", + "CHANNEL_BRIDGE", + "CHANNEL_UNBRIDGE", + "CHANNEL_HANGUP", + "CHANNEL_HANGUP_COMPLETE", + "CHANNEL_STATE", + "DTMF", + "RECORD_START", + "RECORD_STOP", + "CUSTOM" + ); + + eslClient.setEventSubscriptions("plain", events); + eslClient.addEventFilter("Event-Name", "CHANNEL_CREATE"); + log.info("已订阅事件: {}", events); + } + + /** + * 处理事件 + */ + private void handleEvent(EslEvent event) { + eventExecutor.submit(() -> { + try { + if (eventHandler != null) { + eventHandler.handle(event); + } + } catch (Exception e) { + log.error("事件处理异常: {}", e.getMessage(), e); + } + }); + } + + /** + * 启动心跳检测 + */ + private void startHeartbeat() { + scheduler.scheduleAtFixedRate(() -> { + if (connected.get()) { + try { + // 发送status命令检测连接 + eslClient.sendSyncApiCommand("status", ""); + } catch (Exception e) { + log.warn("心跳检测失败,连接可能已断开"); + connected.set(false); + scheduleReconnect(); + } + } + }, 30, 30, TimeUnit.SECONDS); + } + + /** + * 计划重连 + */ + private void scheduleReconnect() { + if (reconnecting.compareAndSet(false, true)) { + scheduler.schedule(() -> { + log.info("尝试重新连接ESL..."); + connect(); + }, 5, TimeUnit.SECONDS); + } + } + + /** + * 同步执行API命令 + */ + public String sendSyncApiCommand(String command, String args) { + checkConnection(); + try { + return eslClient.sendSyncApiCommand(command, args).getBodyLines().get(0); + } catch (Exception e) { + log.error("执行API命令失败: {} {}", command, args, e); + throw new RuntimeException("ESL命令执行失败", e); + } + } + + /** + * 异步执行API命令 + */ + public CompletableFuture sendAsyncApiCommand(String command, String args) { + return CompletableFuture.supplyAsync(() -> + sendSyncApiCommand(command, args), eventExecutor); + } + + /** + * 向指定UUID发送消息执行应用 + */ + public void sendExecute(String uuid, String application, String args) { + checkConnection(); + try { + eslClient.sendSyncApiCommand("uuid_broadcast", + uuid + " " + application + "::" + args + " both"); + } catch (Exception e) { + log.error("发送Execute失败: uuid={}, app={}", uuid, application, e); + } + } + + /** + * 检查连接状态 + */ + private void checkConnection() { + if (!connected.get()) { + throw new RuntimeException("ESL未连接"); + } + } + + /** + * 获取连接状态 + */ + public boolean isConnected() { + return connected.get(); + } + + /** + * 设置事件处理器 + */ + public void setEventHandler(EslEventHandler handler) { + this.eventHandler = handler; + } + + @PreDestroy + public void destroy() { + try { + connected.set(false); + if (eslClient != null) { + eslClient.close(); + } + if (scheduler != null) { + scheduler.shutdown(); + } + if (eventExecutor != null) { + eventExecutor.shutdown(); + } + log.info("ESL连接已关闭"); + } catch (Exception e) { + log.error("关闭ESL连接异常", e); + } + } +} +``` + +#### 3.2.4 事件处理器 + +```java +package com.callcenter.esl; + +import com.callcenter.model.Call; +import com.callcenter.model.CallDirection; +import com.callcenter.model.CallState; +import com.callcenter.service.AcdService; +import com.callcenter.service.CallService; +import com.callcenter.websocket.EventPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.freeswitch.esl.client.transport.event.EslEvent; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * ESL事件处理器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EslEventHandler { + + private final CallService callService; + private final AcdService acdService; + private final EventPushService eventPushService; + + /** + * 处理ESL事件 + */ + public void handle(EslEvent event) { + String eventName = event.getEventName(); + Map headers = event.getEventHeaders(); + + log.debug("收到事件: {}, UUID: {}", eventName, headers.get("Unique-ID")); + + switch (eventName) { + case "CHANNEL_CREATE": + handleChannelCreate(headers); + break; + case "CHANNEL_ANSWER": + handleChannelAnswer(headers); + break; + case "CHANNEL_BRIDGE": + handleChannelBridge(headers); + break; + case "CHANNEL_UNBRIDGE": + handleChannelUnbridge(headers); + break; + case "CHANNEL_HANGUP": + handleChannelHangup(headers); + break; + case "CHANNEL_HANGUP_COMPLETE": + handleChannelHangupComplete(headers); + break; + case "DTMF": + handleDtmf(headers); + break; + case "RECORD_START": + handleRecordStart(headers); + break; + case "RECORD_STOP": + handleRecordStop(headers); + break; + case "CUSTOM": + handleCustomEvent(headers); + break; + default: + log.trace("未处理的事件类型: {}", eventName); + } + } + + /** + * 处理通道创建事件(新呼叫) + */ + private void handleChannelCreate(Map headers) { + String uuid = headers.get("Unique-ID"); + String direction = headers.get("Call-Direction"); + String callerNumber = headers.get("Caller-Caller-ID-Number"); + String calleeNumber = headers.get("Caller-Destination-Number"); + + log.info("新呼叫: uuid={}, direction={}, caller={}, callee={}", + uuid, direction, callerNumber, calleeNumber); + + // 创建呼叫记录 + Call call = new Call(); + call.setUuid(uuid); + call.setDirection("inbound".equals(direction) ? + CallDirection.INBOUND : CallDirection.OUTBOUND); + call.setCallerNumber(callerNumber); + call.setCalleeNumber(calleeNumber); + call.setState(CallState.RINGING); + call.setCreateTime(LocalDateTime.now()); + + // 获取业务标识(从通道变量) + String businessType = headers.get("variable_business_type"); + String skillGroup = headers.get("variable_skill_group"); + call.setBusinessType(businessType); + call.setSkillGroup(skillGroup); + + callService.saveCall(call); + + // 如果是呼入,触发ACD排队 + if ("inbound".equals(direction)) { + acdService.enqueue(call); + } + + // 推送事件到前端 + eventPushService.pushCallEvent("CALL_RINGING", call); + } + + /** + * 处理通话接听事件 + */ + private void handleChannelAnswer(Map headers) { + String uuid = headers.get("Unique-ID"); + + log.info("通话接听: uuid={}", uuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.ANSWERED); + call.setAnswerTime(LocalDateTime.now()); + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_ANSWERED", call); + } + } + + /** + * 处理通道桥接事件(双方接通) + */ + private void handleChannelBridge(Map headers) { + String uuid = headers.get("Unique-ID"); + String otherUuid = headers.get("Other-Leg-Unique-ID"); + + log.info("通道桥接: uuid={}, otherUuid={}", uuid, otherUuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.BRIDGED); + call.setBridgeUuid(otherUuid); + call.setBridgeTime(LocalDateTime.now()); + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_BRIDGED", call); + } + } + + /** + * 处理取消桥接事件 + */ + private void handleChannelUnbridge(Map headers) { + String uuid = headers.get("Unique-ID"); + + log.info("取消桥接: uuid={}", uuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.UNBRIDGED); + callService.updateCall(call); + + eventPushService.pushCallEvent("CALL_UNBRIDGED", call); + } + } + + /** + * 处理挂断事件 + */ + private void handleChannelHangup(Map headers) { + String uuid = headers.get("Unique-ID"); + String hangupCause = headers.get("Hangup-Cause"); + + log.info("通话挂断: uuid={}, cause={}", uuid, hangupCause); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.HANGUP); + call.setHangupCause(hangupCause); + call.setHangupTime(LocalDateTime.now()); + + // 计算通话时长 + if (call.getAnswerTime() != null) { + long duration = java.time.Duration.between( + call.getAnswerTime(), call.getHangupTime()).getSeconds(); + call.setDuration(duration); + } + + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_HANGUP", call); + + // 更新坐席状态 + if (call.getAgentId() != null) { + acdService.onCallEnd(call.getAgentId(), call); + } + } + } + + /** + * 处理挂断完成事件(获取完整话单) + */ + private void handleChannelHangupComplete(Map headers) { + String uuid = headers.get("Unique-ID"); + String billsec = headers.get("variable_billsec"); + String duration = headers.get("variable_duration"); + String recordingPath = headers.get("variable_recording_path"); + + log.info("通话结束: uuid={}, billsec={}, recording={}", + uuid, billsec, recordingPath); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setBillsec(billsec != null ? Long.parseLong(billsec) : 0); + call.setRecordingPath(recordingPath); + callService.updateCall(call); + + // 推送话单 + eventPushService.pushCdr(call); + } + } + + /** + * 处理DTMF按键事件 + */ + private void handleDtmf(Map headers) { + String uuid = headers.get("Unique-ID"); + String digit = headers.get("DTMF-Digit"); + + log.info("DTMF按键: uuid={}, digit={}", uuid, digit); + + // 推送按键事件 + eventPushService.pushDtmf(uuid, digit); + } + + /** + * 处理录音开始事件 + */ + private void handleRecordStart(Map headers) { + String uuid = headers.get("Unique-ID"); + String recordPath = headers.get("Record-File-Path"); + + log.info("开始录音: uuid={}, path={}", uuid, recordPath); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setRecordingPath(recordPath); + callService.updateCall(call); + } + } + + /** + * 处理录音停止事件 + */ + private void handleRecordStop(Map headers) { + String uuid = headers.get("Unique-ID"); + String recordPath = headers.get("Record-File-Path"); + + log.info("停止录音: uuid={}, path={}", uuid, recordPath); + } + + /** + * 处理自定义事件 + */ + private void handleCustomEvent(Map headers) { + String subclass = headers.get("Event-Subclass"); + log.debug("自定义事件: subclass={}", subclass); + + // 处理特定的自定义事件 + if ("callcenter::info".equals(subclass)) { + // 处理呼叫中心相关事件 + } + } +} +``` + +#### 3.2.5 呼叫服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * 呼叫服务 + * 提供呼叫控制相关功能 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CallService { + + private final EslConnectionManager eslManager; + private final RedisTemplate redisTemplate; + + private static final String CALL_KEY_PREFIX = "call:"; + private static final int CALL_EXPIRE_HOURS = 24; + + /** + * 发起外呼 + * @param agentId 坐席ID + * @param callerNumber 主叫号码(外显号码) + * @param calleeNumber 被叫号码 + * @return 呼叫UUID + */ + public String originate(String agentId, String callerNumber, String calleeNumber) { + log.info("发起外呼: agent={}, caller={}, callee={}", + agentId, callerNumber, calleeNumber); + + // 获取坐席分机号 + Agent agent = getAgent(agentId); + if (agent == null || agent.getState() != AgentState.READY) { + throw new RuntimeException("坐席不可用"); + } + + // 构建originate命令 + // originate {变量}呼叫字符串 &应用(参数) + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("origination_caller_id_number=").append(callerNumber).append(","); + cmd.append("origination_caller_id_name=公司名称").append(","); + cmd.append("agent_id=").append(agentId).append(","); + cmd.append("direction=outbound").append(","); + cmd.append("RECORD_STEREO=true"); + cmd.append("}"); + + // A-leg: 先呼叫坐席分机 + cmd.append("user/").append(agent.getExtension()); + + // B-leg: 坐席接听后呼叫客户 + cmd.append(" &bridge({origination_caller_id_number=") + .append(callerNumber) + .append("}sofia/gateway/carrier_primary/") + .append(calleeNumber) + .append(")"); + + String result = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (result.startsWith("+OK")) { + String uuid = result.substring(4).trim(); + log.info("外呼成功: uuid={}", uuid); + return uuid; + } else { + log.error("外呼失败: {}", result); + throw new RuntimeException("外呼失败: " + result); + } + } + + /** + * 接听来电 + */ + public void answer(String uuid) { + log.info("接听来电: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_answer", uuid); + } + + /** + * 挂断通话 + */ + public void hangup(String uuid) { + log.info("挂断通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_kill", uuid); + } + + /** + * 挂断通话(带原因) + */ + public void hangup(String uuid, String cause) { + log.info("挂断通话: uuid={}, cause={}", uuid, cause); + eslManager.sendSyncApiCommand("uuid_kill", uuid + " " + cause); + } + + /** + * 保持通话 + */ + public void hold(String uuid) { + log.info("保持通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_hold", uuid); + } + + /** + * 取回保持 + */ + public void unhold(String uuid) { + log.info("取回通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_hold", "off " + uuid); + } + + /** + * 静音 + */ + public void mute(String uuid) { + log.info("静音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_audio", uuid + " start mute"); + } + + /** + * 取消静音 + */ + public void unmute(String uuid) { + log.info("取消静音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_audio", uuid + " stop"); + } + + /** + * 盲转(直接转接) + */ + public void blindTransfer(String uuid, String destination) { + log.info("盲转: uuid={}, dest={}", uuid, destination); + eslManager.sendSyncApiCommand("uuid_transfer", uuid + " " + destination); + } + + /** + * 咨询转(先咨询再转接) + */ + public String consultTransfer(String uuid, String destination) { + log.info("咨询转: uuid={}, dest={}", uuid, destination); + + // 先保持当前通话 + hold(uuid); + + // 发起咨询呼叫 + String consultCmd = "{origination_uuid=consult_" + uuid + "}user/" + destination; + String result = eslManager.sendSyncApiCommand("originate", + consultCmd + " &park()"); + + if (result.startsWith("+OK")) { + return result.substring(4).trim(); + } + throw new RuntimeException("咨询转失败"); + } + + /** + * 完成咨询转接 + */ + public void completeTransfer(String originalUuid, String consultUuid) { + log.info("完成咨询转: original={}, consult={}", originalUuid, consultUuid); + eslManager.sendSyncApiCommand("uuid_bridge", originalUuid + " " + consultUuid); + } + + /** + * 三方通话 + */ + public void conference(String uuid1, String uuid2, String conferenceName) { + log.info("三方通话: uuid1={}, uuid2={}, conf={}", uuid1, uuid2, conferenceName); + + // 将两个通道加入会议 + eslManager.sendSyncApiCommand("uuid_transfer", + uuid1 + " conference:" + conferenceName + " inline"); + eslManager.sendSyncApiCommand("uuid_transfer", + uuid2 + " conference:" + conferenceName + " inline"); + } + + /** + * 播放语音 + */ + public void playback(String uuid, String filePath) { + log.info("播放语音: uuid={}, file={}", uuid, filePath); + eslManager.sendSyncApiCommand("uuid_broadcast", + uuid + " " + filePath + " both"); + } + + /** + * 停止播放 + */ + public void stopPlayback(String uuid) { + log.info("停止播放: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_break", uuid); + } + + /** + * 开始录音 + */ + public void startRecording(String uuid, String filePath) { + log.info("开始录音: uuid={}, file={}", uuid, filePath); + eslManager.sendSyncApiCommand("uuid_record", uuid + " start " + filePath); + } + + /** + * 停止录音 + */ + public void stopRecording(String uuid, String filePath) { + log.info("停止录音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_record", uuid + " stop " + filePath); + } + + /** + * 桥接两个通道 + */ + public void bridge(String uuid1, String uuid2) { + log.info("桥接通道: uuid1={}, uuid2={}", uuid1, uuid2); + eslManager.sendSyncApiCommand("uuid_bridge", uuid1 + " " + uuid2); + } + + /** + * 设置通道变量 + */ + public void setVariable(String uuid, String name, String value) { + eslManager.sendSyncApiCommand("uuid_setvar", + uuid + " " + name + " " + value); + } + + /** + * 获取通道变量 + */ + public String getVariable(String uuid, String name) { + return eslManager.sendSyncApiCommand("uuid_getvar", uuid + " " + name); + } + + // ============ 呼叫记录管理 ============ + + /** + * 保存呼叫记录 + */ + public void saveCall(Call call) { + String key = CALL_KEY_PREFIX + call.getUuid(); + redisTemplate.opsForValue().set(key, call, CALL_EXPIRE_HOURS, TimeUnit.HOURS); + } + + /** + * 获取呼叫记录 + */ + public Call getCallByUuid(String uuid) { + String key = CALL_KEY_PREFIX + uuid; + return (Call) redisTemplate.opsForValue().get(key); + } + + /** + * 更新呼叫记录 + */ + public void updateCall(Call call) { + saveCall(call); + } + + /** + * 获取坐席信息 + */ + private Agent getAgent(String agentId) { + // TODO: 从坐席服务获取 + return null; + } +} +``` + +#### 3.2.6 ACD话务分配服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * ACD话务分配服务 + * 实现多种分配策略:历史服务优先、最长空闲优先、轮询 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AcdService { + + private final EslConnectionManager eslManager; + private final RedisTemplate redisTemplate; + private final AgentService agentService; + private final CallService callService; + + // 队列Key前缀 + private static final String QUEUE_KEY_PREFIX = "queue:"; + // 坐席最后服务客户映射 + private static final String AGENT_CUSTOMER_MAP_KEY = "agent:customer:map"; + // 坐席最后空闲时间 + private static final String AGENT_IDLE_TIME_KEY = "agent:idle:time:"; + // 轮询索引 + private final Map roundRobinIndex = new ConcurrentHashMap<>(); + + /** + * 呼叫入队 + */ + public void enqueue(Call call) { + String skillGroup = call.getSkillGroup(); + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + QueueItem item = new QueueItem(); + item.setCallUuid(call.getUuid()); + item.setCallerNumber(call.getCallerNumber()); + item.setSkillGroup(skillGroup); + item.setEnqueueTime(LocalDateTime.now()); + item.setPriority(calculatePriority(call)); + + // 使用SortedSet,按优先级和入队时间排序 + double score = item.getPriority() * 1000000000L + + System.currentTimeMillis() / 1000; + redisTemplate.opsForZSet().add(queueKey, item, score); + + log.info("呼叫入队: uuid={}, queue={}, priority={}", + call.getUuid(), skillGroup, item.getPriority()); + + // 尝试立即分配 + tryAssign(skillGroup); + } + + /** + * 计算优先级(数字越小优先级越高) + */ + private int calculatePriority(Call call) { + // VIP客户优先级高 + if ("VIP".equals(call.getCustomerLevel())) { + return 1; + } + // 投诉优先 + if ("complaint".equals(call.getBusinessType())) { + return 2; + } + // 普通客户 + return 5; + } + + /** + * 尝试分配队列中的呼叫 + */ + public void tryAssign(String skillGroup) { + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + // 获取可用坐席 + List availableAgents = agentService.getAvailableAgents(skillGroup); + if (availableAgents.isEmpty()) { + log.debug("技能组{}无可用坐席", skillGroup); + return; + } + + // 获取队列中的呼叫 + Set queueItems = redisTemplate.opsForZSet().range(queueKey, 0, 0); + if (queueItems == null || queueItems.isEmpty()) { + log.debug("技能组{}队列为空", skillGroup); + return; + } + + QueueItem item = (QueueItem) queueItems.iterator().next(); + Call call = callService.getCallByUuid(item.getCallUuid()); + + if (call == null || call.getState() == CallState.HANGUP) { + // 呼叫已结束,移除队列 + redisTemplate.opsForZSet().remove(queueKey, item); + tryAssign(skillGroup); // 继续处理下一个 + return; + } + + // 选择坐席 + Agent selectedAgent = selectAgent(availableAgents, call); + + if (selectedAgent != null) { + // 从队列移除 + redisTemplate.opsForZSet().remove(queueKey, item); + + // 分配呼叫 + assignCallToAgent(call, selectedAgent); + } + } + + /** + * 选择坐席(多策略) + */ + private Agent selectAgent(List agents, Call call) { + if (agents.isEmpty()) { + return null; + } + + // 策略1: 历史服务优先 + Agent lastAgent = findLastServingAgent(call.getCallerNumber(), agents); + if (lastAgent != null) { + log.info("历史服务优先: 客户{}分配给上次服务坐席{}", + call.getCallerNumber(), lastAgent.getId()); + return lastAgent; + } + + // 策略2: 最长空闲优先 + Agent longestIdleAgent = findLongestIdleAgent(agents); + if (longestIdleAgent != null) { + log.info("最长空闲优先: 选择坐席{}", longestIdleAgent.getId()); + return longestIdleAgent; + } + + // 策略3: 轮询 + Agent roundRobinAgent = selectByRoundRobin(agents, call.getSkillGroup()); + log.info("轮询分配: 选择坐席{}", roundRobinAgent.getId()); + return roundRobinAgent; + } + + /** + * 查找上次服务该客户的坐席 + */ + private Agent findLastServingAgent(String customerNumber, List agents) { + String lastAgentId = (String) redisTemplate.opsForHash() + .get(AGENT_CUSTOMER_MAP_KEY, customerNumber); + + if (lastAgentId != null) { + return agents.stream() + .filter(a -> a.getId().equals(lastAgentId)) + .findFirst() + .orElse(null); + } + return null; + } + + /** + * 查找空闲时间最长的坐席 + */ + private Agent findLongestIdleAgent(List agents) { + return agents.stream() + .max(Comparator.comparing(agent -> { + String key = AGENT_IDLE_TIME_KEY + agent.getId(); + Long idleTime = (Long) redisTemplate.opsForValue().get(key); + return idleTime != null ? idleTime : 0L; + })) + .orElse(null); + } + + /** + * 轮询选择坐席 + */ + private Agent selectByRoundRobin(List agents, String skillGroup) { + int currentIndex = roundRobinIndex.getOrDefault(skillGroup, 0); + int nextIndex = (currentIndex + 1) % agents.size(); + roundRobinIndex.put(skillGroup, nextIndex); + return agents.get(currentIndex); + } + + /** + * 分配呼叫给坐席 + */ + private void assignCallToAgent(Call call, Agent agent) { + log.info("分配呼叫: uuid={} -> agent={}", call.getUuid(), agent.getId()); + + // 更新坐席状态 + agentService.updateAgentState(agent.getId(), AgentState.RINGING); + + // 更新呼叫信息 + call.setAgentId(agent.getId()); + call.setAgentExtension(agent.getExtension()); + callService.updateCall(call); + + // 将呼叫转接到坐席分机 + // 方式1: 使用uuid_transfer + eslManager.sendSyncApiCommand("uuid_transfer", + call.getUuid() + " " + agent.getExtension() + " XML default"); + + // 方式2: 使用originate + bridge(更灵活) + // String bridgeCmd = String.format( + // "{origination_uuid=%s,agent_id=%s}user/%s", + // UUID.randomUUID(), agent.getId(), agent.getExtension()); + // eslManager.sendSyncApiCommand("uuid_broadcast", + // call.getUuid() + " bridge::" + bridgeCmd + " both"); + + // 记录坐席-客户关系(用于历史服务优先) + redisTemplate.opsForHash().put(AGENT_CUSTOMER_MAP_KEY, + call.getCallerNumber(), agent.getId()); + } + + /** + * 通话结束处理 + */ + public void onCallEnd(String agentId, Call call) { + log.info("通话结束: agent={}, call={}", agentId, call.getUuid()); + + // 更新坐席状态为后处理 + agentService.updateAgentState(agentId, AgentState.WRAP_UP); + + // 设置后处理超时(30秒后自动置闲) + // 由定时任务处理 + } + + /** + * 坐席置闲时记录空闲时间 + */ + public void onAgentReady(String agentId, String skillGroup) { + String key = AGENT_IDLE_TIME_KEY + agentId; + redisTemplate.opsForValue().set(key, System.currentTimeMillis()); + + // 尝试分配队列中的呼叫 + tryAssign(skillGroup); + } + + /** + * 定时处理队列 + */ + @Scheduled(fixedDelay = 1000) + public void processQueues() { + // 获取所有技能组 + Set skillGroups = agentService.getAllSkillGroups(); + for (String skillGroup : skillGroups) { + tryAssign(skillGroup); + } + } + + /** + * 获取队列状态 + */ + public QueueStatus getQueueStatus(String skillGroup) { + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + QueueStatus status = new QueueStatus(); + status.setSkillGroup(skillGroup); + status.setWaitingCount(redisTemplate.opsForZSet().size(queueKey)); + status.setAvailableAgents(agentService.getAvailableAgents(skillGroup).size()); + + // 计算最长等待时间 + Set items = redisTemplate.opsForZSet().range(queueKey, 0, 0); + if (items != null && !items.isEmpty()) { + QueueItem firstItem = (QueueItem) items.iterator().next(); + long waitSeconds = java.time.Duration.between( + firstItem.getEnqueueTime(), LocalDateTime.now()).getSeconds(); + status.setLongestWaitTime(waitSeconds); + } + + return status; + } + + /** + * 溢出处理 + * 当技能组无可用坐席且等待时间超过阈值时,溢出到其他技能组 + */ + @Scheduled(fixedDelay = 5000) + public void handleOverflow() { + // 获取溢出配置 + Map overflowConfig = getOverflowConfig(); + + for (Map.Entry entry : overflowConfig.entrySet()) { + String sourceGroup = entry.getKey(); + String targetGroup = entry.getValue(); + + String queueKey = QUEUE_KEY_PREFIX + sourceGroup; + Set items = redisTemplate.opsForZSet().range(queueKey, 0, -1); + + if (items == null || items.isEmpty()) { + continue; + } + + // 检查是否需要溢出 + List sourceAgents = agentService.getAvailableAgents(sourceGroup); + List targetAgents = agentService.getAvailableAgents(targetGroup); + + if (sourceAgents.isEmpty() && !targetAgents.isEmpty()) { + for (Object obj : items) { + QueueItem item = (QueueItem) obj; + long waitSeconds = java.time.Duration.between( + item.getEnqueueTime(), LocalDateTime.now()).getSeconds(); + + // 等待超过60秒,溢出 + if (waitSeconds > 60) { + log.info("溢出处理: uuid={}, {}->{}", + item.getCallUuid(), sourceGroup, targetGroup); + + // 移出原队列 + redisTemplate.opsForZSet().remove(queueKey, item); + + // 加入目标队列 + item.setSkillGroup(targetGroup); + String targetQueueKey = QUEUE_KEY_PREFIX + targetGroup; + double score = item.getPriority() * 1000000000L + + System.currentTimeMillis() / 1000; + redisTemplate.opsForZSet().add(targetQueueKey, item, score); + + // 尝试分配 + tryAssign(targetGroup); + } + } + } + } + } + + private Map getOverflowConfig() { + // 配置:源技能组 -> 目标技能组 + Map config = new HashMap<>(); + config.put("rescue_group", "default_group"); + config.put("housekeeping_group", "default_group"); + config.put("driver_group", "default_group"); + return config; + } +} +``` + +#### 3.2.7 坐席服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import com.callcenter.websocket.EventPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 坐席服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentService { + + private final RedisTemplate redisTemplate; + private final EslConnectionManager eslManager; + private final EventPushService eventPushService; + private final AcdService acdService; + + private static final String AGENT_KEY_PREFIX = "agent:"; + private static final String AGENT_SET_KEY = "agents:all"; + private static final String SKILL_GROUP_AGENTS_KEY = "skillgroup:agents:"; + + /** + * 坐席签入 + */ + public void signIn(String agentId, String extension, List skillGroups) { + log.info("坐席签入: agentId={}, extension={}, skills={}", + agentId, extension, skillGroups); + + // 检查分机是否注册 + String regStatus = eslManager.sendSyncApiCommand("sofia_contact", + "internal/" + extension + "@${domain}"); + if (regStatus.contains("error")) { + throw new RuntimeException("分机未注册: " + extension); + } + + Agent agent = new Agent(); + agent.setId(agentId); + agent.setExtension(extension); + agent.setSkillGroups(skillGroups); + agent.setState(AgentState.NOT_READY); + agent.setSignInTime(LocalDateTime.now()); + + // 保存坐席信息 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.opsForValue().set(key, agent); + + // 加入坐席集合 + redisTemplate.opsForSet().add(AGENT_SET_KEY, agentId); + + // 加入技能组 + for (String skillGroup : skillGroups) { + redisTemplate.opsForSet().add(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId); + } + + // 推送状态变更 + eventPushService.pushAgentState(agent); + } + + /** + * 坐席签出 + */ + public void signOut(String agentId) { + log.info("坐席签出: agentId={}", agentId); + + Agent agent = getAgent(agentId); + if (agent == null) { + return; + } + + // 检查是否在通话中 + if (agent.getState() == AgentState.BUSY || + agent.getState() == AgentState.RINGING) { + throw new RuntimeException("坐席正在通话中,无法签出"); + } + + // 从技能组移除 + for (String skillGroup : agent.getSkillGroups()) { + redisTemplate.opsForSet().remove(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId); + } + + // 从坐席集合移除 + redisTemplate.opsForSet().remove(AGENT_SET_KEY, agentId); + + // 删除坐席信息 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.delete(key); + + // 推送签出事件 + agent.setState(AgentState.OFFLINE); + eventPushService.pushAgentState(agent); + } + + /** + * 坐席置闲 + */ + public void ready(String agentId) { + log.info("坐席置闲: agentId={}", agentId); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + updateAgentState(agentId, AgentState.READY); + + // 通知ACD有坐席可用 + for (String skillGroup : agent.getSkillGroups()) { + acdService.onAgentReady(agentId, skillGroup); + } + } + + /** + * 坐席置忙 + */ + public void notReady(String agentId, String reason) { + log.info("坐席置忙: agentId={}, reason={}", agentId, reason); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + agent.setNotReadyReason(reason); + updateAgentState(agentId, AgentState.NOT_READY); + } + + /** + * 坐席小休 + */ + public void pause(String agentId, String reason) { + log.info("坐席小休: agentId={}, reason={}", agentId, reason); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + agent.setPauseReason(reason); + updateAgentState(agentId, AgentState.PAUSED); + } + + /** + * 更新坐席状态 + */ + public void updateAgentState(String agentId, AgentState newState) { + Agent agent = getAgent(agentId); + if (agent == null) { + return; + } + + AgentState oldState = agent.getState(); + agent.setState(newState); + agent.setStateTime(LocalDateTime.now()); + + // 保存 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.opsForValue().set(key, agent); + + log.info("坐席状态变更: agent={}, {} -> {}", agentId, oldState, newState); + + // 推送状态变更 + eventPushService.pushAgentState(agent); + } + + /** + * 获取坐席信息 + */ + public Agent getAgent(String agentId) { + String key = AGENT_KEY_PREFIX + agentId; + return (Agent) redisTemplate.opsForValue().get(key); + } + + /** + * 获取技能组可用坐席 + */ + public List getAvailableAgents(String skillGroup) { + Set agentIds = redisTemplate.opsForSet() + .members(SKILL_GROUP_AGENTS_KEY + skillGroup); + + if (agentIds == null || agentIds.isEmpty()) { + return Collections.emptyList(); + } + + return agentIds.stream() + .map(id -> getAgent((String) id)) + .filter(agent -> agent != null && agent.getState() == AgentState.READY) + .collect(Collectors.toList()); + } + + /** + * 获取所有技能组 + */ + public Set getAllSkillGroups() { + Set keys = redisTemplate.keys(SKILL_GROUP_AGENTS_KEY + "*"); + if (keys == null) { + return Collections.emptySet(); + } + return keys.stream() + .map(key -> key.replace(SKILL_GROUP_AGENTS_KEY, "")) + .collect(Collectors.toSet()); + } + + /** + * 获取所有坐席状态(监控大屏) + */ + public List getAllAgents() { + Set agentIds = redisTemplate.opsForSet().members(AGENT_SET_KEY); + if (agentIds == null) { + return Collections.emptyList(); + } + return agentIds.stream() + .map(id -> getAgent((String) id)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 获取坐席状态统计 + */ + public Map getAgentStateStats() { + return getAllAgents().stream() + .collect(Collectors.groupingBy(Agent::getState, Collectors.counting())); + } +} +``` + +### 3.3 Go ESL开发 + +如果您的团队更熟悉Go语言,以下是Go版本的ESL开发示例: + +#### 3.3.1 项目结构 + +``` +callcenter-cti-go/ +├── go.mod +├── go.sum +├── main.go +├── config/ +│ └── config.go +├── esl/ +│ ├── client.go # ESL客户端 +│ ├── event.go # 事件定义 +│ └── handler.go # 事件处理器 +├── service/ +│ ├── agent.go # 坐席服务 +│ ├── call.go # 呼叫服务 +│ └── acd.go # ACD服务 +├── model/ +│ └── models.go # 数据模型 +├── api/ +│ ├── router.go # 路由 +│ ├── agent_handler.go # 坐席API +│ └── call_handler.go # 呼叫API +└── websocket/ + └── hub.go # WebSocket推送 +``` + +#### 3.3.2 ESL客户端 + +```go +// esl/client.go +package esl + +import ( + "bufio" + "fmt" + "log" + "net" + "net/textproto" + "strconv" + "strings" + "sync" + "time" +) + +// ESL客户端 +type Client struct { + conn net.Conn + reader *textproto.Reader + writer *bufio.Writer + mu sync.Mutex + connected bool + password string + eventChan chan *Event + handlers []EventHandler + reconnect bool +} + +// 事件处理器接口 +type EventHandler interface { + Handle(event *Event) +} + +// 创建ESL客户端 +func NewClient(host string, port int, password string) (*Client, error) { + client := &Client{ + password: password, + eventChan: make(chan *Event, 1000), + handlers: make([]EventHandler, 0), + reconnect: true, + } + + err := client.connect(host, port) + if err != nil { + return nil, err + } + + // 启动事件读取协程 + go client.readLoop() + + // 启动事件分发协程 + go client.dispatchLoop() + + return client, nil +} + +// 连接FreeSWITCH +func (c *Client) connect(host string, port int) error { + addr := fmt.Sprintf("%s:%d", host, port) + + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("连接失败: %v", err) + } + + c.conn = conn + c.reader = textproto.NewReader(bufio.NewReader(conn)) + c.writer = bufio.NewWriter(conn) + + // 读取欢迎消息 + _, err = c.readMessage() + if err != nil { + return err + } + + // 认证 + err = c.auth() + if err != nil { + return err + } + + c.connected = true + log.Println("ESL连接成功") + + return nil +} + +// 认证 +func (c *Client) auth() error { + _, err := c.sendCommand(fmt.Sprintf("auth %s", c.password)) + if err != nil { + return fmt.Errorf("认证失败: %v", err) + } + return nil +} + +// 发送命令 +func (c *Client) sendCommand(command string) (*Message, error) { + c.mu.Lock() + defer c.mu.Unlock() + + _, err := c.writer.WriteString(command + "\n\n") + if err != nil { + return nil, err + } + c.writer.Flush() + + return c.readMessage() +} + +// 发送API命令 +func (c *Client) API(command string, args string) (string, error) { + cmd := fmt.Sprintf("api %s %s", command, args) + msg, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return msg.Body, nil +} + +// 发送后台API命令 +func (c *Client) BgAPI(command string, args string) (string, error) { + cmd := fmt.Sprintf("bgapi %s %s", command, args) + msg, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return msg.GetHeader("Job-UUID"), nil +} + +// 订阅事件 +func (c *Client) Subscribe(events ...string) error { + eventStr := strings.Join(events, " ") + _, err := c.sendCommand(fmt.Sprintf("event plain %s", eventStr)) + return err +} + +// 订阅所有事件 +func (c *Client) SubscribeAll() error { + _, err := c.sendCommand("event plain ALL") + return err +} + +// 读取消息 +func (c *Client) readMessage() (*Message, error) { + msg := &Message{ + Headers: make(map[string]string), + } + + // 读取headers + for { + line, err := c.reader.ReadLine() + if err != nil { + return nil, err + } + + if line == "" { + break + } + + parts := strings.SplitN(line, ": ", 2) + if len(parts) == 2 { + msg.Headers[parts[0]] = parts[1] + } + } + + // 读取body + if lenStr := msg.GetHeader("Content-Length"); lenStr != "" { + length, _ := strconv.Atoi(lenStr) + if length > 0 { + body := make([]byte, length) + _, err := c.reader.R.Read(body) + if err != nil { + return nil, err + } + msg.Body = string(body) + } + } + + return msg, nil +} + +// 事件读取循环 +func (c *Client) readLoop() { + for c.connected { + msg, err := c.readMessage() + if err != nil { + log.Printf("读取消息错误: %v", err) + c.connected = false + if c.reconnect { + c.tryReconnect() + } + return + } + + contentType := msg.GetHeader("Content-Type") + if contentType == "text/event-plain" { + event := parseEvent(msg) + c.eventChan <- event + } + } +} + +// 事件分发循环 +func (c *Client) dispatchLoop() { + for event := range c.eventChan { + for _, handler := range c.handlers { + go handler.Handle(event) + } + } +} + +// 尝试重连 +func (c *Client) tryReconnect() { + for i := 0; i < 10; i++ { + time.Sleep(5 * time.Second) + log.Printf("尝试重连ESL (第%d次)...", i+1) + + // TODO: 重新连接 + } +} + +// 添加事件处理器 +func (c *Client) AddHandler(handler EventHandler) { + c.handlers = append(c.handlers, handler) +} + +// 关闭连接 +func (c *Client) Close() { + c.connected = false + c.reconnect = false + if c.conn != nil { + c.conn.Close() + } + close(c.eventChan) +} + +// 消息结构 +type Message struct { + Headers map[string]string + Body string +} + +func (m *Message) GetHeader(name string) string { + if v, ok := m.Headers[name]; ok { + return v + } + return "" +} + +// 解析事件 +func parseEvent(msg *Message) *Event { + event := &Event{ + Headers: make(map[string]string), + } + + // 解析body中的headers + lines := strings.Split(msg.Body, "\n") + for _, line := range lines { + parts := strings.SplitN(line, ": ", 2) + if len(parts) == 2 { + // URL解码 + value := strings.ReplaceAll(parts[1], "%20", " ") + event.Headers[parts[0]] = value + } + } + + event.Name = event.GetHeader("Event-Name") + event.UUID = event.GetHeader("Unique-ID") + + return event +} +``` + +#### 3.3.3 事件定义 + +```go +// esl/event.go +package esl + +// 事件结构 +type Event struct { + Name string + UUID string + Headers map[string]string +} + +func (e *Event) GetHeader(name string) string { + if v, ok := e.Headers[name]; ok { + return v + } + return "" +} + +// 事件名称常量 +const ( + EventChannelCreate = "CHANNEL_CREATE" + EventChannelAnswer = "CHANNEL_ANSWER" + EventChannelBridge = "CHANNEL_BRIDGE" + EventChannelUnbridge = "CHANNEL_UNBRIDGE" + EventChannelHangup = "CHANNEL_HANGUP" + EventChannelHangupComplete = "CHANNEL_HANGUP_COMPLETE" + EventChannelState = "CHANNEL_STATE" + EventDTMF = "DTMF" + EventRecordStart = "RECORD_START" + EventRecordStop = "RECORD_STOP" + EventCustom = "CUSTOM" +) +``` + +#### 3.3.4 事件处理器 + +```go +// esl/handler.go +package esl + +import ( + "callcenter/service" + "log" +) + +// 呼叫中心事件处理器 +type CallCenterHandler struct { + callService *service.CallService + agentService *service.AgentService + acdService *service.AcdService +} + +func NewCallCenterHandler( + callService *service.CallService, + agentService *service.AgentService, + acdService *service.AcdService, +) *CallCenterHandler { + return &CallCenterHandler{ + callService: callService, + agentService: agentService, + acdService: acdService, + } +} + +func (h *CallCenterHandler) Handle(event *Event) { + switch event.Name { + case EventChannelCreate: + h.handleChannelCreate(event) + case EventChannelAnswer: + h.handleChannelAnswer(event) + case EventChannelBridge: + h.handleChannelBridge(event) + case EventChannelHangup: + h.handleChannelHangup(event) + case EventChannelHangupComplete: + h.handleChannelHangupComplete(event) + case EventDTMF: + h.handleDTMF(event) + } +} + +func (h *CallCenterHandler) handleChannelCreate(event *Event) { + uuid := event.UUID + direction := event.GetHeader("Call-Direction") + callerNumber := event.GetHeader("Caller-Caller-ID-Number") + calleeNumber := event.GetHeader("Caller-Destination-Number") + + log.Printf("新呼叫: uuid=%s, direction=%s, caller=%s, callee=%s", + uuid, direction, callerNumber, calleeNumber) + + // 创建呼叫记录 + call := &service.Call{ + UUID: uuid, + Direction: direction, + CallerNumber: callerNumber, + CalleeNumber: calleeNumber, + State: "ringing", + } + + h.callService.SaveCall(call) + + // 呼入排队 + if direction == "inbound" { + skillGroup := event.GetHeader("variable_skill_group") + call.SkillGroup = skillGroup + h.acdService.Enqueue(call) + } +} + +func (h *CallCenterHandler) handleChannelAnswer(event *Event) { + uuid := event.UUID + log.Printf("通话接听: uuid=%s", uuid) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "answered" + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleChannelBridge(event *Event) { + uuid := event.UUID + otherUUID := event.GetHeader("Other-Leg-Unique-ID") + log.Printf("通道桥接: uuid=%s, other=%s", uuid, otherUUID) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "bridged" + call.BridgeUUID = otherUUID + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleChannelHangup(event *Event) { + uuid := event.UUID + hangupCause := event.GetHeader("Hangup-Cause") + log.Printf("通话挂断: uuid=%s, cause=%s", uuid, hangupCause) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "hangup" + call.HangupCause = hangupCause + h.callService.UpdateCall(call) + + // 更新坐席状态 + if call.AgentID != "" { + h.acdService.OnCallEnd(call.AgentID, call) + } + } +} + +func (h *CallCenterHandler) handleChannelHangupComplete(event *Event) { + uuid := event.UUID + billsec := event.GetHeader("variable_billsec") + recordingPath := event.GetHeader("variable_recording_path") + + log.Printf("通话结束: uuid=%s, billsec=%s, recording=%s", + uuid, billsec, recordingPath) + + call := h.callService.GetCall(uuid) + if call != nil { + call.Billsec = billsec + call.RecordingPath = recordingPath + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleDTMF(event *Event) { + uuid := event.UUID + digit := event.GetHeader("DTMF-Digit") + log.Printf("DTMF按键: uuid=%s, digit=%s", uuid, digit) +} +``` + +--- + +## 第四部分 呼叫中心场景开发实践 + +### 4.1 预测式外呼实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 预测式外呼服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PredictiveDialerService { + + private final EslConnectionManager eslManager; + private final AgentService agentService; + private final CallService callService; + + // 外呼任务 + private final Map activeTasks = new ConcurrentHashMap<>(); + // 外呼线程池 + private final ExecutorService dialerExecutor = Executors.newFixedThreadPool(10); + + /** + * 创建外呼任务 + */ + public String createTask(DialTaskRequest request) { + String taskId = UUID.randomUUID().toString(); + + DialTask task = new DialTask(); + task.setId(taskId); + task.setName(request.getName()); + task.setSkillGroup(request.getSkillGroup()); + task.setCallerIdNumber(request.getCallerIdNumber()); + task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers())); + task.setMaxConcurrent(request.getMaxConcurrent()); + task.setState(TaskState.CREATED); + task.setStats(new DialStats()); + + activeTasks.put(taskId, task); + log.info("创建外呼任务: taskId={}, name={}, numbers={}", + taskId, request.getName(), request.getNumbers().size()); + + return taskId; + } + + /** + * 启动任务 + */ + public void startTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task == null) { + throw new RuntimeException("任务不存在"); + } + + task.setState(TaskState.RUNNING); + log.info("启动外呼任务: {}", taskId); + + // 启动外呼循环 + dialerExecutor.submit(() -> dialLoop(task)); + } + + /** + * 暂停任务 + */ + public void pauseTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null) { + task.setState(TaskState.PAUSED); + log.info("暂停外呼任务: {}", taskId); + } + } + + /** + * 恢复任务 + */ + public void resumeTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null && task.getState() == TaskState.PAUSED) { + task.setState(TaskState.RUNNING); + dialerExecutor.submit(() -> dialLoop(task)); + log.info("恢复外呼任务: {}", taskId); + } + } + + /** + * 停止任务 + */ + public void stopTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null) { + task.setState(TaskState.STOPPED); + log.info("停止外呼任务: {}", taskId); + } + } + + /** + * 外呼循环 + */ + private void dialLoop(DialTask task) { + log.info("开始外呼循环: taskId={}", task.getId()); + + while (task.getState() == TaskState.RUNNING && !task.getNumbers().isEmpty()) { + try { + // 计算应该发起的呼叫数 + int dialCount = calculateDialCount(task); + + if (dialCount <= 0) { + // 没有空闲坐席,等待 + Thread.sleep(1000); + continue; + } + + // 发起外呼 + for (int i = 0; i < dialCount; i++) { + String number = task.getNumbers().poll(); + if (number == null) { + break; + } + + dial(task, number); + } + + // 短暂等待,控制拨打速率 + Thread.sleep(500); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error("外呼异常: {}", e.getMessage(), e); + } + } + + // 任务完成 + if (task.getNumbers().isEmpty()) { + task.setState(TaskState.COMPLETED); + } + + log.info("外呼循环结束: taskId={}, state={}", task.getId(), task.getState()); + } + + /** + * 计算应该发起的呼叫数(预测算法) + */ + private int calculateDialCount(DialTask task) { + // 获取可用坐席数 + List availableAgents = agentService.getAvailableAgents(task.getSkillGroup()); + int availableAgentCount = availableAgents.size(); + + if (availableAgentCount == 0) { + return 0; + } + + // 当前正在拨打的数量 + int currentDialing = task.getStats().getCurrentDialing().get(); + + // 历史接通率(动态计算) + double connectRate = calculateConnectRate(task); + + // 预测式算法: + // 目标:让坐席接到电话时,刚好有客户接通 + // 预测需要拨打数 = 空闲坐席数 / 接通率 * 系数 + double factor = 1.2; // 超拨系数,避免坐席空闲 + int predictedDial = (int) Math.ceil(availableAgentCount / connectRate * factor); + + // 限制最大并发 + int maxAllowed = task.getMaxConcurrent() - currentDialing; + + return Math.min(predictedDial, maxAllowed); + } + + /** + * 计算接通率 + */ + private double calculateConnectRate(DialTask task) { + DialStats stats = task.getStats(); + int total = stats.getTotalDialed().get(); + int connected = stats.getConnected().get(); + + if (total < 10) { + // 样本太少,使用默认值 + return 0.3; + } + + double rate = (double) connected / total; + // 限制范围 + return Math.max(0.1, Math.min(0.8, rate)); + } + + /** + * 发起单个外呼 + */ + private void dial(DialTask task, String number) { + log.info("外呼: taskId={}, number={}", task.getId(), number); + + task.getStats().getTotalDialed().incrementAndGet(); + task.getStats().getCurrentDialing().incrementAndGet(); + + try { + // 构建originate命令 + // 先拨打客户,客户接听后转到队列等待坐席 + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("origination_caller_id_number=").append(task.getCallerIdNumber()).append(","); + cmd.append("task_id=").append(task.getId()).append(","); + cmd.append("dial_number=").append(number).append(","); + cmd.append("dialer_mode=predictive"); + cmd.append("}"); + + // 通过网关外呼 + cmd.append("sofia/gateway/carrier_primary/").append(number); + + // 客户接听后的处理 + cmd.append(" &lua(predictive_handler.lua)"); + + String result = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (!result.startsWith("+OK")) { + log.warn("外呼失败: number={}, result={}", number, result); + task.getStats().getFailed().incrementAndGet(); + } + + } catch (Exception e) { + log.error("外呼异常: number={}", number, e); + task.getStats().getFailed().incrementAndGet(); + } finally { + task.getStats().getCurrentDialing().decrementAndGet(); + } + } + + /** + * 客户接听回调(由事件触发) + */ + public void onCustomerAnswer(String uuid, String taskId) { + DialTask task = activeTasks.get(taskId); + if (task == null) { + eslManager.sendSyncApiCommand("uuid_kill", uuid + " NO_ROUTE_DESTINATION"); + return; + } + + task.getStats().getConnected().incrementAndGet(); + + // 查找可用坐席 + List agents = agentService.getAvailableAgents(task.getSkillGroup()); + + if (agents.isEmpty()) { + // 无可用坐席,播放等待音乐 + eslManager.sendSyncApiCommand("uuid_broadcast", + uuid + " ivr/please_wait.wav both"); + + // 放入等待队列 + // TODO: 实现等待逻辑 + + // 超时处理 + task.getStats().getAbandoned().incrementAndGet(); + } else { + // 转接坐席 + Agent agent = agents.get(0); + agentService.updateAgentState(agent.getId(), AgentState.RINGING); + + eslManager.sendSyncApiCommand("uuid_transfer", + uuid + " " + agent.getExtension() + " XML default"); + + task.getStats().getTransferred().incrementAndGet(); + } + } + + /** + * 获取任务统计 + */ + public DialStats getTaskStats(String taskId) { + DialTask task = activeTasks.get(taskId); + return task != null ? task.getStats() : null; + } +} + +/** + * 外呼任务 + */ +@Data +class DialTask { + private String id; + private String name; + private String skillGroup; + private String callerIdNumber; + private BlockingQueue numbers; + private int maxConcurrent; + private TaskState state; + private DialStats stats; +} + +/** + * 任务状态 + */ +enum TaskState { + CREATED, RUNNING, PAUSED, STOPPED, COMPLETED +} + +/** + * 外呼统计 + */ +@Data +class DialStats { + private AtomicInteger totalDialed = new AtomicInteger(0); + private AtomicInteger currentDialing = new AtomicInteger(0); + private AtomicInteger connected = new AtomicInteger(0); + private AtomicInteger transferred = new AtomicInteger(0); + private AtomicInteger failed = new AtomicInteger(0); + private AtomicInteger abandoned = new AtomicInteger(0); +} +``` + +### 4.2 机器人外呼实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import com.callcenter.tts.TtsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.*; + +/** + * 机器人外呼服务 + * 用于批量通知、回访、满意度调查 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RobotDialerService { + + private final EslConnectionManager eslManager; + private final TtsService ttsService; + + private final Map activeTasks = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newFixedThreadPool(20); + + /** + * 创建机器人外呼任务 + */ + public String createTask(RobotTaskRequest request) { + String taskId = UUID.randomUUID().toString(); + + RobotTask task = new RobotTask(); + task.setId(taskId); + task.setType(request.getType()); // NOTIFICATION, SURVEY, CALLBACK + task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers())); + task.setTtsText(request.getTtsText()); + task.setVariables(request.getVariables()); // 每个号码的变量 + task.setMaxConcurrent(request.getMaxConcurrent()); + task.setRetryTimes(request.getRetryTimes()); + task.setCollectDtmf(request.isCollectDtmf()); + task.setState(TaskState.CREATED); + task.setResults(new ConcurrentHashMap<>()); + + activeTasks.put(taskId, task); + return taskId; + } + + /** + * 启动任务 + */ + public void startTask(String taskId) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + throw new RuntimeException("任务不存在"); + } + + task.setState(TaskState.RUNNING); + executor.submit(() -> robotDialLoop(task)); + } + + /** + * 机器人外呼循环 + */ + private void robotDialLoop(RobotTask task) { + log.info("开始机器人外呼: taskId={}", task.getId()); + + Semaphore semaphore = new Semaphore(task.getMaxConcurrent()); + + while (task.getState() == TaskState.RUNNING) { + String number = task.getNumbers().poll(); + if (number == null) { + break; + } + + try { + semaphore.acquire(); + executor.submit(() -> { + try { + robotCall(task, number); + } finally { + semaphore.release(); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // 等待所有呼叫完成 + try { + semaphore.acquire(task.getMaxConcurrent()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + task.setState(TaskState.COMPLETED); + log.info("机器人外呼完成: taskId={}", task.getId()); + } + + /** + * 单个机器人呼叫 + */ + private void robotCall(RobotTask task, String number) { + log.info("机器人呼叫: taskId={}, number={}", task.getId(), number); + + RobotCallResult result = new RobotCallResult(); + result.setNumber(number); + result.setStartTime(System.currentTimeMillis()); + + try { + // 替换TTS文本中的变量 + String ttsText = replaceTtsVariables(task.getTtsText(), + task.getVariables().get(number)); + + // 生成语音文件 + String audioFile = ttsService.synthesize(ttsText); + + // 构建外呼命令 + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("task_id=").append(task.getId()).append(","); + cmd.append("dial_number=").append(number).append(","); + cmd.append("robot_mode=true").append(","); + cmd.append("audio_file=").append(audioFile); + cmd.append("}"); + + cmd.append("sofia/gateway/carrier_primary/").append(number); + + if (task.isCollectDtmf()) { + // 需要收集按键 + cmd.append(" &lua(robot_survey.lua)"); + } else { + // 纯播报 + cmd.append(" &playback(").append(audioFile).append(")"); + } + + String apiResult = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (apiResult.startsWith("+OK")) { + result.setStatus("SUCCESS"); + result.setCallUuid(apiResult.substring(4).trim()); + } else { + result.setStatus("FAILED"); + result.setError(apiResult); + + // 重试逻辑 + handleRetry(task, number); + } + + } catch (Exception e) { + log.error("机器人呼叫异常: number={}", number, e); + result.setStatus("ERROR"); + result.setError(e.getMessage()); + handleRetry(task, number); + } + + result.setEndTime(System.currentTimeMillis()); + task.getResults().put(number, result); + } + + /** + * 替换TTS变量 + */ + private String replaceTtsVariables(String template, Map vars) { + if (vars == null || vars.isEmpty()) { + return template; + } + + String result = template; + for (Map.Entry entry : vars.entrySet()) { + result = result.replace("${" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + /** + * 处理重试 + */ + private void handleRetry(RobotTask task, String number) { + int retryCount = task.getRetryCount().getOrDefault(number, 0); + if (retryCount < task.getRetryTimes()) { + task.getRetryCount().put(number, retryCount + 1); + task.getNumbers().offer(number); + log.info("加入重试队列: number={}, retry={}", number, retryCount + 1); + } + } + + /** + * 处理DTMF按键结果(由事件触发) + */ + public void onDtmfResult(String uuid, String taskId, String number, String digit) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + return; + } + + RobotCallResult result = task.getResults().get(number); + if (result != null) { + result.setDtmfDigit(digit); + log.info("收到按键: taskId={}, number={}, digit={}", taskId, number, digit); + } + } + + /** + * 获取任务结果 + */ + public List getTaskResults(String taskId) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + return Collections.emptyList(); + } + return new ArrayList<>(task.getResults().values()); + } +} +``` + +### 4.3 班长监控实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 班长监控服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SupervisorService { + + private final EslConnectionManager eslManager; + + /** + * 监听坐席通话 + * 班长可以听到双方说话,但双方听不到班长 + */ + public void spy(String supervisorExtension, String targetUuid) { + log.info("开始监听: supervisor={}, target={}", supervisorExtension, targetUuid); + + // 使用eavesdrop应用 + // originate user/supervisor &eavesdrop(target_uuid) + String cmd = String.format( + "{origination_caller_id_name=监听}user/%s &eavesdrop(%s)", + supervisorExtension, targetUuid + ); + + String result = eslManager.sendSyncApiCommand("originate", cmd); + + if (!result.startsWith("+OK")) { + throw new RuntimeException("监听失败: " + result); + } + } + + /** + * 强插通话 + * 班长加入通话,三方都可以听到 + */ + public void barge(String supervisorExtension, String targetUuid) { + log.info("强插通话: supervisor={}, target={}", supervisorExtension, targetUuid); + + // 使用三方会议实现 + String confName = "barge_" + targetUuid; + + // 1. 将目标通话转入会议 + eslManager.sendSyncApiCommand("uuid_transfer", + targetUuid + " conference:" + confName + "+flags{mute} inline"); + + // 2. 呼叫班长加入会议 + String cmd = String.format( + "{origination_caller_id_name=强插}user/%s &conference(%s)", + supervisorExtension, confName + ); + + eslManager.sendSyncApiCommand("originate", cmd); + } + + /** + * 强拆通话 + * 强制挂断坐席通话 + */ + public void hangup(String targetUuid, String reason) { + log.info("强拆通话: target={}, reason={}", targetUuid, reason); + + eslManager.sendSyncApiCommand("uuid_kill", targetUuid + " " + reason); + } + + /** + * 密语(耳语) + * 班长对坐席说话,客户听不到 + */ + public void whisper(String supervisorExtension, String agentUuid) { + log.info("密语: supervisor={}, agent={}", supervisorExtension, agentUuid); + + // 使用eavesdrop的whisper模式 + String cmd = String.format( + "{origination_caller_id_name=密语}user/%s &eavesdrop(%s whisper)", + supervisorExtension, agentUuid + ); + + eslManager.sendSyncApiCommand("originate", cmd); + } + + /** + * 获取实时通话列表 + */ + public String getActiveCalls() { + return eslManager.sendSyncApiCommand("show", "calls"); + } + + /** + * 获取坐席通道信息 + */ + public String getChannelInfo(String uuid) { + return eslManager.sendSyncApiCommand("uuid_dump", uuid); + } +} +``` + +--- + +## 第五部分 高可用部署方案 + +### 5.1 主备架构 + +``` + ┌─────────────────┐ + │ Keepalived │ + │ VIP漂移管理 │ + └────────┬────────┘ + │ + ┌───────────────────────────┴───────────────────────────┐ + │ VIP: 10.10.1.100 │ + │ (SIP/ESL统一入口) │ + └───────────────────────────┬───────────────────────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌────────────────────┐ + │ Master节点 │ 心跳检测 │ Backup节点 │ + │ 10.10.1.10 │◄──────────────────►│ 10.10.1.11 │ + ├────────────────────┤ ├────────────────────┤ + │ FreeSWITCH │ │ FreeSWITCH │ + │ CTI中间件 │ │ CTI中间件 │ + │ API服务 │ │ API服务 │ + └────────────────────┘ └────────────────────┘ + │ │ + │ 共享存储 / 数据同步 │ + │ │ + └─────────────────────┬─────────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MySQL 主从 │ │ Redis Sentinel │ │ NAS存储 │ +│ 10.10.2.10/11 │ │ 10.10.2.20-22 │ │ 10.10.2.30 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 5.2 Keepalived配置 + +**Master节点配置:** + +```bash +# /etc/keepalived/keepalived.conf (Master) + +global_defs { + router_id FS_MASTER +} + +# 检测FreeSWITCH状态的脚本 +vrrp_script check_freeswitch { + script "/etc/keepalived/check_freeswitch.sh" + interval 2 + weight -20 + fall 3 + rise 2 +} + +vrrp_instance VI_FS { + state MASTER + interface eth0 + virtual_router_id 51 + priority 100 + advert_int 1 + + authentication { + auth_type PASS + auth_pass your_password + } + + virtual_ipaddress { + 10.10.1.100/24 dev eth0 + } + + track_script { + check_freeswitch + } + + notify_master "/etc/keepalived/notify_master.sh" + notify_backup "/etc/keepalived/notify_backup.sh" + notify_fault "/etc/keepalived/notify_fault.sh" +} +``` + +**检测脚本:** + +```bash +#!/bin/bash +# /etc/keepalived/check_freeswitch.sh + +# 检测FreeSWITCH进程 +if ! pgrep -x "freeswitch" > /dev/null; then + exit 1 +fi + +# 检测ESL端口 +if ! nc -z localhost 8021; then + exit 1 +fi + +# 检测SIP端口 +if ! nc -z localhost 5060; then + exit 1 +fi + +# 执行简单API命令测试 +result=$(fs_cli -x "status" 2>/dev/null) +if [[ $? -ne 0 ]]; then + exit 1 +fi + +exit 0 +``` + +**主节点切换通知脚本:** + +```bash +#!/bin/bash +# /etc/keepalived/notify_master.sh + +# 记录日志 +echo "$(date): Becoming MASTER" >> /var/log/keepalived-state.log + +# 启动FreeSWITCH(如果未运行) +systemctl start freeswitch + +# 启动CTI服务 +systemctl start callcenter-cti + +# 发送告警通知 +curl -X POST "http://your-alert-api/notify" \ + -d "message=FreeSWITCH切换到Master节点" +``` + +### 5.3 配置同步方案 + +```bash +#!/bin/bash +# sync_config.sh - 配置同步脚本 + +MASTER_HOST="10.10.1.10" +BACKUP_HOST="10.10.1.11" +FS_CONF_DIR="/usr/local/freeswitch/conf" + +# 使用rsync同步配置 +sync_config() { + rsync -avz --delete \ + ${FS_CONF_DIR}/ \ + ${BACKUP_HOST}:${FS_CONF_DIR}/ + + # 通知备机重载配置 + ssh ${BACKUP_HOST} "fs_cli -x 'reloadxml'" +} + +# 监控配置变化 +inotifywait -m -r -e modify,create,delete ${FS_CONF_DIR} | while read path action file; do + echo "配置变化: $path$file ($action)" + sync_config +done +``` + +### 5.4 FreeSWITCH集群配置 + +对于需要更高并发的场景,可以部署FreeSWITCH集群: + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 第六部分 性能优化指南 + +### 6.1 FreeSWITCH优化 + +**系统级优化:** + +```bash +# /etc/security/limits.conf +freeswitch soft nofile 65536 +freeswitch hard nofile 65536 +freeswitch soft core unlimited +freeswitch hard core unlimited + +# /etc/sysctl.conf +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +net.core.rmem_default = 1048576 +net.core.wmem_default = 1048576 +net.core.netdev_max_backlog = 30000 +net.ipv4.tcp_max_syn_backlog = 10240 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 15 +``` + +**FreeSWITCH配置优化:** + +```xml + + + + + + + + + + + + + + + + +``` + +### 6.2 CTI中间件优化 + +```java +// 连接池配置 +@Configuration +public class ConnectionPoolConfig { + + @Bean + public ThreadPoolTaskExecutor eslEventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(32); + executor.setQueueCapacity(10000); + executor.setThreadNamePrefix("esl-event-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useClusterServers() + .addNodeAddress("redis://10.10.2.20:6379") + .addNodeAddress("redis://10.10.2.21:6379") + .addNodeAddress("redis://10.10.2.22:6379") + .setMasterConnectionPoolSize(64) + .setSlaveConnectionPoolSize(64); + return Redisson.create(config); + } +} +``` + +### 6.3 监控指标 + +```yaml +# prometheus配置 +scrape_configs: + - job_name: 'freeswitch' + static_configs: + - targets: ['10.10.1.10:9282', '10.10.1.11:9282'] + + - job_name: 'callcenter-cti' + static_configs: + - targets: ['10.10.1.10:8080', '10.10.1.11:8080'] + metrics_path: '/actuator/prometheus' +``` + +**关键监控指标:** + +| 指标 | 说明 | 阈值 | +|------|------|------| +| freeswitch_sessions_active | 当前活跃会话数 | <80%最大值 | +| freeswitch_sessions_per_second | 每秒新建会话数 | <100 | +| freeswitch_cpu_usage | CPU使用率 | <70% | +| freeswitch_memory_usage | 内存使用率 | <80% | +| esl_event_queue_size | 事件队列大小 | <5000 | +| acd_queue_wait_time | 平均等待时间 | <30s | +| agent_utilization | 坐席利用率 | 目标>70% | + +--- + +## 总结 + +本文档详细介绍了FreeSWITCH的技术架构和ESL开发实践,涵盖了: + +1. **核心概念**:Channel、Session、Event等基础概念 +2. **模块详解**:mod_sofia、Dialplan、ESL等核心模块 +3. **ESL开发**:Java和Go两种语言的开发示例 +4. **场景实践**:ACD、预测式外呼、机器人外呼、班长监控 +5. **高可用部署**:主备架构、Keepalived配置 +6. **性能优化**:系统优化、应用优化、监控指标 + +建议开发团队在正式开发前: +- 搭建FreeSWITCH测试环境熟悉操作 +- 仔细研读FreeSWITCH官方文档 +- 从简单场景(如点击拨号)开始,逐步实现复杂功能 +- 重视测试,特别是高可用切换测试和压力测试 diff --git a/cc-center/chat-📞 呼叫中心系统需求规划.txt b/cc-center/chat-📞 呼叫中心系统需求规划.txt new file mode 100644 index 0000000..c055215 --- /dev/null +++ b/cc-center/chat-📞 呼叫中心系统需求规划.txt @@ -0,0 +1,6953 @@ +### USER +你是一位资深的产品经理,对基于FreeSWITCH的呼叫中心系统有着丰富的产品经验。我现在想做一个企业的呼叫中心系统,除了可以实现基本的呼入呼出外,还要实现自动外呼和机器人客服。你可以向我问一些启发式的问题澄清需求,最终生成一份需求文档。 + +### ASSISTANT +您好!很高兴能帮助您规划这个呼叫中心系统。为了生成一份完整且符合您实际业务场景的需求文档,我需要通过一系列问题来深入了解您的需求。 + +让我们从最基础的业务背景开始: + +--- + +## 第一轮:业务背景与目标 + +**1. 关于您的企业和目标客户:** +- 这个呼叫中心系统是给**您自己企业内部使用**,还是作为**SaaS产品对外销售**给其他企业? +- 您的企业/目标客户属于哪个行业?(如:电商、金融、教育、医疗、政务热线等) + +**2. 关于规模预期:** +- 初期预计需要支持多少个**坐席(人工客服)**同时工作? +- 预计的**日呼叫量**大概是多少?(呼入+呼出) + +**3. 关于核心业务痛点:** +- 您目前为什么想要建设这个系统?是**从零开始建设**,还是**替换现有系统**? +- 如果是替换,现有系统最让您不满意的地方是什么? + +--- + +请先回答这几个基础问题,我会根据您的回答继续深入探讨呼入呼出、自动外呼、机器人客服等具体模块的细节需求。🎯 + +### USER +1.企业内部使用,企业属于服务行业,2.预计支持 150个坐席同时工作,日呼量50000 ,3.从零开始建设 + +### ASSISTANT +感谢您的回答!150坐席、日呼叫量5万是一个中大型呼叫中心的规模,服务行业的场景也非常典型。 + +--- + +## 第二轮:业务场景细化 + +**4. 关于服务业务类型:** +- 您的服务行业具体是哪类?(如:家政服务、汽车4S店/维修、物业管理、旅游预订、餐饮连锁、上门维修等) +- 您的客户群体主要是**ToC(个人消费者)**还是**ToB(企业客户)**? + +**5. 关于呼入呼出业务占比:** +- 目前预估的**呼入和呼出比例**大概是多少?(如:呼入70%呼出30%) +- 呼入的主要场景是什么?(如:咨询、投诉、预约、售后等) +- 呼出的主要场景是什么?(如:回访、营销推广、催收/催缴、通知提醒等) + +**6. 关于自动外呼的核心诉求:** +- 您希望自动外呼解决什么问题? + - [ ] 提高人工坐席效率(预测式外呼,减少等待) + - [ ] 完全无人化批量通知(如:短信+语音通知) + - [ ] 营销筛选(机器人先打,意向客户转人工) + - [ ] 其他:______ + +**7. 关于机器人客服的定位:** +- 您希望机器人客服承担什么角色? + - **呼入场景**:IVR导航、自助查询、常见问题解答、转人工前预处理? + - **呼出场景**:自动回访、满意度调查、标准化通知? +- 机器人是作为**第一道接待**(先机器人再转人工),还是**人工为主机器人辅助**? + +--- + +请您根据实际业务情况回答,这些信息将帮助我明确系统的功能优先级和架构设计方向。💡 + +### USER +服务行业类型:道路救援、家政服务,代驾服务,客户群体:ToC,呼入40%呼出60%,呼入的场景:叫服务,咨询、投诉,呼出场景:回访、营销推广、催收/催缴、通知提醒等,关于自动外呼的核心诉求:提高人工坐席效率和完全无人化批量通知;机器人客服的定位:**呼入场景**:IVR导航、常见问题解答、转人工前预处理,呼出场景:标准化通知、自动回访、满意度调查。先机器人再转人工 + +### ASSISTANT +非常清晰!您的业务场景很典型——**道路救援、家政、代驾**都是对**响应速度要求极高**的服务行业,而且ToC场景意味着客户体验至关重要。 + +--- + +## 第三轮:关键业务流程与系统集成 + +**8. 关于呼入核心流程(叫服务场景):** +- 当客户来电叫服务时,坐席需要**实时派单**给服务人员(救援师傅、家政阿姨、代驾司机)吗? +- 您是否已有**业务系统/派单系统/工单系统**需要与呼叫中心对接? + - 如果有,是什么系统?(自研、第三方SaaS如:易企秀、工单宝等) + - 如果没有,是否需要呼叫中心系统**内置工单模块**? + +**9. 关于客户来电识别:** +- 客户来电时,您希望系统能自动**识别客户身份**吗? + - [ ] 通过来电号码匹配历史客户资料 + - [ ] 弹出历史服务记录、订单记录 + - [ ] 显示客户标签(VIP、黑名单、投诉倾向等) +- 这意味着系统需要对接您的**CRM或会员系统**,您目前有这类系统吗? + +**10. 关于服务工作时间:** +- 您的呼叫中心是**7×24小时**运营,还是**固定工作时间**? +- 道路救援类业务通常是24小时的,非工作时间来电如何处理? + - [ ] 全部由机器人接待+记录,次日回拨 + - [ ] 安排夜班值班坐席 + - [ ] 紧急情况直接转值班手机 + +**11. 关于多业务线管理:** +- 道路救援、家政服务、代驾服务是**共用一个号码**,通过IVR分流?还是**不同业务独立号码**? +- 坐席是**混合技能组**(一个坐席可接多种业务),还是**专属技能组**(救援组、家政组、代驾组分开)? + +--- + +## 第四轮:自动外呼深入 + +**12. 关于预测式外呼(提高人工效率):** +- 外呼营销时,是否需要**预测式拨号**(系统自动批量拨打,接通后才转坐席)? +- 对于**接通率和坐席利用率**有什么期望指标吗? + +**13. 关于无人化批量通知:** +- 典型的通知场景有哪些? + - [ ] 服务人员即将到达通知 + - [ ] 订单确认/取消通知 + - [ ] 账单催缴提醒 + - [ ] 营销活动通知 +- 通知需要**纯语音播报**,还是需要**交互确认**(如:按1确认,按2取消)? +- 通知失败后是否需要**自动重试**或**转短信**? + +**14. 关于外呼数据来源:** +- 外呼名单从哪里来? + - [ ] 从业务系统自动推送 + - [ ] 人工Excel导入 + - [ ] API实时触发(如:订单完成自动触发回访) + +--- + +请您挑重点回答,我们离完整需求越来越近了!🚀 + +### USER +已经有了自己的CRM系统,需要呼叫中心系统集成CRM系统,客户来电叫服务时,识别客户身份,CRM系统弹屏,坐席在CRM系统上创建工单。呼叫中心是**7×24小时**运营,每个时间段都有坐席人员。不同业务独立号码,坐席**专属技能组**(救援组、家政组、代驾组分开)。外呼营销时,需要**预测式拨号,**对于**接通率和坐席利用率要能够统计。**典型的通知场景有:服务人员已经派出通知、服务人员即将到达通知,账单催缴提醒,通知需要**纯语音播报。**外呼名单从业务系统来。注意:坐席的操作方式是,是操作CRM系统或者业务系统,通过API的形式调用呼叫中心系统的呼入呼出功能。呼叫中心系统不需要有短信功能,由第三方的短信平台提供短信服务。 + +### ASSISTANT +非常关键的信息!您的架构思路非常清晰——**呼叫中心作为通信能力中台**,通过API为CRM/业务系统提供呼叫能力,这是目前主流且灵活的架构模式。 + +--- + +## 第五轮:技术架构与接口规范 + +**15. 关于CRM/业务系统集成方式:** +- 您的CRM系统是**Web端(B/S架构)**还是**桌面客户端(C/S架构)**? +- 坐席使用的话机形态是什么? + - [ ] **软电话(网页/PC软件接听)**——WebRTC或SIP软电话 + - [ ] **IP话机(桌面硬件话机)** + - [ ] **手机/手机App** +- 如果是软电话,您倾向于: + - [ ] 呼叫中心提供**独立软电话条**(小组件嵌入CRM) + - [ ] CRM系统**自行开发通话UI**,只调用呼叫中心API + +**16. 关于API能力需求确认:** +请确认以下API能力是否需要: + +| 功能分类 | 具体能力 | 是否需要 | +|---------|---------|---------| +| **呼入控制** | 来电弹屏通知(推送来电信息到CRM) | ? | +| | 接听/挂断/保持/取回 | ? | +| | 转接(转坐席/转技能组/转外线) | ? | +| | 三方通话/会议 | ? | +| **呼出控制** | 点击外呼(坐席点击号码拨打) | ? | +| | 预览式外呼(显示客户信息后手动拨打) | ? | +| | 预测式外呼(系统自动拨打,接通转坐席) | ? | +| **坐席状态** | 签入/签出/置忙/置闲/小休 | ? | +| | 实时坐席状态查询 | ? | +| **通话事件** | 通话状态回调(振铃、接通、挂断等) | ? | +| | 通话结束后话单推送 | ? | +| **录音** | 录音文件获取/下载 | ? | +| | 实时录音流(用于实时质检/转写) | ? | + +**17. 关于来电分配策略(ACD):** +- 同一技能组内多个坐席,来电如何分配? + - [ ] 轮询(Round Robin) + - [ ] 最长空闲优先 + - [ ] 技能值优先(技能等级高的优先) + - [ ] 历史服务优先(上次是谁服务的优先给谁) +- 如果技能组全忙,是否需要**排队等待**功能? + - [ ] 播放等待音乐/提示前面排队人数 + - [ ] 等待超时转其他组/留言/回拨 + +--- + +## 第六轮:机器人客服细化 + +**18. 关于呼入机器人(IVR+智能导航):** +- IVR导航的复杂度如何? + - [ ] **简单按键导航**:按1道路救援,按2家政服务,按3代驾... + - [ ] **语音识别导航**:客户直接说"我要叫代驾"自动识别转接 + - [ ] **多轮对话**:机器人先收集信息(位置、服务类型)再转人工 +- 常见问题解答需要支持多少个问题?(10个以内 / 10-50个 / 50个以上) +- 机器人**转人工的触发条件**是什么? + - [ ] 客户说"转人工" + - [ ] 客户按键选择 + - [ ] 机器人识别不了时自动转 + - [ ] 特定问题类型必须转人工(如投诉) + +**19. 关于呼出机器人:** +- 回访/满意度调查的典型流程是什么? + - 例如:播放"您好,请问您对本次服务满意吗?满意请按1,不满意请按2" +- 是否需要**采集客户按键结果**并回传业务系统? +- 如果客户不按键/无人接听,如何处理? + +**20. 关于机器人技术选型:** +- 您是否有**指定的ASR/TTS供应商**?(如:阿里云、科大讯飞、百度等) +- 还是希望呼叫中心系统**内置语音能力**? +- 如果涉及智能对话(NLU),是否有指定的对话平台?还是需要系统提供? + +--- + +## 第七轮:管理与运营功能 + +**21. 关于监控与管理:** +- 是否需要**实时监控大屏**?(显示:当前排队数、各组坐席状态、接通率等) +- 班长/主管需要哪些监控能力? + - [ ] 实时监听坐席通话 + - [ ] 强插(加入通话协助) + - [ ] 强拆(强制结束通话) + - [ ] 密语(对坐席耳语指导,客户听不到) + +**22. 关于统计报表:** +- 需要哪些维度的报表? + - [ ] 坐席维度:接通量、通话时长、工作状态时长占比 + - [ ] 技能组维度:呼入量、接通率、排队放弃率 + - [ ] 时段维度:每小时/每日话务量趋势 + - [ ] 外呼任务维度:拨打量、接通率、意向客户数 +- 报表是在**呼叫中心系统查看**,还是**数据推送到您的BI系统**? + +**23. 关于录音与质检:** +- 所有通话都需要**全程录音**吗? +- 是否需要**质检功能**? + - [ ] 人工质检(抽检打分) + - [ ] 智能质检(语音转文字+关键词检测+情绪分析) + +--- + +请您对以上问题进行确认和补充,尤其是**第16题API能力表格**,这对系统设计至关重要!📋 + +### USER +整套系统从零建设的话,大概需要多少预算?硬件和软件分别需要什么? + +### ASSISTANT +非常好的问题!在进入详细预算前,我需要先确认几个影响成本的关键因素: + +--- + +## 第八轮:预算与部署相关问题 + +**24. 关于部署方式:** +- 您倾向于哪种部署方式? + - [ ] **私有化部署**(服务器放在自己机房或自有云主机) + - [ ] **公有云部署**(阿里云、腾讯云、华为云等) + - [ ] **混合部署**(核心私有化,部分能力用云服务) + +**25. 关于线路资源:** +- 您目前是否已有**运营商线路资源**? + - [ ] 已有中继线路(E1/SIP中继) + - [ ] 已有95/96/400/1010号码 + - [ ] 没有,需要新申请 +- 三种业务(道路救援、家政、代驾)计划用几个号码? + +**26. 关于并发需求:** +- 150坐席同时工作,预估**最大并发通话数**是多少? + - 通常按坐席数的 **70%-80%** 估算,即 **100-120路并发** + - 您认为这个估算合理吗? + +**27. 关于建设方式:** +- 您倾向于哪种方式? + - [ ] **自研团队开发**(有FreeSWITCH开发能力) + - [ ] **采购成熟产品**(购买商业呼叫中心系统) + - [ ] **外包定制开发**(找供应商定制) + +--- + +## 预算估算框架(先给您一个概览) + +在您回答上述问题之前,我先给您一个**参考框架**,具体数字会根据您的回答调整: + +### 一、硬件/基础设施预算 + +| 项目 | 私有化部署 | 云部署 | +|------|-----------|--------| +| **服务器** | 10-30万(3-5台服务器集群) | 云主机 3-8万/年 | +| **存储(录音)** | 5-15万(NAS/SAN存储) | 对象存储 1-3万/年 | +| **网络设备** | 2-5万(交换机、防火墙) | 含在云服务中 | +| **语音网关** | 3-10万(若用传统E1线路) | SIP中继无需网关 | +| **IP话机(可选)** | 300-800元/台 × 150 = 4.5-12万 | 同左 | +| **机房/带宽** | 5-10万/年 | 含在云服务中 | +| **小计** | **25-80万(一次性)+ 5-10万/年** | **5-15万/年** | + +### 二、软件/系统预算 + +| 项目 | 采购商业产品 | 自研/定制开发 | +|------|------------|--------------| +| **呼叫中心平台** | 30-80万(按坐席授权) | 开发成本 50-150万 | +| **智能IVR/机器人** | 10-30万 + 调用费用 | 需对接ASR/TTS厂商 | +| **预测式外呼模块** | 10-20万 | 含在开发中 | +| **录音/质检系统** | 5-15万 | 含在开发中 | +| **API/集成开发** | 5-15万(定制费用) | 含在开发中 | +| **小计** | **60-160万** | **50-150万 + 团队成本** | + +### 三、语音资源/运营费用 + +| 项目 | 预估费用 | +|------|---------| +| **号码月租** | 95/96号码:5000-2万/月;400号码:1000-5000/月 | +| **通话费** | 呼入:0.02-0.05元/分钟;呼出:0.08-0.15元/分钟 | +| **ASR/TTS调用** | 约 0.01-0.03元/秒(按使用量) | +| **线路并发费** | SIP中继:约 50-100元/路/月 | + +**按日呼量5万、平均时长3分钟估算:** +- 月通话分钟数:5万 × 30天 × 3分钟 = **450万分钟/月** +- 呼入成本(40%):180万分钟 × 0.03 = **5.4万/月** +- 呼出成本(60%):270万分钟 × 0.10 = **27万/月** +- **月通信成本约:30-35万/月** + +--- + +### 四、总体预算汇总(参考) + +| 部署方式 | 首年投入 | 后续年度 | +|---------|---------|---------| +| **云部署 + 采购商业产品** | 70-180万 | 40-60万/年(含云资源+维保) | +| **私有化 + 采购商业产品** | 100-250万 | 20-40万/年(维保+资源) | +| **云部署 + 自研开发** | 80-200万 | 30-50万/年 + 研发团队 | + +> ⚠️ 以上**不含通信费用**(每年约 **360-420万**,这是大头) + +--- + +## 追问几个成本优化相关问题: + +**28. 关于话机/通话终端:** +- 坐席是否可以接受**纯软电话**(用耳麦+电脑接听)?可节省硬件话机成本 +- 还是必须配备**IP硬话机**? + +**29. 关于录音存储:** +- 录音需要保存多长时间?(3个月 / 6个月 / 1年 / 更长) +- 日均5万通话 × 3分钟 ≈ 15万分钟/天,月存储需求约 **1-2TB** + +**30. 关于智能语音:** +- 机器人对话的ASR/TTS,您倾向于: + - [ ] 用云厂商API(按量付费,无需部署) + - [ ] 私有化部署(一次性授权,成本较高但长期划算) + +--- + +请您回答 **第24-30题**,我将结合您之前的回答,给出更精准的预算方案和硬件软件清单!💰 + +### USER +关于CRM/业务系统集成方式:CRM系统是 **Web端(B/S架构),**坐席使用的话机形态是:IP话机,但是要支持软电话功能,软电话使用呼叫中心提供**独立软电话条**(小组件嵌入CRM),列出的API能力都需要,来电分配策略:可以支持多种分配策略,首先历史服务优先,最长空闲优先和轮训。如果技能组全忙,可以设置溢出到其他技能组。IVR导航要支持语音识别导航,常见问题解答需要支持50个以上。机器人**转人工的触发条件**是:客户说"转人工"、机器人识别不了时自动转、特定问题类型必须转人工(如投诉);机器人技术选型:指定的ASR/TTS供应商阿里云,如果涉及智能对话(NLU)由指定的对话平台。需要**实时监控大屏,**班长/主管需要监控能力:实时监听坐席通话、强插(加入通话协助)、强拆(强制结束通话);所有通话都要全程录音;统计报表和质检本次先不涉及。 + +### ASSISTANT +非常详细!信息已经非常完整了。让我再确认最后几个关键问题,然后就可以为您生成完整的需求文档了。 + +--- + +## 第九轮:最终确认 + +**31. 关于部署方式(回顾第24题):** +- 您倾向于 **私有化部署** 还是 **公有云部署**? + +**32. 关于线路资源(回顾第25题):** +- 是否已有运营商线路?还是需要新申请? +- 三种业务(道路救援、家政、代驾)分别用独立号码,计划用什么类型的号码? + - [ ] 95/96短号(品牌形象好,申请门槛高) + - [ ] 400号码(全国统一,成本适中) + - [ ] 普通固话/手机号(成本低) + +**33. 关于建设方式(回顾第27题):** +- 您倾向于: + - [ ] **采购成熟商业产品** + - [ ] **自有团队基于FreeSWITCH自研** + - [ ] **找供应商定制开发** + +**34. 关于录音存储(回顾第29题):** +- 录音需要保存多长时间? + +**35. 关于外呼机器人补充:** +- 您之前提到呼出机器人用于「标准化通知、自动回访、满意度调查」 +- 请确认回访/满意度调查是否需要**采集客户按键反馈**并回传业务系统? + - 例如:「满意请按1,不满意请按2」-> 结果写回CRM + +**36. 关于系统可用性要求:** +- 道路救援是紧急服务,对系统稳定性要求高 +- 是否需要**主备高可用部署**?(即一台故障,自动切换到备机) + +**37. 关于上线时间预期:** +- 您期望系统在多长时间内上线? + - [ ] 1-2个月(紧急) + - [ ] 3-4个月(正常) + - [ ] 6个月以上(充裕) + +--- + +请您回答以上问题后,我将为您生成一份完整的 **《呼叫中心系统需求规格说明书》**,包含: + +1. **项目概述**(背景、目标、范围) +2. **业务需求**(呼入、呼出、机器人场景) +3. **功能需求**(分模块详细描述) +4. **接口需求**(API清单) +5. **非功能需求**(性能、安全、可用性) +6. **系统架构建议** +7. **硬件软件清单** +8. **预算估算** +9. **项目里程碑建议** + +期待您的最后回复!📝 + +### USER +私有化部署,计划使用400号码和普通固话,建设方式:自有团队基于FreeSWITCH自研,录音需要长期保存,回访/满意度调查是需要**采集客户按键反馈**并回传业务系统;道路救援是紧急服务,对系统稳定性要求高且需要**主备高可用部署;**期望系统3-4个月上线。 + +### ASSISTANT +感谢您的耐心回答!所有关键信息已收集完毕。现在我为您生成完整的需求规格说明书。 + +--- + +# 企业呼叫中心系统需求规格说明书 + +**文档版本**:V1.0 +**编制日期**:2024年1月 +**文档状态**:初稿 + +--- + +## 第一章 项目概述 + +### 1.1 项目背景 + +本企业主营道路救援、家政服务、代驾服务三大业务板块,面向个人消费者(ToC)提供服务。随着业务规模扩大,现需建设一套专业的呼叫中心系统,以提升客户服务体验、提高服务响应效率、降低人工成本。 + +### 1.2 项目目标 + +| 目标维度 | 具体目标 | +|---------|---------| +| **服务能力** | 支持150坐席同时在线,日处理呼叫量50,000通 | +| **响应效率** | 道路救援等紧急服务实现快速响应,7×24小时不间断服务 | +| **人工效率** | 通过预测式外呼提升坐席利用率,减少空闲等待时间 | +| **自动化水平** | 通过智能IVR和机器人实现常见问题自助解答、批量通知自动化 | +| **系统稳定性** | 主备高可用架构,确保核心业务(道路救援)不中断 | + +### 1.3 项目范围 + +**纳入范围:** +- 基础呼叫能力(呼入/呼出) +- 智能IVR与语音导航 +- 呼入/呼出机器人 +- 预测式外呼 +- 无人化批量通知 +- CRM系统集成(API对接) +- 软电话条组件 +- 实时监控大屏 +- 班长监控功能 +- 全程录音 +- 主备高可用部署 + +**不纳入本期范围:** +- 统计报表系统(二期规划) +- 智能质检系统(二期规划) +- 短信功能(由第三方平台提供) + +### 1.4 术语定义 + +| 术语 | 定义 | +|------|------| +| **ACD** | 自动呼叫分配(Automatic Call Distribution) | +| **IVR** | 交互式语音应答(Interactive Voice Response) | +| **ASR** | 自动语音识别(Automatic Speech Recognition) | +| **TTS** | 语音合成(Text To Speech) | +| **NLU** | 自然语言理解(Natural Language Understanding) | +| **CTI** | 计算机电话集成(Computer Telephony Integration) | +| **SIP** | 会话发起协议(Session Initiation Protocol) | + +--- + +## 第二章 业务需求 + +### 2.1 业务架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户触点 │ +│ 道路救援热线 家政服务热线 代驾服务热线 │ +│ (400-XXX-XXXX) (400-XXX-XXXX) (固话/400) │ +└───────────────┬─────────────────┬─────────────────┬─────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 呼叫中心系统 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 智能IVR │ │ 呼入机器人│ │ ACD分配 │ │ 外呼系统 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 坐席管理 │ │ 录音系统 │ │ 实时监控 │ │ API网关 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└───────────────────────────┬─────────────────────────────────────┘ + │ API + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CRM/业务系统 │ +│ 客户管理 │ 工单管理 │ 派单系统 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 呼入业务场景 + +#### 2.2.1 道路救援来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(语音识别:"道路救援") + → 机器人收集初步信息(可选) + → 转救援技能组 + → ACD分配(历史服务优先 > 最长空闲 > 轮询) + → 技能组全忙时溢出到备用组 + → 坐席接听 + CRM弹屏 + → 坐席在CRM创建工单/派单 + → 通话结束 → 录音保存 +``` + +**业务要求:** +- 响应时效要求高,需优先保障接通率 +- 支持7×24小时服务 +- 来电需快速识别客户身份,展示历史服务记录 + +#### 2.2.2 家政服务来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(按键或语音选择"家政服务") + → 常见问题解答(服务项目、价格咨询等) + → 客户需要人工服务 → 转家政技能组 + → 坐席接听 + CRM弹屏 + → 预约服务/创建工单 + → 通话结束 → 录音保存 +``` + +#### 2.2.3 代驾服务来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(语音识别:"叫代驾") + → 转代驾技能组 + → 坐席接听 + CRM弹屏 + → 创建代驾订单/派单 + → 通话结束 → 录音保存 +``` + +#### 2.2.4 投诉来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(识别"投诉"意图) + → 直接转人工(投诉不经机器人处理) + → 分配至对应业务技能组或投诉专席 + → CRM弹屏 + 标记投诉标签 + → 坐席记录投诉内容 +``` + +### 2.3 呼出业务场景 + +#### 2.3.1 预测式外呼(营销/回访) + +**业务流程:** + +``` +业务系统推送外呼任务(客户名单) + → 呼叫中心批量拨打 + → 客户接听 → 自动转接空闲坐席 + → CRM弹屏展示客户信息 + → 坐席进行营销/回访 + → 通话结束 → 录音保存 → 话单回传业务系统 +``` + +**业务要求:** +- 系统自动拨打,接通后才转坐席,减少坐席等待时间 +- 支持接通率、坐席利用率统计 +- 外呼任务支持暂停、恢复、停止 + +#### 2.3.2 机器人自动外呼(通知类) + +**场景一:服务人员派出/即将到达通知** + +``` +业务系统触发通知(API调用) + → 呼叫中心自动拨打客户电话 + → 播放TTS语音:"您好,您预约的道路救援服务,师傅已出发,预计10分钟到达..." + → 通话结束 → 结果回传业务系统 +``` + +**场景二:账单催缴提醒** + +``` +业务系统推送催缴名单 + → 批量自动拨打 + → 播放TTS语音:"您好,您有一笔待支付账单..." + → 纯语音播报,无需交互 + → 拨打结果回传(接通/未接通/关机等) +``` + +#### 2.3.3 机器人自动外呼(回访/满意度调查) + +**业务流程:** + +``` +业务系统推送回访任务 + → 呼叫中心自动拨打 + → 播放TTS语音:"您好,请问您对本次服务满意吗?满意请按1,不满意请按2" + → 采集客户按键 + → 按键结果回传业务系统 + → 不满意客户可选择转人工跟进 +``` + +### 2.4 机器人客服场景 + +#### 2.4.1 呼入机器人功能定位 + +| 功能 | 说明 | +|------|------| +| **语音导航** | 支持客户语音说出需求,自动识别并转接对应技能组 | +| **常见问题解答** | 支持50个以上FAQ,自动语音解答 | +| **转人工预处理** | 收集客户初步信息后转人工,提高坐席效率 | + +#### 2.4.2 转人工触发条件 + +| 触发条件 | 说明 | +|---------|------| +| 客户主动要求 | 客户说"转人工"或按键选择转人工 | +| 识别失败 | 机器人连续2-3次无法识别客户意图 | +| 特定问题类型 | 投诉类问题直接转人工,不经机器人处理 | + +--- + +## 第三章 功能需求 + +### 3.1 智能IVR模块 + +#### 3.1.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IVR-001 | 多级语音菜单 | 支持配置多级IVR语音菜单,按键导航 | P0 | +| IVR-002 | 语音识别导航 | 集成阿里云ASR,支持客户语音说出需求自动识别 | P0 | +| IVR-003 | 多业务线路由 | 根据来电号码(400/固话)自动进入对应业务IVR流程 | P0 | +| IVR-004 | 时间条件路由 | 支持按工作时间/节假日配置不同IVR流程 | P1 | +| IVR-005 | 黑名单拦截 | 来电号码在黑名单中自动播放提示后挂断 | P1 | +| IVR-006 | VIP客户识别 | VIP客户来电自动优先排队或转专属坐席 | P2 | +| IVR-007 | IVR流程可视化配置 | 提供可视化拖拽界面配置IVR流程 | P1 | + +#### 3.1.2 语音识别要求 + +- **ASR供应商**:阿里云语音识别 +- **识别准确率**:≥90% +- **响应时间**:≤500ms +- **支持方言**:普通话为主,可选支持粤语、四川话等 + +### 3.2 ACD自动分配模块 + +#### 3.2.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| ACD-001 | 技能组管理 | 支持创建多个技能组(救援组、家政组、代驾组) | P0 | +| ACD-002 | 坐席技能配置 | 坐席可属于一个或多个技能组,配置技能等级 | P0 | +| ACD-003 | 历史服务优先 | 来电客户优先分配给上次服务的坐席 | P0 | +| ACD-004 | 最长空闲优先 | 同技能组内优先分配给空闲时间最长的坐席 | P0 | +| ACD-005 | 轮询分配 | 同技能组内按顺序轮流分配 | P0 | +| ACD-006 | 分配策略优先级 | 支持配置分配策略优先级:历史服务 > 最长空闲 > 轮询 | P0 | +| ACD-007 | 技能组溢出 | 技能组全忙时,来电溢出到配置的备用技能组 | P0 | +| ACD-008 | 排队等待 | 所有坐席忙时,来电进入排队,播放等待音乐 | P0 | +| ACD-009 | 排队超时处理 | 排队超时可配置:继续等待/溢出/留言/回拨 | P1 | +| ACD-010 | 排队位置播报 | 向等待客户播报当前排队位置 | P2 | + +### 3.3 坐席管理模块 + +#### 3.3.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| AGT-001 | 坐席签入/签出 | 坐席上下班签入签出系统 | P0 | +| AGT-002 | 坐席状态管理 | 支持状态:空闲、通话中、振铃中、后处理、小休、忙碌 | P0 | +| AGT-003 | 小休原因 | 小休时需选择原因(用餐、洗手间、培训等) | P1 | +| AGT-004 | 状态自动切换 | 通话结束自动进入后处理状态,超时自动转空闲 | P0 | +| AGT-005 | 后处理时长配置 | 可配置后处理时长(如30秒) | P1 | +| AGT-006 | 强制签出 | 班长可强制签出坐席 | P1 | + +#### 3.3.2 坐席工作台要求 + +- 坐席不使用呼叫中心独立工作台 +- 坐席在CRM系统中操作业务 +- 呼叫中心提供**软电话条组件**嵌入CRM页面 +- 软电话条通过WebSocket与呼叫中心通信 +- 同时支持IP话机(SIP协议注册) + +### 3.4 软电话条组件 + +#### 3.4.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| SFT-001 | 签入/签出 | 坐席登录CRM后通过软电话条签入呼叫中心 | P0 | +| SFT-002 | 状态切换 | 置忙/置闲/小休状态切换按钮 | P0 | +| SFT-003 | 来电弹屏触发 | 来电时向CRM发送弹屏事件,携带来电号码、客户ID等 | P0 | +| SFT-004 | 接听/挂断 | 点击接听/挂断按钮控制通话 | P0 | +| SFT-005 | 保持/取回 | 通话保持(播放等待音乐)和取回 | P0 | +| SFT-006 | 静音 | 坐席侧静音 | P1 | +| SFT-007 | 转接 | 转接至其他坐席/技能组/外线号码 | P0 | +| SFT-008 | 咨询转 | 先与第三方通话咨询,再决定是否转接 | P1 | +| SFT-009 | 三方通话 | 坐席、客户、第三方三方通话 | P1 | +| SFT-010 | 点击拨号 | 在CRM中点击号码呼出 | P0 | +| SFT-011 | 手动拨号 | 手动输入号码拨打 | P0 | +| SFT-012 | 通话计时 | 显示当前通话时长 | P0 | +| SFT-013 | 来电信息展示 | 显示来电号码、归属地、技能组等 | P0 | + +#### 3.4.2 软电话条技术要求 + +| 要求项 | 说明 | +|-------|------| +| **前端技术** | Web组件,支持iframe嵌入或JS SDK集成 | +| **通信协议** | WebSocket长连接,实时接收呼叫事件 | +| **语音方案** | WebRTC(浏览器软电话)或SIP话机(硬件话机) | +| **浏览器支持** | Chrome 70+、Firefox 65+、Edge 79+ | + +### 3.5 呼入通话控制 + +#### 3.5.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IBC-001 | 来电接入 | 支持SIP中继接入来电 | P0 | +| IBC-002 | 来电排队 | 坐席全忙时来电进入队列等待 | P0 | +| IBC-003 | 等待音乐 | 排队等待时播放等待音乐 | P0 | +| IBC-004 | 来电转接 | 支持盲转、咨询转、转技能组、转外线 | P0 | +| IBC-005 | 三方通话 | 支持三方会议通话 | P1 | +| IBC-006 | 通话保持 | 通话保持并播放等待音乐 | P0 | +| IBC-007 | 通话录音 | 全程录音,支持双声道(坐席/客户分轨)| P0 | + +### 3.6 呼出通话控制 + +#### 3.6.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| OBC-001 | 点击外呼 | 坐席点击号码发起外呼 | P0 | +| OBC-002 | 外显号码设置 | 支持配置外呼显示的主叫号码 | P0 | +| OBC-003 | 外呼转接 | 外呼通话中支持转接 | P0 | +| OBC-004 | 通话录音 | 外呼通话全程录音 | P0 | + +### 3.7 预测式外呼模块 + +#### 3.7.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| PDL-001 | 外呼任务创建 | 通过API创建外呼任务,导入号码名单 | P0 | +| PDL-002 | 任务状态控制 | 支持任务启动、暂停、恢复、停止 | P0 | +| PDL-003 | 预测式拨号算法 | 根据坐席空闲情况自动计算拨打频率 | P0 | +| PDL-004 | 接通转坐席 | 客户接听后自动转接空闲坐席 | P0 | +| PDL-005 | 无人接听处理 | 无人接听/忙音/关机等自动标记状态 | P0 | +| PDL-006 | 外呼时段限制 | 可配置允许外呼的时间段(如9:00-21:00) | P1 | +| PDL-007 | 号码去重 | 同一任务内号码自动去重 | P1 | +| PDL-008 | 重试策略 | 未接通号码可配置自动重试次数和间隔 | P1 | +| PDL-009 | 坐席利用率统计 | 统计坐席有效通话时长占比 | P0 | +| PDL-010 | 接通率统计 | 统计任务接通率 | P0 | + +### 3.8 机器人外呼模块 + +#### 3.8.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| RBT-001 | 批量通知任务 | 通过API创建批量语音通知任务 | P0 | +| RBT-002 | TTS语音播放 | 集成阿里云TTS,文本转语音播放 | P0 | +| RBT-003 | 变量替换 | 支持语音内容中插入变量(姓名、时间、金额等) | P0 | +| RBT-004 | 纯播报模式 | 播放语音后直接挂断 | P0 | +| RBT-005 | 按键采集模式 | 播放语音后等待客户按键,采集结果 | P0 | +| RBT-006 | 按键结果回传 | 按键结果通过回调接口推送至业务系统 | P0 | +| RBT-007 | 无应答处理 | 客户无应答/超时可配置重试或标记 | P1 | +| RBT-008 | 并发控制 | 可配置任务最大并发拨打数 | P1 | +| RBT-009 | 任务进度查询 | API查询任务进度和结果 | P0 | + +### 3.9 呼入机器人模块 + +#### 3.9.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IRB-001 | 语音识别 | 集成阿里云ASR识别客户语音 | P0 | +| IRB-002 | 意图识别 | 对接NLU平台,识别客户意图 | P0 | +| IRB-003 | FAQ问答 | 支持配置50+常见问题及答案 | P0 | +| IRB-004 | 语音合成应答 | 集成阿里云TTS合成语音应答 | P0 | +| IRB-005 | 多轮对话 | 支持多轮对话收集信息 | P1 | +| IRB-006 | 转人工-主动请求 | 客户说"转人工"触发转接 | P0 | +| IRB-007 | 转人工-识别失败 | 连续N次识别失败自动转人工 | P0 | +| IRB-008 | 转人工-特定意图 | 识别到投诉等意图直接转人工 | P0 | +| IRB-009 | 转接携带信息 | 转人工时携带已收集的信息至坐席 | P1 | +| IRB-010 | 话术配置 | 支持配置机器人话术和回复内容 | P0 | + +### 3.10 录音管理模块 + +#### 3.10.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| REC-001 | 全程录音 | 所有呼入呼出通话全程录音 | P0 | +| REC-002 | 双声道录音 | 坐席和客户分轨录音(便于质检) | P1 | +| REC-003 | 录音存储 | 录音文件存储至NAS/分布式存储 | P0 | +| REC-004 | 长期保存 | 支持录音长期保存,定期归档 | P0 | +| REC-005 | 录音检索 | 按通话ID、时间、号码等检索录音 | P0 | +| REC-006 | 录音下载 | 通过API获取录音文件下载地址 | P0 | +| REC-007 | 录音权限 | 录音访问权限控制 | P1 | + +### 3.11 班长监控模块 + +#### 3.11.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| SUP-001 | 坐席状态监控 | 实时查看所有坐席当前状态 | P0 | +| SUP-002 | 实时监听 | 监听坐席与客户通话,双方不知情 | P0 | +| SUP-003 | 强插通话 | 加入通话,三方均可听到 | P0 | +| SUP-004 | 强拆通话 | 强制结束坐席通话 | P0 | +| SUP-005 | 密语(可选) | 对坐席单向说话,客户听不到(二期) | P2 | +| SUP-006 | 强制签出 | 强制将坐席签出系统 | P1 | +| SUP-007 | 强制置忙 | 强制将坐席状态设为忙碌 | P1 | + +### 3.12 实时监控大屏 + +#### 3.12.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| DSP-001 | 整体概览 | 展示当前呼入量、呼出量、接通率等 | P0 | +| DSP-002 | 坐席状态分布 | 展示各状态坐席数量(空闲、通话、小休等) | P0 | +| DSP-003 | 技能组监控 | 各技能组排队数、等待时长、可用坐席数 | P0 | +| DSP-004 | 实时告警 | 排队超阈值、坐席空闲过少等告警提示 | P1 | +| DSP-005 | 趋势图表 | 当日话务量趋势图(按小时) | P1 | +| DSP-006 | 大屏适配 | 支持大屏显示器展示 | P0 | + +--- + +## 第四章 接口需求 + +### 4.1 接口概述 + +呼叫中心系统作为通信能力中台,通过API方式为CRM/业务系统提供呼叫能力。接口采用RESTful API + WebSocket事件推送模式。 + +### 4.2 接口清单 + +#### 4.2.1 呼入控制接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-IN-001 | 接听来电 | POST | 坐席接听当前来电 | +| API-IN-002 | 挂断通话 | POST | 挂断当前通话 | +| API-IN-003 | 保持通话 | POST | 将通话置于保持状态 | +| API-IN-004 | 取回通话 | POST | 取回保持的通话 | +| API-IN-005 | 转接通话 | POST | 转接至指定坐席/技能组/外线 | +| API-IN-006 | 咨询通话 | POST | 发起咨询通话 | +| API-IN-007 | 咨询转移 | POST | 咨询后转移 | +| API-IN-008 | 三方通话 | POST | 建立三方通话 | + +#### 4.2.2 呼出控制接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-OUT-001 | 点击外呼 | POST | 坐席发起外呼 | +| API-OUT-002 | 取消外呼 | POST | 取消正在拨打的外呼 | +| API-OUT-003 | 预览式外呼确认 | POST | 坐席确认拨打预览号码 | + +#### 4.2.3 坐席状态接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-AGT-001 | 坐席签入 | POST | 坐席签入系统 | +| API-AGT-002 | 坐席签出 | POST | 坐席签出系统 | +| API-AGT-003 | 置忙 | POST | 坐席设置为忙碌状态 | +| API-AGT-004 | 置闲 | POST | 坐席设置为空闲状态 | +| API-AGT-005 | 小休 | POST | 坐席进入小休状态 | +| API-AGT-006 | 查询坐席状态 | GET | 查询指定坐席当前状态 | +| API-AGT-007 | 批量查询坐席状态 | GET | 批量查询坐席状态 | + +#### 4.2.4 外呼任务接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-TASK-001 | 创建外呼任务 | POST | 创建预测式/机器人外呼任务 | +| API-TASK-002 | 导入号码 | POST | 向任务导入外呼号码 | +| API-TASK-003 | 启动任务 | POST | 启动外呼任务 | +| API-TASK-004 | 暂停任务 | POST | 暂停外呼任务 | +| API-TASK-005 | 恢复任务 | POST | 恢复暂停的任务 | +| API-TASK-006 | 停止任务 | POST | 停止外呼任务 | +| API-TASK-007 | 查询任务进度 | GET | 查询任务执行进度 | +| API-TASK-008 | 查询任务结果 | GET | 查询任务拨打结果明细 | + +#### 4.2.5 单条外呼接口(通知类) + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-NOTIFY-001 | 发起语音通知 | POST | 触发单条语音通知外呼 | +| API-NOTIFY-002 | 查询通知结果 | GET | 查询通知拨打结果 | + +#### 4.2.6 录音接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-REC-001 | 查询录音列表 | GET | 按条件查询录音记录 | +| API-REC-002 | 获取录音下载地址 | GET | 获取录音文件下载URL | + +#### 4.2.7 监控接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-MON-001 | 监听通话 | POST | 班长监听指定坐席通话 | +| API-MON-002 | 强插通话 | POST | 班长强插指定坐席通话 | +| API-MON-003 | 强拆通话 | POST | 班长强制挂断指定坐席通话 | +| API-MON-004 | 实时统计数据 | GET | 获取实时监控统计数据 | +| API-MON-005 | 技能组排队信息 | GET | 获取各技能组排队情况 | + +### 4.3 事件推送(WebSocket) + +#### 4.3.1 通话事件 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-001 | 来电振铃 | 来电分配到坐席时 | 来电号码、客户ID、技能组、IVR收集信息等 | +| EVT-002 | 通话接通 | 坐席接听后 | 通话ID、接通时间 | +| EVT-003 | 通话挂断 | 通话结束后 | 通话ID、挂断方、通话时长 | +| EVT-004 | 转接振铃 | 转接到新坐席振铃时 | 转接来源、来电信息 | +| EVT-005 | 外呼振铃 | 外呼拨打中 | 外呼号码、坐席ID | +| EVT-006 | 外呼接通 | 客户接听 | 通话ID | +| EVT-007 | 外呼失败 | 外呼未接通 | 失败原因(无人接听/忙/关机等) | + +#### 4.3.2 坐席状态事件 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-101 | 状态变更 | 坐席状态变化时 | 坐席ID、新状态、旧状态、时间 | +| EVT-102 | 签入成功 | 坐席签入成功 | 坐席ID、技能组 | +| EVT-103 | 签出成功 | 坐席签出成功 | 坐席ID | + +#### 4.3.3 话单推送 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-201 | 话单推送 | 通话结束后 | 通话ID、主被叫、开始时间、结束时间、时长、录音地址、坐席ID、技能组、呼叫类型等 | + +#### 4.3.4 机器人交互结果推送 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-301 | 按键结果 | 客户按键后 | 任务ID、号码、按键值、时间 | +| EVT-302 | 机器人通话结果 | 机器人外呼结束 | 任务ID、号码、接通状态、通话时长、按键结果 | + +--- + +## 第五章 非功能需求 + +### 5.1 性能需求 + +| 指标项 | 要求 | +|-------|------| +| **并发坐席数** | 支持150坐席同时在线 | +| **并发通话数** | 支持120路并发通话(含呼入+呼出) | +| **日处理量** | 支持日均50,000通呼叫 | +| **呼叫建立时延** | 来电从接入到振铃坐席 ≤ 1秒 | +| **API响应时间** | 95%请求响应 ≤ 200ms | +| **事件推送时延** | 事件产生到推送至CRM ≤ 500ms | +| **ASR识别延迟** | 语音识别延迟 ≤ 500ms | +| **系统可用性** | ≥ 99.9%(全年停机不超过8.76小时) | + +### 5.2 高可用需求 + +| 要求项 | 说明 | +|-------|------| +| **主备部署** | FreeSWITCH主备双机部署,主机故障自动切换至备机 | +| **切换时间** | 故障切换时间 ≤ 30秒 | +| **数据同步** | 主备配置实时同步 | +| **心跳检测** | 主备心跳检测间隔 ≤ 5秒 | +| **数据库高可用** | 数据库采用主从复制或集群部署 | +| **录音存储** | 录音存储采用RAID或分布式存储,防止数据丢失 | + +### 5.3 安全需求 + +| 要求项 | 说明 | +|-------|------| +| **网络安全** | SIP通信支持TLS加密;API支持HTTPS | +| **API认证** | API调用需Token认证,支持IP白名单 | +| **权限控制** | 支持角色权限管理(管理员、班长、坐席) | +| **录音安全** | 录音文件访问需权限验证 | +| **操作审计** | 关键操作记录审计日志 | + +### 5.4 兼容性需求 + +| 要求项 | 说明 | +|-------|------| +| **SIP话机** | 兼容主流SIP话机品牌(如:Yealink、Grandstream、Fanvil等) | +| **SIP协议** | 符合RFC 3261标准 | +| **浏览器** | 软电话条支持Chrome 70+、Firefox 65+、Edge 79+ | +| **编解码** | 语音编解码支持G.711a、G.711u、G.729(可选) | + +### 5.5 可扩展性需求 + +| 要求项 | 说明 | +|-------|------| +| **坐席扩展** | 架构支持水平扩展至300+坐席 | +| **业务线扩展** | 支持新增业务线/技能组,无需改动核心架构 | +| **线路扩展** | 支持新增SIP中继线路 | + +--- + +## 第六章 系统架构设计建议 + +### 6.1 整体架构图 + +``` + ┌──────────────────┐ + │ 运营商网络 │ + │ (SIP Trunk/400) │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ SBC/边界网关 │ + │ (可选,安全防护) │ + └────────┬─────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + │ ┌──────────────────────▼─────────────────────┐ │ + │ │ FreeSWITCH │ │ + │ │ (主备双机部署) │ │ + │ │ ┌─────────┬─────────┬─────────┬─────────┐ │ │ + │ │ │ IVR │ ACD │ 录音模块 │ 外呼引擎│ │ │ + │ │ └─────────┴─────────┴─────────┴─────────┘ │ │ + │ └───────────────────────┬────────────────────┘ │ + │ │ │ + │ ┌───────────────────────┼───────────────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌──────────┐ ┌──────────────┐ ┌────────┐│ + │ │ CTI中间件 │ │ API Gateway │ │ WS推送 ││ + │ │(ESL连接) │ │ (REST API) │ │ 服务 ││ + │ └────┬─────┘ └──────┬───────┘ └───┬────┘│ + │ │ │ │ │ + │ └────────────────────┼────────────────────┘ │ + │ │ │ + │ ┌────────────────┼────────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ + │ │ MySQL │ │ Redis │ │ 文件存储 │ │ + │ │ (话单/配置)│ │ (缓存/状态)│ │ (录音) │ │ + │ └──────────┘ └──────────┘ └───────────┘ │ + │ │ + │ 呼叫中心系统边界 │ + └───────────────────────────┬──────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌────────────┐ + │ CRM系统 │ │ 业务系统 │ │ 阿里云语音 │ + │(弹屏/工单) │ │(派单/订单)│ │(ASR/TTS) │ + └──────────┘ └──────────┘ └────────────┘ +``` + +### 6.2 核心组件说明 + +| 组件 | 技术选型 | 说明 | +|------|---------|------| +| **媒体服务器** | FreeSWITCH | 核心呼叫控制、媒体处理、IVR、录音 | +| **CTI中间件** | 自研(基于ESL) | 封装FreeSWITCH ESL接口,提供业务逻辑 | +| **API网关** | Nginx/Kong/自研 | RESTful API统一入口 | +| **WebSocket服务** | Node.js/Golang | 实时事件推送 | +| **数据库** | MySQL 8.0 | 话单、配置、坐席信息存储 | +| **缓存** | Redis | 坐席状态、会话信息缓存 | +| **录音存储** | NAS/MinIO/对象存储 | 录音文件长期存储 | +| **ASR/TTS** | 阿里云智能语音 | 语音识别与合成 | +| **NLU** | 外部指定平台 | 意图识别与对话管理 | + +### 6.3 部署架构(主备高可用) + +``` + ┌─────────────────────────────────────┐ + │ VIP(虚拟IP) │ + └──────────────────┬──────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ │ ▼ + ┌─────────────────┐ │ ┌─────────────────┐ + │ FreeSWITCH │◄─────Keepalived心跳──────────────►│ FreeSWITCH │ + │ (主机) │ │ │ (备机) │ + │ CTI中间件 │ │ │ CTI中间件 │ + │ API服务 │ │ │ API服务 │ + └─────────────────┘ │ └─────────────────┘ + │ │ │ + └──────────────────────────┼──────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + ┌──────────────┐ ┌───────────────┐ + │ MySQL主从集群 │ │ Redis Sentinel│ + └──────────────┘ └───────────────┘ + │ + ▼ + ┌───────────────┐ + │ NAS/分布式存储 │ + │ (录音文件) │ + └───────────────┘ +``` + +--- + +## 第七章 硬件与软件清单 + +### 7.1 硬件清单 + +| 序号 | 设备类型 | 规格配置 | 数量 | 用途 | 参考单价(万元) | 合计(万元) | +|-----|---------|---------|------|------|---------------|-------------| +| 1 | 应用服务器(主) | 32核CPU/64GB内存/500GB SSD+2TB HDD | 1 | FreeSWITCH+CTI+API | 5-8 | 5-8 | +| 2 | 应用服务器(备) | 32核CPU/64GB内存/500GB SSD+2TB HDD | 1 | FreeSWITCH备机 | 5-8 | 5-8 | +| 3 | 数据库服务器 | 16核CPU/64GB内存/1TB SSD | 2 | MySQL主从 | 4-6 | 8-12 | +| 4 | Redis服务器 | 8核CPU/32GB内存/500GB SSD | 3 | Redis Sentinel集群 | 2-3 | 6-9 | +| 5 | NAS存储 | 96TB可用空间(RAID) | 1 | 录音长期存储 | 8-15 | 8-15 | +| 6 | 网络交换机 | 千兆三层交换机 | 2 | 网络通信 | 1-2 | 2-4 | +| 7 | 防火墙 | 企业级防火墙 | 1 | 安全防护 | 2-5 | 2-5 | +| 8 | IP话机 | SIP话机(如Yealink T46U) | 150 | 坐席通话终端 | 0.05-0.08 | 7.5-12 | +| 9 | 坐席耳麦 | 专业话务耳麦 | 150 | 坐席配件 | 0.02-0.05 | 3-7.5 | +| **硬件合计** | | | | | | **47-80.5** | + +### 7.2 软件清单 + +| 序号 | 软件/平台 | 说明 | 授权方式 | 预估费用(万元) | +|-----|---------|------|---------|----------------| +| 1 | FreeSWITCH | 开源软交换平台 | 开源免费 | 0 | +| 2 | Linux操作系统 | CentOS/Rocky Linux | 开源免费 | 0 | +| 3 | MySQL数据库 | 社区版 | 开源免费 | 0 | +| 4 | Redis | 缓存 | 开源免费 | 0 | +| 5 | Nginx | 反向代理/负载均衡 | 开源免费 | 0 | +| 6 | 阿里云ASR | 语音识别 | 按量付费 | 详见运营费用 | +| 7 | 阿里云TTS | 语音合成 | 按量付费 | 详见运营费用 | +| 8 | NLU对话平台 | 意图识别 | 按需对接 | 视平台定价 | +| 9 | 监控软件(Zabbix/Prometheus) | 系统监控 | 开源免费 | 0 | +| **软件合计** | | | | **0(开源为主)** | + +### 7.3 开发成本估算 + +| 模块 | 预估人天 | 说明 | +|------|---------|------| +| FreeSWITCH环境搭建与配置 | 15 | 主备部署、线路对接、编解码配置 | +| IVR流程开发 | 20 | 多业务线IVR、语音导航 | +| ACD分配模块 | 25 | 多种分配策略、溢出逻辑 | +| CTI中间件开发 | 40 | ESL封装、业务逻辑 | +| 坐席状态管理 | 15 | 状态机、持久化 | +| API服务开发 | 30 | 全部API接口 | +| WebSocket推送服务 | 15 | 事件推送 | +| 软电话条组件 | 25 | 前端组件、WebRTC集成 | +| 预测式外呼引擎 | 30 | 外呼算法、任务管理 | +| 机器人外呼模块 | 20 | TTS集成、按键采集 | +| 呼入机器人模块 | 25 | ASR/NLU集成、对话流程 | +| 录音管理模块 | 10 | 录音存储、检索 | +| 班长监控模块 | 15 | 监听、强插、强拆 | +| 实时监控大屏 | 20 | 前端大屏、实时数据 | +| 高可用部署 | 15 | 主备切换、Keepalived | +| 联调测试 | 30 | 全流程测试 | +| **合计** | **350人天** | | + +**按人天单价1500-2500元估算,开发成本约:52.5-87.5万元** + +--- + +## 第八章 预算汇总 + +### 8.1 一次性投入 + +| 项目 | 费用(万元) | +|------|-------------| +| 硬件采购 | 47-80.5 | +| 软件(开源为主) | 0 | +| 开发成本 | 52.5-87.5 | +| 实施部署 | 5-10 | +| **一次性投入合计** | **104.5-178** | + +### 8.2 年度运营费用 + +| 项目 | 月费用(万元) | 年费用(万元) | 备注 | +|------|--------------|---------------|------| +| **线路月租** | | | | +| 400号码(3个) | 0.3-1.5 | 3.6-18 | 3条业务线独立号码 | +| SIP中继并发费(120路) | 0.6-1.2 | 7.2-14.4 | 按50-100元/路/月 | +| **通信费** | | | | +| 呼入通话费 | 5-9 | 60-108 | 180万分钟/月×0.03-0.05元 | +| 呼出通话费 | 21.6-40.5 | 259-486 | 270万分钟/月×0.08-0.15元 | +| **云服务费** | | | | +| 阿里云ASR | 2-5 | 24-60 | 按使用量 | +| 阿里云TTS | 1-3 | 12-36 | 按使用量 | +| **其他** | | | | +| 机房/带宽 | 1-2 | 12-24 | 如自有机房可降低 | +| 系统维护 | 1-2 | 12-24 | 人员/备件 | +| **年度运营合计** | | **390-770** | | + +### 8.3 总预算汇总 + +| 项目 | 费用(万元) | +|------|-------------| +| **首年投入** | 494.5-948(一次性+首年运营) | +| **后续每年** | 390-770(运营费用) | + +> ⚠️ **说明:** +> - 通信费用是大头,约占年度成本的80%以上 +> - 具体费用取决于运营商议价、实际话务量 +> - 可通过接通率优化、IVR分流等降低通信成本 + +--- + +## 第九章 项目里程碑建议 + +按照3-4个月上线周期规划: + +| 阶段 | 周期 | 主要工作 | 交付物 | +|------|------|---------|--------| +| **一、需求确认与设计** | 第1-2周 | 需求评审、技术方案设计、架构设计 | 技术方案文档、接口设计文档 | +| **二、环境搭建** | 第3-4周 | 硬件采购到货、FreeSWITCH环境部署、线路对接 | 基础环境就绪 | +| **三、核心功能开发** | 第5-10周 | CTI中间件、API服务、坐席管理、呼入呼出基础功能 | 核心功能可用 | +| **四、高级功能开发** | 第11-14周 | 预测式外呼、机器人模块、IVR、监控大屏 | 全功能开发完成 | +| **五、集成联调** | 第15-16周 | CRM对接、全流程联调、高可用测试 | 联调通过 | +| **六、测试与优化** | 第17-18周 | 功能测试、性能测试、Bug修复 | 测试报告 | +| **七、试运行** | 第19-20周 | 小范围上线、问题收集、优化调整 | 试运行报告 | +| **八、正式上线** | 第21周 | 全面上线、监控保障 | 系统上线 | + +``` +第1月 第2月 第3月 第4月 第5月 +|----|----|----|----|----|----|----|----|----|----| +[需求设计][环境搭建][====核心功能开发====][==高级功能==] + [集成联调][测试] + [试运行][上线] +``` + +--- + +## 第十章 风险与建议 + +### 10.1 项目风险 + +| 风险项 | 风险等级 | 影响 | 应对措施 | +|-------|---------|------|---------| +| FreeSWITCH技术门槛高 | 高 | 开发进度延迟 | 建议团队提前学习ESL开发,或引入有经验顾问 | +| 线路对接周期不可控 | 中 | 影响联调测试 | 提前与运营商沟通,预留缓冲时间 | +| ASR识别效果不及预期 | 中 | 影响机器人体验 | 提前做语料测试,必要时做热词优化 | +| 高可用切换影响业务 | 高 | 道路救援中断 | 充分测试切换机制,演练故障场景 | +| 录音存储空间不足 | 低 | 录音丢失 | 监控存储空间,提前扩容 | + +### 10.2 建议事项 + +1. **团队能力准备**:建议团队提前熟悉FreeSWITCH、ESL、SIP协议相关知识 +2. **分阶段上线**:可先上线基础呼入呼出功能,机器人功能迭代上线 +3. **压力测试**:上线前务必进行150坐席并发的压力测试 +4. **灰度发布**:建议先在一条业务线(如家政)试运行,再推广到道路救援 +5. **监控告警**:完善监控体系,特别是对道路救援线路的可用性监控 + +--- + +## 附录 + +### 附录A:术语表 +(见第一章1.4节) + +### 附录B:接口详细设计 +(需在技术设计阶段细化) + +### 附录C:IVR流程图 +(需在需求确认阶段补充) + +### 附录D:机器人话术模板 +(需在需求确认阶段补充) + +--- + +**文档结束** + +--- + +以上就是完整的需求规格说明书。请您审阅,如有需要补充或调整的内容,请随时告知!🎯 + +### USER +根据以上的文档,生成个项目立项书 + +### ASSISTANT +# 企业呼叫中心系统项目立项书 + +--- + +**项目名称**:企业呼叫中心系统建设项目 +**项目编号**:CC-2024-001 +**编制日期**:2024年1月 +**编 制 人**:_________________ +**审 核 人**:_________________ +**批 准 人**:_________________ + +--- + +## 文档修订记录 + +| 版本 | 日期 | 修订内容 | 修订人 | 审核人 | +|------|------|---------|--------|--------| +| V1.0 | 2024-01-XX | 初稿 | | | + +--- + +## 目录 + +1. [项目概述](#第一章-项目概述) +2. [项目背景与必要性](#第二章-项目背景与必要性) +3. [项目目标](#第三章-项目目标) +4. [项目范围](#第四章-项目范围) +5. [项目建设方案](#第五章-项目建设方案) +6. [项目投资预算](#第六章-项目投资预算) +7. [项目效益分析](#第七章-项目效益分析) +8. [项目实施计划](#第八章-项目实施计划) +9. [项目组织架构](#第九章-项目组织架构) +10. [风险分析与应对](#第十章-风险分析与应对) +11. [结论与建议](#第十一章-结论与建议) + +--- + +## 第一章 项目概述 + +### 1.1 项目名称 + +企业呼叫中心系统建设项目 + +### 1.2 项目建设单位 + +**单位名称**:_________________(企业名称) +**负 责 人**:_________________ +**联系方式**:_________________ + +### 1.3 项目概要 + +本项目旨在建设一套集**呼入呼出、智能IVR、机器人客服、预测式外呼**于一体的企业级呼叫中心系统,支撑公司道路救援、家政服务、代驾服务三大业务板块的客户服务与营销运营工作。 + +| 项目要素 | 内容描述 | +|---------|---------| +| **服务对象** | 个人消费者(ToC) | +| **业务范围** | 道路救援、家政服务、代驾服务 | +| **坐席规模** | 150坐席 | +| **日话务量** | 50,000通/日 | +| **服务时间** | 7×24小时 | +| **建设周期** | 3-4个月 | +| **建设方式** | 自主研发(基于FreeSWITCH开源平台) | +| **部署方式** | 私有化部署(主备高可用架构) | + +### 1.4 项目总投资 + +| 投资类别 | 金额(万元) | +|---------|-------------| +| 一次性建设投资 | 104.5-178 | +| 首年运营费用 | 390-770 | +| **首年总投入** | **494.5-948** | + +--- + +## 第二章 项目背景与必要性 + +### 2.1 企业现状 + +本企业主营**道路救援、家政服务、代驾服务**三大业务,面向个人消费者提供上门服务。随着业务规模的持续扩大,现有的客户服务模式已无法满足业务发展需求: + +**现状问题:** + +| 问题领域 | 具体问题 | 影响 | +|---------|---------|------| +| **客服能力** | 无统一呼叫平台,多部门分散接听 | 客户体验差,管理困难 | +| **响应效率** | 道路救援等紧急需求响应慢 | 客户流失,品牌受损 | +| **服务时间** | 夜间/节假日服务能力不足 | 错失订单,客户投诉 | +| **外呼效率** | 人工逐一拨打,效率低下 | 人力成本高,触达率低 | +| **数据管理** | 通话无录音,无法追溯 | 纠纷处理困难,质量无法管控 | +| **系统集成** | 与CRM系统无法打通 | 信息孤岛,坐席重复操作 | + +### 2.2 建设必要性 + +#### 2.2.1 业务发展的迫切需要 + +``` + 业务增长趋势 + 话务量 + ↑ + 60000│ ╭──── 预测增长 + │ ╭────╯ + 50000│ ╭────╯ ←── 当前日均50000通 + │ ╭────╯ + 40000│ ╭────╯ + │ ╭────╯ + 30000│╭────╯ + │ + └────────────────────────────────────→ 时间 + 2022 2023 2024 2025 +``` + +随着业务规模扩大,日话务量已达**50,000通**,预计未来2年将持续增长。迫切需要建设专业呼叫中心系统支撑业务发展。 + +#### 2.2.2 客户体验提升的需要 + +| 客户痛点 | 系统解决方案 | +|---------|-------------| +| 打电话难以接通 | 150坐席+智能排队+溢出机制 | +| 等待时间长 | IVR自助分流+机器人预处理 | +| 反复描述问题 | CRM弹屏,坐席即时了解客户信息 | +| 投诉处理无反馈 | 录音追溯+工单闭环 | + +#### 2.2.3 运营效率提升的需要 + +| 运营痛点 | 系统解决方案 | +|---------|-------------| +| 外呼效率低 | 预测式外呼,坐席利用率提升50%+ | +| 通知类工作繁重 | 机器人自动批量通知 | +| 回访覆盖率低 | 机器人自动回访+满意度调查 | +| 管理缺乏抓手 | 实时监控大屏+班长监控 | + +#### 2.2.4 合规与风控的需要 + +- 道路救援涉及紧急服务,需确保系统**高可用** +- 服务过程需**全程录音**,便于纠纷处理 +- 需与现有CRM系统**无缝集成**,确保数据安全 + +### 2.3 建设可行性 + +#### 2.3.1 技术可行性 + +| 技术领域 | 可行性分析 | +|---------|-----------| +| **呼叫平台** | FreeSWITCH开源成熟,已广泛应用于企业呼叫中心 | +| **智能语音** | 阿里云ASR/TTS技术成熟,识别准确率>95% | +| **系统集成** | 标准API/WebSocket接口,与现有CRM可顺利对接 | +| **高可用** | 主备架构+Keepalived方案成熟可靠 | + +#### 2.3.2 团队可行性 + +| 条件 | 评估 | +|------|------| +| 自有技术团队 | ✅ 具备自主开发能力 | +| FreeSWITCH经验 | ⚠️ 需提前培训或引入顾问支持 | +| 项目管理能力 | ✅ 具备中大型项目实施经验 | + +#### 2.3.3 经济可行性 + +- 自主研发相比采购商业产品**节省授权费用** +- 私有化部署**长期成本可控** +- 项目投资回报分析详见第七章 + +--- + +## 第三章 项目目标 + +### 3.1 总体目标 + +建设一套**安全、稳定、高效、智能**的企业级呼叫中心系统,实现客户服务统一接入、智能化分流、高效外呼,全面提升客户服务体验与运营效率。 + +### 3.2 具体目标 + +#### 3.2.1 能力目标 + +| 目标项 | 指标要求 | +|-------|---------| +| 坐席并发能力 | 支持150坐席同时在线 | +| 通话并发能力 | 支持120路并发通话 | +| 日处理能力 | 支持50,000通/日 | +| 系统可用性 | ≥99.9%(全年停机<8.76小时) | +| 故障切换时间 | ≤30秒 | + +#### 3.2.2 效率目标 + +| 目标项 | 当前值 | 目标值 | 提升幅度 | +|-------|-------|-------|---------| +| 人工坐席利用率 | 约40% | ≥70% | +75% | +| 外呼接通后转接时间 | N/A(人工拨号) | ≤3秒 | - | +| 批量通知人工耗时 | 100%人工 | 0%(全自动) | 释放人力 | +| 客户等待时长 | 无统计 | 平均<30秒 | - | + +#### 3.2.3 体验目标 + +| 目标项 | 指标要求 | +|-------|---------| +| 首次呼叫解决率 | ≥80% | +| IVR自助分流率 | ≥30% | +| 客户满意度 | ≥90% | + +### 3.3 建设原则 + +| 原则 | 说明 | +|------|------| +| **统一规划** | 三大业务线统一平台,避免重复建设 | +| **安全可靠** | 主备高可用,确保道路救援等紧急服务不中断 | +| **开放集成** | 标准API接口,与CRM/业务系统无缝对接 | +| **智能高效** | 引入AI能力,提升服务效率与体验 | +| **弹性扩展** | 架构支持未来扩展至300+坐席 | +| **自主可控** | 自主研发,核心技术自主掌握 | + +--- + +## 第四章 项目范围 + +### 4.1 业务范围 + +| 业务线 | 呼入场景 | 呼出场景 | +|-------|---------|---------| +| **道路救援** | 叫救援、咨询、投诉 | 回访、满意度调查、到达通知 | +| **家政服务** | 预约服务、咨询、投诉 | 回访、营销推广、上门提醒 | +| **代驾服务** | 叫代驾、咨询、投诉 | 回访、营销推广、到达通知 | + +### 4.2 功能范围 + +#### 4.2.1 本期建设范围(一期) + +| 模块 | 功能项 | 优先级 | +|------|-------|--------| +| **基础通话** | 呼入接听、呼出拨打、转接、保持、三方通话 | P0 | +| **智能IVR** | 语音导航、按键导航、语音识别、多业务分流 | P0 | +| **ACD分配** | 技能组管理、多种分配策略、排队溢出 | P0 | +| **坐席管理** | 签入签出、状态管理、软电话条 | P0 | +| **预测式外呼** | 批量外呼任务、自动拨打、接通转坐席 | P0 | +| **机器人外呼** | 批量通知、TTS播报、按键采集 | P0 | +| **呼入机器人** | FAQ问答、意图识别、转人工 | P0 | +| **录音管理** | 全程录音、录音存储、录音检索下载 | P0 | +| **班长监控** | 监听、强插、强拆 | P0 | +| **监控大屏** | 实时数据展示、坐席状态、排队监控 | P0 | +| **系统集成** | CRM弹屏、API接口、事件推送 | P0 | +| **高可用** | 主备部署、故障切换 | P0 | + +#### 4.2.2 二期规划范围(本期不含) + +| 模块 | 功能项 | 说明 | +|------|-------|------| +| **统计报表** | 话务报表、坐席绩效、外呼分析 | 二期建设 | +| **智能质检** | 语音转写、关键词检测、情绪分析 | 二期建设 | +| **密语功能** | 班长对坐席单向指导 | 二期建设 | +| **短信功能** | 不涉及,由第三方平台提供 | 不纳入 | + +### 4.3 集成范围 + +| 集成系统 | 集成方式 | 集成内容 | +|---------|---------|---------| +| **CRM系统** | API双向对接 | 来电弹屏、客户资料、工单创建 | +| **业务系统** | API对接 | 外呼任务推送、通知触发、结果回传 | +| **阿里云语音** | API调用 | ASR语音识别、TTS语音合成 | +| **NLU平台** | API对接 | 意图识别、对话管理 | + +--- + +## 第五章 项目建设方案 + +### 5.1 总体架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 用户层 │ +│ 道路救援客户 家政服务客户 代驾服务客户 │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ 400-XXX-XXX1 400-XXX-XXX2 固话/400 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 接入层 │ +│ 运营商SIP中继线路 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 呼叫控制层 │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ FreeSWITCH (主备高可用) │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ │ 呼入控制│ │ 呼出控制│ │智能IVR │ │ ACD分配│ │ 录音模块│ │ │ +│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 业务服务层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │CTI中间件 │ │API Gateway│ │ WS推送 │ │ 外呼引擎 │ │ 机器人引擎│ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ MySQL │ │ Redis │ │ NAS存储 │ │ +│ │ (主从集群) │ │(Sentinel)│ │ (录音) │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 集成层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ CRM系统 │ │ 业务系统 │ │ 阿里云ASR/TTS│ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 终端层 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ IP话机 │ │ 软电话条 │ │ +│ │ (150台) │ │ (嵌入CRM) │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 部署架构 + +采用**私有化部署+主备高可用**架构: + +``` + ┌─────────────────┐ + │ 虚拟IP(VIP) │ + │ SIP线路接入 │ + └────────┬────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ 主服务器 │◄──── Keepalived ────►│ 备服务器 │ + ├─────────────────┤ 心跳检测 ├─────────────────┤ + │ FreeSWITCH │ │ FreeSWITCH │ + │ CTI中间件 │ │ CTI中间件 │ + │ API服务 │ │ API服务 │ + │ WebSocket服务 │ │ WebSocket服务 │ + └────────┬────────┘ └────────┬────────┘ + │ │ + └─────────────────────┬─────────────────────┘ + │ + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MySQL主从集群 │ │ Redis Sentinel │ │ NAS存储 │ +│ (2节点) │ │ (3节点) │ │ (RAID冗余) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 5.3 技术选型 + +| 组件类型 | 技术选型 | 选型理由 | +|---------|---------|---------| +| **媒体服务器** | FreeSWITCH 1.10+ | 开源成熟、性能优异、功能完善 | +| **操作系统** | Rocky Linux 8/CentOS 8 | 稳定可靠、长期支持 | +| **开发语言** | Java/Golang | CTI中间件、API服务 | +| **前端框架** | Vue.js 3.x | 软电话条、监控大屏 | +| **数据库** | MySQL 8.0 | 成熟稳定、主从复制 | +| **缓存** | Redis 6.x | 高性能、Sentinel高可用 | +| **消息队列** | RabbitMQ/Kafka | 事件异步处理(可选) | +| **负载均衡** | Nginx | 反向代理、SSL卸载 | +| **高可用** | Keepalived | VIP漂移、故障切换 | +| **监控** | Prometheus + Grafana | 系统监控、告警 | +| **ASR/TTS** | 阿里云智能语音 | 识别准确率高、服务稳定 | + +### 5.4 网络规划 + +| 网络区域 | 网段规划 | 用途 | +|---------|---------|------| +| **业务网段** | 10.10.1.0/24 | 呼叫中心服务器 | +| **数据网段** | 10.10.2.0/24 | 数据库、存储 | +| **坐席网段** | 10.10.10.0/23 | 坐席终端、IP话机 | +| **管理网段** | 10.10.100.0/24 | 运维管理 | +| **SIP信令** | 独立VLAN | SIP中继对接 | + +### 5.5 号码规划 + +| 业务线 | 号码类型 | 号码 | 用途 | +|-------|---------|------|------| +| 道路救援 | 400号码 | 400-XXX-XXX1 | 客户呼入 | +| 家政服务 | 400号码 | 400-XXX-XXX2 | 客户呼入 | +| 代驾服务 | 400/固话 | 400-XXX-XXX3 | 客户呼入 | +| 外呼显号 | 固话 | 0XX-XXXXXXXX | 外呼主叫显示 | + +--- + +## 第六章 项目投资预算 + +### 6.1 投资概算汇总 + +| 投资类别 | 金额(万元) | 占比 | +|---------|-------------|------| +| 硬件设备 | 47-80.5 | 33%-45% | +| 软件及开发 | 52.5-87.5 | 37%-49% | +| 实施部署 | 5-10 | 4%-6% | +| **一次性投资合计** | **104.5-178** | **100%** | + +### 6.2 硬件投资明细 + +| 序号 | 设备名称 | 规格 | 数量 | 单价(万) | 合计(万) | +|-----|---------|------|------|---------|---------| +| 1 | 应用服务器 | 32核/64GB/500GB SSD+2TB | 2 | 5-8 | 10-16 | +| 2 | 数据库服务器 | 16核/64GB/1TB SSD | 2 | 4-6 | 8-12 | +| 3 | Redis服务器 | 8核/32GB/500GB SSD | 3 | 2-3 | 6-9 | +| 4 | NAS存储 | 96TB可用(RAID) | 1 | 8-15 | 8-15 | +| 5 | 网络交换机 | 千兆三层 | 2 | 1-2 | 2-4 | +| 6 | 防火墙 | 企业级 | 1 | 2-5 | 2-5 | +| 7 | IP话机 | SIP话机 | 150 | 0.05-0.08 | 7.5-12 | +| 8 | 坐席耳麦 | 专业话务耳麦 | 150 | 0.02-0.05 | 3-7.5 | +| | **硬件合计** | | | | **47-80.5** | + +### 6.3 软件及开发投资明细 + +| 序号 | 项目 | 说明 | 费用(万) | +|-----|------|------|---------| +| 1 | FreeSWITCH | 开源免费 | 0 | +| 2 | 操作系统 | Rocky Linux开源 | 0 | +| 3 | MySQL/Redis | 社区版开源 | 0 | +| 4 | 系统开发 | 350人天×1500-2500元 | 52.5-87.5 | +| | **软件及开发合计** | | **52.5-87.5** | + +### 6.4 实施部署投资 + +| 序号 | 项目 | 费用(万) | +|-----|------|---------| +| 1 | 硬件安装调试 | 1-2 | +| 2 | 网络部署配置 | 1-2 | +| 3 | 系统联调测试 | 2-4 | +| 4 | 培训及上线支持 | 1-2 | +| | **实施部署合计** | **5-10** | + +### 6.5 年度运营费用 + +| 项目 | 月费用(万) | 年费用(万) | +|------|-----------|-----------| +| **线路费用** | | | +| 400号码月租(3个) | 0.3-1.5 | 3.6-18 | +| SIP并发费(120路) | 0.6-1.2 | 7.2-14.4 | +| **通信费用** | | | +| 呼入通话费 | 5-9 | 60-108 | +| 呼出通话费 | 21.6-40.5 | 259-486 | +| **云服务费用** | | | +| 阿里云ASR | 2-5 | 24-60 | +| 阿里云TTS | 1-3 | 12-36 | +| **运维费用** | | | +| 机房/带宽 | 1-2 | 12-24 | +| 系统维护 | 1-2 | 12-24 | +| **年度运营合计** | | **390-770** | + +### 6.6 投资估算汇总表 + +| 项目 | 首年(万) | 第二年(万) | 第三年(万) | +|------|---------|-----------|-----------| +| 一次性建设投入 | 104.5-178 | - | - | +| 年度运营费用 | 390-770 | 390-770 | 390-770 | +| **年度总计** | **494.5-948** | **390-770** | **390-770** | +| **三年累计** | | | **1274.5-2488** | + +--- + +## 第七章 项目效益分析 + +### 7.1 经济效益 + +#### 7.1.1 人力成本节约 + +| 节约项 | 计算依据 | 年节约(万) | +|-------|---------|-----------| +| **外呼效率提升** | 坐席利用率从40%→70%,相当于节约45个坐席人力 | 45×8=360 | +| **批量通知自动化** | 原需5人专职,现全自动 | 5×8=40 | +| **回访自动化** | 原需10人,现机器人承担80% | 8×8=64 | +| **IVR自助分流** | 30%简单咨询自助解决,节约人力 | 15×8=120 | +| **年人力成本节约** | | **约584万** | + +> 注:按坐席人均年成本8万元估算 + +#### 7.1.2 业务增收 + +| 增收项 | 计算依据 | 年增收(万) | +|-------|---------|-----------| +| **接通率提升** | 减少客户流失,预计增收5% | 约200 | +| **外呼营销转化** | 新增营销触达能力 | 约150 | +| **年业务增收** | | **约350万** | + +#### 7.1.3 投资回报分析 + +| 指标 | 数值 | +|------|------| +| 一次性投入 | 141万(取中值) | +| 年运营成本 | 580万(取中值) | +| 年成本节约 | 584万 | +| 年业务增收 | 350万 | +| **年净收益** | **354万** | +| **投资回收期** | **约4.8个月** | + +### 7.2 管理效益 + +| 效益项 | 说明 | +|-------|------| +| **服务可追溯** | 全程录音,服务质量可追溯,纠纷可举证 | +| **管理可视化** | 实时监控大屏,管理者实时掌握运营状态 | +| **决策有依据** | 话务数据积累,为排班、扩容提供数据支撑 | +| **流程标准化** | IVR+机器人固化服务流程,服务质量稳定 | + +### 7.3 客户效益 + +| 效益项 | 说明 | +|-------|------| +| **接通率提升** | 专业排队机制,减少占线挂断 | +| **等待时间缩短** | IVR分流+智能分配,平均等待<30秒 | +| **服务体验提升** | 来电自动识别,坐席了解客户历史,避免重复描述 | +| **服务时间扩展** | 7×24小时服务,紧急需求随时响应 | + +--- + +## 第八章 项目实施计划 + +### 8.1 项目周期 + +**总工期:4个月(17周)** + +### 8.2 阶段划分 + +``` + 1月 2月 3月 4月 5月 + |----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----| +W1 W2 W3 W4 W5 W6 W7 W8 W9 W10 W11 W12 W13 W14 W15 W16 W17 W18 + +[需求设计 ] + [环境搭建 ] + [========= 核心功能开发 =========] + [=== 高级功能开发 ===] + [集成联调] + [测试优化] + [试运行][上线] +``` + +### 8.3 里程碑计划 + +| 里程碑 | 计划时间 | 交付物 | 验收标准 | +|-------|---------|-------|---------| +| **M1: 需求确认** | 第2周末 | 需求规格说明书、技术方案 | 评审通过 | +| **M2: 环境就绪** | 第4周末 | 硬件到位、基础环境部署完成 | FreeSWITCH可接打电话 | +| **M3: 核心功能完成** | 第10周末 | 呼入呼出、ACD、坐席管理、API | 核心流程跑通 | +| **M4: 全功能完成** | 第14周末 | 外呼、机器人、监控大屏 | 全部功能可用 | +| **M5: 联调测试完成** | 第16周末 | CRM集成完成、测试报告 | 全流程联调通过 | +| **M6: 试运行完成** | 第18周末 | 试运行报告 | 问题收敛,达到上线标准 | +| **M7: 正式上线** | 第19周 | 上线报告 | 系统平稳运行 | + +### 8.4 详细进度计划 + +| 阶段 | 时间 | 主要任务 | 交付物 | +|------|------|---------|--------| +| **一、需求设计** | W1-W2 | 需求评审、架构设计、接口设计、详细设计 | 技术方案、接口文档、设计文档 | +| **二、环境搭建** | W3-W4 | 硬件采购到货、网络部署、FreeSWITCH安装、线路对接 | 基础环境就绪 | +| **三、核心开发** | W5-W10 | CTI中间件、API服务、坐席管理、ACD、录音、软电话条 | 核心功能可用 | +| **四、高级开发** | W11-W14 | 预测式外呼、机器人模块、IVR语音识别、监控大屏 | 高级功能可用 | +| **五、集成联调** | W15-W16 | CRM对接、全流程联调、高可用测试 | 联调通过 | +| **六、测试优化** | W16-W17 | 功能测试、性能测试、安全测试、Bug修复 | 测试报告 | +| **七、试运行** | W18 | 小范围上线、问题收集 | 试运行报告 | +| **八、正式上线** | W19 | 全面上线、监控保障 | 上线完成 | + +--- + +## 第九章 项目组织架构 + +### 9.1 组织架构图 + +``` + ┌─────────────────┐ + │ 项目发起人 │ + │ (公司高层) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ 项目经理 │ + │ (总体负责) │ + └────────┬────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 技术开发组 │ │ 业务需求组 │ │ 测试实施组 │ +├───────────────┤ ├───────────────┤ ├───────────────┤ +│ · 架构师 │ │ · 业务分析师 │ │ · 测试工程师 │ +│ · 后端开发 │ │ · 产品经理 │ │ · 实施工程师 │ +│ · 前端开发 │ │ · 业务对接人 │ │ · 运维工程师 │ +│ · FreeSWITCH │ │ │ │ │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 9.2 角色职责 + +| 角色 | 人数 | 职责 | +|------|------|------| +| **项目发起人** | 1 | 项目决策、资源协调、重大问题决策 | +| **项目经理** | 1 | 项目整体管理、进度控制、风险管理、对外沟通 | +| **架构师** | 1 | 技术架构设计、技术选型、技术难点攻关 | +| **后端开发** | 3-4 | CTI中间件、API服务、外呼引擎、机器人模块开发 | +| **前端开发** | 1-2 | 软电话条、监控大屏开发 | +| **FreeSWITCH工程师** | 1-2 | FreeSWITCH配置、IVR开发、ESL对接 | +| **业务分析师** | 1 | 需求分析、业务流程梳理 | +| **测试工程师** | 1-2 | 测试用例、功能测试、性能测试 | +| **实施工程师** | 1 | 环境部署、系统上线 | +| **运维工程师** | 1 | 系统运维、监控告警 | +| **合计** | **12-16人** | | + +### 9.3 沟通机制 + +| 会议类型 | 频率 | 参与人 | 内容 | +|---------|------|-------|------| +| **项目周会** | 每周一次 | 全体成员 | 进度汇报、问题协调、下周计划 | +| **每日站会** | 每日 | 开发+测试 | 当日任务、阻塞问题 | +| **技术评审会** | 按需 | 技术组 | 技术方案评审、代码评审 | +| **里程碑汇报** | 每里程碑 | 项目经理+发起人 | 阶段成果、风险汇报 | +| **上线评审会** | 上线前 | 全体+业务方 | 上线准备度检查 | + +--- + +## 第十章 风险分析与应对 + +### 10.1 风险清单 + +| 风险编号 | 风险描述 | 可能性 | 影响程度 | 风险等级 | +|---------|---------|--------|---------|---------| +| R01 | FreeSWITCH技术门槛高,团队经验不足 | 高 | 高 | **高** | +| R02 | 运营商线路对接周期长 | 中 | 高 | **高** | +| R03 | CRM系统对接复杂度超预期 | 中 | 中 | **中** | +| R04 | ASR识别效果不及预期 | 中 | 中 | **中** | +| R05 | 硬件采购/到货延迟 | 低 | 高 | **中** | +| R06 | 并发性能不达标 | 低 | 高 | **中** | +| R07 | 关键人员离职 | 低 | 高 | **中** | +| R08 | 需求变更频繁 | 中 | 中 | **中** | + +### 10.2 应对措施 + +| 风险编号 | 应对措施 | 责任人 | +|---------|---------|--------| +| **R01** | 1. 项目前期组织FreeSWITCH专项培训
2. 考虑引入外部FreeSWITCH顾问支持
3. 预留20%时间缓冲 | 项目经理 | +| **R02** | 1. 提前2个月启动线路申请
2. 准备备选运营商
3. 先用测试线路开发,并行对接正式线路 | 项目经理 | +| **R03** | 1. 需求阶段明确接口规范
2. 预留接口联调时间
3. CRM团队提前介入 | 架构师 | +| **R04** | 1. 提前进行ASR效果测试
2. 准备热词优化方案
3. 设计降级策略(按键兜底) | 技术组 | +| **R05** | 1. 提前下单采购
2. 准备备选供应商
3. 先用云主机开发 | 项目经理 | +| **R06** | 1. 上线前进行压力测试
2. 准备扩容方案
3. 架构支持水平扩展 | 架构师 | +| **R07** | 1. 知识文档化
2. 关键模块双人备份
3. 提前储备人员 | 项目经理 | +| **R08** | 1. 需求基线管理
2. 变更走评审流程
3. 评估变更影响后决策 | 产品经理 | + +### 10.3 风险监控 + +| 风险等级 | 监控频率 | 汇报层级 | +|---------|---------|---------| +| **高** | 每周跟踪 | 项目发起人 | +| **中** | 双周跟踪 | 项目经理 | +| **低** | 月度跟踪 | 项目组 | + +--- + +## 第十一章 结论与建议 + +### 11.1 结论 + +经过全面分析,本项目具备以下条件: + +| 维度 | 评估结论 | +|------|---------| +| **必要性** | ✅ 业务发展迫切需要,现有模式已无法支撑 | +| **可行性** | ✅ 技术方案成熟,团队具备基本能力 | +| **经济性** | ✅ 投资回报期短(约4.8个月),效益显著 | +| **风险性** | ⚠️ 存在技术风险,但可通过措施控制 | + +**综合结论:项目具备立项条件,建议批准立项。** + +### 11.2 建议 + +1. **尽快启动**:业务增长迅速,建议尽快批准立项,启动实施 +2. **技术准备**:建议在项目正式启动前,组织FreeSWITCH技术培训 +3. **线路先行**:建议立即启动运营商线路申请,避免成为关键路径 +4. **分步上线**:建议优先上线家政业务,验证后再推广至道路救援 +5. **预留缓冲**:建议预留10%-20%预算和时间缓冲,应对不确定性 +6. **运维同步**:建议运维人员全程参与,确保上线后平稳运行 + +### 11.3 审批意见 + +| 审批环节 | 审批人 | 日期 | 意见 | +|---------|-------|------|------| +| **技术评审** | | | | +| **财务评审** | | | | +| **业务评审** | | | | +| **最终审批** | | | | + +--- + +## 附件清单 + +| 附件编号 | 附件名称 | 说明 | +|---------|---------|------| +| 附件1 | 《呼叫中心系统需求规格说明书》 | 详细需求文档 | +| 附件2 | 硬件选型参考清单 | 硬件详细配置及备选品牌 | +| 附件3 | 运营商线路资费对比 | 各运营商资费方案对比 | +| 附件4 | FreeSWITCH技术评估报告 | 技术可行性详细分析(如有) | + +--- + +**文档结束** + +--- + +以上是完整的项目立项书,涵盖了项目背景、建设方案、投资预算、效益分析、实施计划、组织架构和风险应对等内容。请您审阅,如需调整或补充,请随时告知!📋 + +### USER +能否提供FreeSWITCH的详细技术架构和ESL开发指南? + +### ASSISTANT +# FreeSWITCH技术架构与ESL开发指南 + +--- + +## 目录 + +1. [FreeSWITCH技术架构](#第一部分-freeswitch技术架构) +2. [核心模块详解](#第二部分-核心模块详解) +3. [ESL开发指南](#第三部分-esl开发指南) +4. [呼叫中心场景开发实践](#第四部分-呼叫中心场景开发实践) +5. [高可用部署方案](#第五部分-高可用部署方案) +6. [性能优化指南](#第六部分-性能优化指南) + +--- + +## 第一部分 FreeSWITCH技术架构 + +### 1.1 整体架构概览 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 外部系统/应用层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ CTI中间件 │ │ Web应用 │ │ 业务系统 │ │ 监控系统 │ │ 第三方App│ │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ │ │ +│ └─────────────┴─────────────┴──────┬──────┴─────────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ ESL接口 │ │ +│ │ (TCP:8021) │ │ +│ └──────┬──────┘ │ +└───────────────────────────────────────────┼─────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ FreeSWITCH 核心层 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Core(核心引擎) │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ 状态机管理 │ │ 内存管理 │ │ 线程池管理 │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ 事件系统 │ │ 数据库抽象 │ │ 日志系统 │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Module Interface(模块接口层) │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Endpoint │ │ Application │ │ Codec │ │ Dialplan │ │ │ +│ │ │ 终端模块 │ │ 应用模块 │ │ 编解码 │ │ 拨号计划 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Event │ │ Format │ │ Logger │ │ XML │ │ │ +│ │ │ 事件模块 │ │ 文件格式 │ │ 日志模块 │ │ XML处理 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ 协议/媒体层 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ mod_sofia │ │ mod_verto │ │ mod_rtmp │ │ mod_skinny │ │ +│ │ (SIP协议) │ │ (WebRTC) │ │ (RTMP) │ │ (Cisco) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ 媒体处理引擎 │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ RTP处理 │ │ 编解码转换 │ │ 录音处理 │ │ DTMF检测 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ +┌───────────────────────────────────────────▼─────────────────────────────────┐ +│ 网络层 │ +│ SIP/UDP/TCP RTP/SRTP WebSocket HTTP │ +│ :5060 :16384-32768 :8081 :8080 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心概念 + +#### 1.2.1 Channel(通道) + +Channel是FreeSWITCH中最核心的概念,代表一路通话的端点。 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 一通电话 │ +│ │ +│ ┌─────────────┐ Bridge ┌─────────────┐ │ +│ │ Channel A │◄─────────────────────────►│ Channel B │ │ +│ │ (主叫方) │ │ (被叫方) │ │ +│ │ │ │ │ │ +│ │ UUID: xxx-a │ │ UUID: xxx-b │ │ +│ │ State: CS_ │ │ State: CS_ │ │ +│ │ Variables │ │ Variables │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Channel状态机:** + +``` + ┌──────────────────────────────────────────┐ + │ │ + ▼ │ +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──┴──────┐ +│ NEW │───►│ INIT │───►│ ROUTING │───►│ EXECUTE │───►│ HANGUP │ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ + │ │ + ┌────▼────┐ ┌────▼────┐ + │ CONSUME │ │ EXCHANGE│ + │ MEDIA │ │ MEDIA │ + └─────────┘ └─────────┘ + +Channel状态说明: +├── CS_NEW : 新建通道 +├── CS_INIT : 初始化 +├── CS_ROUTING : 路由中(执行dialplan) +├── CS_EXECUTE : 执行应用中 +├── CS_EXCHANGE_MEDIA : 媒体交换中(bridged) +├── CS_CONSUME_MEDIA : 媒体消费中 +├── CS_HIBERNATE : 休眠状态 +├── CS_HANGUP : 挂断中 +└── CS_DESTROY : 销毁 +``` + +#### 1.2.2 Session(会话) + +Session包含Channel及其相关上下文信息: + +```c +// Session结构概念图 +Session { + ├── Channel // 通道信息 + │ ├── UUID // 唯一标识 + │ ├── State // 当前状态 + │ ├── Direction // 呼叫方向(inbound/outbound) + │ └── Variables // 通道变量 + │ + ├── Caller Profile // 主叫信息 + │ ├── caller_id_name + │ ├── caller_id_number + │ ├── destination_number + │ └── ... + │ + ├── Codec // 编解码信息 + ├── RTP Session // RTP会话 + └── Bridge Session // 桥接会话(如果有) +} +``` + +#### 1.2.3 Event(事件) + +FreeSWITCH基于事件驱动架构,所有状态变化都产生事件: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FreeSWITCH Event System │ +│ │ +│ ┌───────────────┐ │ +│ │ Event Producer│ ──────┐ │ +│ │ (模块/核心) │ │ │ +│ └───────────────┘ │ ┌─────────────────────────┐ │ +│ ├─────►│ Event Dispatcher │ │ +│ ┌───────────────┐ │ │ (事件分发器) │ │ +│ │ Event Producer│ ──────┤ └───────────┬─────────────┘ │ +│ │ (模块/核心) │ │ │ │ +│ └───────────────┘ │ ┌───────────┴─────────────┐ │ +│ │ │ │ │ │ +│ ┌───────────────┐ │ ▼ ▼ ▼ │ +│ │ Event Producer│ ──────┘ ┌───────┐ ┌───────┐ ┌───────┐│ +│ │ (模块/核心) │ │ ESL │ │Internal│ │ Lua/ ││ +│ └───────────────┘ │Consumer│ │Handler │ │ JS ││ +│ └───────┘ └───────┘ └───────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +**常用事件类型:** + +| 事件名称 | 触发时机 | 用途 | +|---------|---------|------| +| CHANNEL_CREATE | 通道创建 | 新呼叫进入 | +| CHANNEL_ANSWER | 通话接听 | 通话建立 | +| CHANNEL_BRIDGE | 通道桥接 | 双方接通 | +| CHANNEL_UNBRIDGE | 取消桥接 | 桥接断开 | +| CHANNEL_HANGUP | 挂断 | 通话结束 | +| CHANNEL_HANGUP_COMPLETE | 挂断完成 | 通话完全结束 | +| CHANNEL_STATE | 状态变化 | 状态监控 | +| DTMF | 按键检测 | IVR交互 | +| RECORD_START | 开始录音 | 录音控制 | +| RECORD_STOP | 停止录音 | 录音控制 | +| CUSTOM | 自定义事件 | 业务扩展 | + +### 1.3 目录结构 + +``` +/usr/local/freeswitch/ +├── bin/ # 可执行文件 +│ └── freeswitch # 主程序 +│ +├── conf/ # 配置文件目录 +│ ├── freeswitch.xml # 主配置文件 +│ ├── vars.xml # 全局变量 +│ ├── sip_profiles/ # SIP配置 +│ │ ├── internal.xml # 内部Profile(坐席注册) +│ │ └── external.xml # 外部Profile(中继对接) +│ ├── dialplan/ # 拨号计划 +│ │ ├── default.xml # 默认拨号计划 +│ │ └── public.xml # 公共拨号计划(呼入) +│ ├── directory/ # 用户目录(分机注册) +│ │ └── default/ +│ │ └── 1000.xml # 分机1000配置 +│ ├── autoload_configs/ # 模块自动加载配置 +│ │ ├── modules.conf.xml # 模块加载列表 +│ │ ├── sofia.conf.xml # SIP模块配置 +│ │ ├── event_socket.conf.xml# ESL配置 +│ │ └── ... +│ └── lang/ # 语音提示 +│ +├── lib/ # 库文件 +├── mod/ # 模块文件(.so) +├── log/ # 日志目录 +├── db/ # 数据库文件 +├── recordings/ # 录音文件 +├── sounds/ # 语音文件 +├── scripts/ # 脚本目录 +└── storage/ # 存储目录 +``` + +--- + +## 第二部分 核心模块详解 + +### 2.1 mod_sofia(SIP模块) + +mod_sofia是FreeSWITCH的SIP协议栈实现,基于Sofia-SIP库。 + +#### 2.1.1 架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ mod_sofia │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Sofia-SIP Stack │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ NUA │ │ NTA │ │ TPORT │ │ SDP │ │ │ +│ │ │ User Agent│ │Transaction│ │ Transport │ │ Parser │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Profile Management │ │ +│ │ │ │ +│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ +│ │ │ Internal Profile │ │ External Profile │ │ │ +│ │ │ (坐席/分机注册) │ │ (中继/网关) │ │ │ +│ │ │ UDP:5060 │ │ UDP:5080 │ │ │ +│ │ └─────────────────────┘ └─────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Gateway Management │ │ │ +│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ +│ │ │ │Gateway A│ │Gateway B│ │Gateway C│ ... │ │ │ +│ │ │ │(运营商1) │ │(运营商2) │ │(备用) │ │ │ │ +│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 2.1.2 配置示例 + +**Internal Profile(坐席注册):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**External Profile(中继对接):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 2.2 Dialplan(拨号计划) + +拨号计划定义了呼叫的路由和处理逻辑。 + +#### 2.2.1 执行流程 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Dialplan 执行流程 │ +│ │ +│ 来电进入 │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Context 选择 ││ +│ │ (根据Profile配置的context) ││ +│ │ 例如: public (呼入) / default (内部) ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Extension 匹配 ││ +│ │ (按顺序匹配condition) ││ +│ │ ││ +│ │ Extension 1: ──┐ ││ +│ │ Condition ───┼─► 不匹配 ───► 下一个Extension ││ +│ │ │ ││ +│ │ └─► 匹配 ───► 执行Actions ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Action 执行 ││ +│ │ (按顺序执行应用) ││ +│ │ ││ +│ │ action: answer ││ +│ │ action: playback ││ +│ │ action: bridge ││ +│ │ ... ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +#### 2.2.2 配置示例 + +**呼入路由(public.xml):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**IVR和呼叫处理(default.xml):** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 2.3 mod_event_socket(ESL模块) + +ESL是FreeSWITCH对外提供的控制接口,是开发呼叫中心的核心。 + +#### 2.3.1 工作模式 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ESL 工作模式 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Inbound 模式 │ │ +│ │ (客户端主动连接) │ │ +│ │ │ │ +│ │ ┌───────────┐ TCP:8021 ┌──────────────────┐ │ │ +│ │ │ CTI中间件 │ ──────────────────────► │ FreeSWITCH │ │ │ +│ │ │ (Client) │ ◄────────────────────── │ (Server) │ │ │ +│ │ └───────────┘ Event Stream └──────────────────┘ │ │ +│ │ │ │ +│ │ 特点: │ │ +│ │ - 客户端主动连接FreeSWITCH │ │ +│ │ - 可以订阅事件、发送命令 │ │ +│ │ - 适合集中控制、监控场景 │ │ +│ │ - 推荐呼叫中心使用此模式 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Outbound 模式 │ │ +│ │ (FreeSWITCH主动连接) │ │ +│ │ │ │ +│ │ ┌──────────────────┐ TCP ┌───────────────┐ │ │ +│ │ │ FreeSWITCH │ ──────────────────►│ 应用服务器 │ │ │ +│ │ │ (每个呼叫) │ ◄────────────────── │ (Server) │ │ │ +│ │ └──────────────────┘ └───────────────┘ │ │ +│ │ │ │ +│ │ 特点: │ │ +│ │ - 每个呼叫FreeSWITCH主动连接应用 │ │ +│ │ - 在dialplan中使用socket应用触发 │ │ +│ │ - 适合简单IVR场景 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 2.3.2 ESL配置 + +```xml + + + + + + + + + + + + + + + +``` + +--- + +## 第三部分 ESL开发指南 + +### 3.1 ESL协议详解 + +#### 3.1.1 协议格式 + +ESL使用类HTTP的文本协议: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ESL 消息格式 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 请求格式: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ command [arguments] │ │ │ +│ │ │ Header-Name: header-value │ │ │ +│ │ │ Header-Name: header-value │ │ │ +│ │ │ │ │ │ +│ │ │ [body if Content-Length > 0] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 响应格式: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Content-Type: text/event-plain │ │ │ +│ │ │ Content-Length: 1234 │ │ │ +│ │ │ Reply-Text: +OK [message] │ │ │ +│ │ │ │ │ │ +│ │ │ [body] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.1.2 常用命令 + +| 命令类型 | 命令 | 说明 | +|---------|------|------| +| **认证** | `auth ` | 连接认证 | +| **事件订阅** | `event plain ` | 订阅事件 | +| | `event plain ALL` | 订阅所有事件 | +| | `event plain CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_HANGUP` | 订阅指定事件 | +| | `nixevent ` | 取消订阅 | +| | `noevents` | 取消所有订阅 | +| **API命令** | `api [args]` | 同步执行API | +| | `bgapi [args]` | 异步执行API | +| **过滤器** | `filter
` | 过滤特定事件 | +| | `filter Unique-ID ` | 只接收指定UUID事件 | +| **呼叫控制** | `sendmsg ` | 向指定通道发送消息 | +| | `execute` | 在通道上执行应用 | +| **其他** | `log ` | 设置日志级别 | +| | `linger` | 保持连接 | +| | `nolinger` | 取消保持 | +| | `exit` | 断开连接 | + +### 3.2 Java ESL开发 + +#### 3.2.1 项目结构 + +``` +callcenter-cti/ +├── pom.xml +├── src/main/java/ +│ └── com/callcenter/ +│ ├── Application.java +│ ├── esl/ +│ │ ├── EslConnectionManager.java # ESL连接管理 +│ │ ├── EslEventHandler.java # 事件处理 +│ │ ├── EslCommandExecutor.java # 命令执行 +│ │ └── InboundClient.java # Inbound客户端 +│ ├── service/ +│ │ ├── AgentService.java # 坐席服务 +│ │ ├── CallService.java # 呼叫服务 +│ │ ├── QueueService.java # 排队服务 +│ │ └── AcdService.java # 话务分配服务 +│ ├── model/ +│ │ ├── Agent.java # 坐席模型 +│ │ ├── Call.java # 呼叫模型 +│ │ └── Queue.java # 队列模型 +│ ├── api/ +│ │ ├── AgentController.java # 坐席API +│ │ └── CallController.java # 呼叫API +│ └── websocket/ +│ └── EventPushHandler.java # 事件推送 +└── src/main/resources/ + └── application.yml +``` + +#### 3.2.2 Maven依赖 + +```xml + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.freeswitch.esl.client + org.freeswitch.esl.client + 0.9.2 + + + + + io.netty + netty-all + 4.1.86.Final + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.alibaba + fastjson + 2.0.23 + + + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + + +``` + +#### 3.2.3 ESL连接管理 + +```java +package com.callcenter.esl; + +import lombok.extern.slf4j.Slf4j; +import org.freeswitch.esl.client.inbound.Client; +import org.freeswitch.esl.client.inbound.InboundConnectionFailure; +import org.freeswitch.esl.client.transport.event.EslEvent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * ESL连接管理器 + * 负责与FreeSWITCH建立和维护连接 + */ +@Slf4j +@Component +public class EslConnectionManager { + + @Value("${freeswitch.esl.host:127.0.0.1}") + private String eslHost; + + @Value("${freeswitch.esl.port:8021}") + private int eslPort; + + @Value("${freeswitch.esl.password:ClueCon}") + private String eslPassword; + + @Value("${freeswitch.esl.timeout:30}") + private int timeout; + + private Client eslClient; + private final AtomicBoolean connected = new AtomicBoolean(false); + private final AtomicBoolean reconnecting = new AtomicBoolean(false); + + private ScheduledExecutorService scheduler; + private ExecutorService eventExecutor; + + private EslEventHandler eventHandler; + + @PostConstruct + public void init() { + // 初始化线程池 + scheduler = Executors.newSingleThreadScheduledExecutor( + r -> new Thread(r, "esl-reconnect")); + eventExecutor = new ThreadPoolExecutor( + 4, 16, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(10000), + r -> new Thread(r, "esl-event-handler"), + new ThreadPoolExecutor.CallerRunsPolicy() + ); + + // 建立连接 + connect(); + + // 启动心跳检测 + startHeartbeat(); + } + + /** + * 建立ESL连接 + */ + public synchronized void connect() { + if (connected.get()) { + log.info("ESL已连接,无需重复连接"); + return; + } + + try { + log.info("正在连接FreeSWITCH ESL: {}:{}", eslHost, eslPort); + + eslClient = new Client(); + eslClient.connect(eslHost, eslPort, eslPassword, timeout); + + // 订阅事件 + subscribeEvents(); + + // 设置事件监听器 + eslClient.setEventSubscriptions("plain", "all"); + eslClient.addEventListener((ctx, event) -> { + handleEvent(event); + }); + + connected.set(true); + reconnecting.set(false); + log.info("ESL连接成功"); + + } catch (InboundConnectionFailure e) { + log.error("ESL连接失败: {}", e.getMessage()); + connected.set(false); + scheduleReconnect(); + } + } + + /** + * 订阅所需事件 + */ + private void subscribeEvents() { + String events = String.join(" ", + "CHANNEL_CREATE", + "CHANNEL_ANSWER", + "CHANNEL_BRIDGE", + "CHANNEL_UNBRIDGE", + "CHANNEL_HANGUP", + "CHANNEL_HANGUP_COMPLETE", + "CHANNEL_STATE", + "DTMF", + "RECORD_START", + "RECORD_STOP", + "CUSTOM" + ); + + eslClient.setEventSubscriptions("plain", events); + eslClient.addEventFilter("Event-Name", "CHANNEL_CREATE"); + log.info("已订阅事件: {}", events); + } + + /** + * 处理事件 + */ + private void handleEvent(EslEvent event) { + eventExecutor.submit(() -> { + try { + if (eventHandler != null) { + eventHandler.handle(event); + } + } catch (Exception e) { + log.error("事件处理异常: {}", e.getMessage(), e); + } + }); + } + + /** + * 启动心跳检测 + */ + private void startHeartbeat() { + scheduler.scheduleAtFixedRate(() -> { + if (connected.get()) { + try { + // 发送status命令检测连接 + eslClient.sendSyncApiCommand("status", ""); + } catch (Exception e) { + log.warn("心跳检测失败,连接可能已断开"); + connected.set(false); + scheduleReconnect(); + } + } + }, 30, 30, TimeUnit.SECONDS); + } + + /** + * 计划重连 + */ + private void scheduleReconnect() { + if (reconnecting.compareAndSet(false, true)) { + scheduler.schedule(() -> { + log.info("尝试重新连接ESL..."); + connect(); + }, 5, TimeUnit.SECONDS); + } + } + + /** + * 同步执行API命令 + */ + public String sendSyncApiCommand(String command, String args) { + checkConnection(); + try { + return eslClient.sendSyncApiCommand(command, args).getBodyLines().get(0); + } catch (Exception e) { + log.error("执行API命令失败: {} {}", command, args, e); + throw new RuntimeException("ESL命令执行失败", e); + } + } + + /** + * 异步执行API命令 + */ + public CompletableFuture sendAsyncApiCommand(String command, String args) { + return CompletableFuture.supplyAsync(() -> + sendSyncApiCommand(command, args), eventExecutor); + } + + /** + * 向指定UUID发送消息执行应用 + */ + public void sendExecute(String uuid, String application, String args) { + checkConnection(); + try { + eslClient.sendSyncApiCommand("uuid_broadcast", + uuid + " " + application + "::" + args + " both"); + } catch (Exception e) { + log.error("发送Execute失败: uuid={}, app={}", uuid, application, e); + } + } + + /** + * 检查连接状态 + */ + private void checkConnection() { + if (!connected.get()) { + throw new RuntimeException("ESL未连接"); + } + } + + /** + * 获取连接状态 + */ + public boolean isConnected() { + return connected.get(); + } + + /** + * 设置事件处理器 + */ + public void setEventHandler(EslEventHandler handler) { + this.eventHandler = handler; + } + + @PreDestroy + public void destroy() { + try { + connected.set(false); + if (eslClient != null) { + eslClient.close(); + } + if (scheduler != null) { + scheduler.shutdown(); + } + if (eventExecutor != null) { + eventExecutor.shutdown(); + } + log.info("ESL连接已关闭"); + } catch (Exception e) { + log.error("关闭ESL连接异常", e); + } + } +} +``` + +#### 3.2.4 事件处理器 + +```java +package com.callcenter.esl; + +import com.callcenter.model.Call; +import com.callcenter.model.CallDirection; +import com.callcenter.model.CallState; +import com.callcenter.service.AcdService; +import com.callcenter.service.CallService; +import com.callcenter.websocket.EventPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.freeswitch.esl.client.transport.event.EslEvent; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * ESL事件处理器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EslEventHandler { + + private final CallService callService; + private final AcdService acdService; + private final EventPushService eventPushService; + + /** + * 处理ESL事件 + */ + public void handle(EslEvent event) { + String eventName = event.getEventName(); + Map headers = event.getEventHeaders(); + + log.debug("收到事件: {}, UUID: {}", eventName, headers.get("Unique-ID")); + + switch (eventName) { + case "CHANNEL_CREATE": + handleChannelCreate(headers); + break; + case "CHANNEL_ANSWER": + handleChannelAnswer(headers); + break; + case "CHANNEL_BRIDGE": + handleChannelBridge(headers); + break; + case "CHANNEL_UNBRIDGE": + handleChannelUnbridge(headers); + break; + case "CHANNEL_HANGUP": + handleChannelHangup(headers); + break; + case "CHANNEL_HANGUP_COMPLETE": + handleChannelHangupComplete(headers); + break; + case "DTMF": + handleDtmf(headers); + break; + case "RECORD_START": + handleRecordStart(headers); + break; + case "RECORD_STOP": + handleRecordStop(headers); + break; + case "CUSTOM": + handleCustomEvent(headers); + break; + default: + log.trace("未处理的事件类型: {}", eventName); + } + } + + /** + * 处理通道创建事件(新呼叫) + */ + private void handleChannelCreate(Map headers) { + String uuid = headers.get("Unique-ID"); + String direction = headers.get("Call-Direction"); + String callerNumber = headers.get("Caller-Caller-ID-Number"); + String calleeNumber = headers.get("Caller-Destination-Number"); + + log.info("新呼叫: uuid={}, direction={}, caller={}, callee={}", + uuid, direction, callerNumber, calleeNumber); + + // 创建呼叫记录 + Call call = new Call(); + call.setUuid(uuid); + call.setDirection("inbound".equals(direction) ? + CallDirection.INBOUND : CallDirection.OUTBOUND); + call.setCallerNumber(callerNumber); + call.setCalleeNumber(calleeNumber); + call.setState(CallState.RINGING); + call.setCreateTime(LocalDateTime.now()); + + // 获取业务标识(从通道变量) + String businessType = headers.get("variable_business_type"); + String skillGroup = headers.get("variable_skill_group"); + call.setBusinessType(businessType); + call.setSkillGroup(skillGroup); + + callService.saveCall(call); + + // 如果是呼入,触发ACD排队 + if ("inbound".equals(direction)) { + acdService.enqueue(call); + } + + // 推送事件到前端 + eventPushService.pushCallEvent("CALL_RINGING", call); + } + + /** + * 处理通话接听事件 + */ + private void handleChannelAnswer(Map headers) { + String uuid = headers.get("Unique-ID"); + + log.info("通话接听: uuid={}", uuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.ANSWERED); + call.setAnswerTime(LocalDateTime.now()); + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_ANSWERED", call); + } + } + + /** + * 处理通道桥接事件(双方接通) + */ + private void handleChannelBridge(Map headers) { + String uuid = headers.get("Unique-ID"); + String otherUuid = headers.get("Other-Leg-Unique-ID"); + + log.info("通道桥接: uuid={}, otherUuid={}", uuid, otherUuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.BRIDGED); + call.setBridgeUuid(otherUuid); + call.setBridgeTime(LocalDateTime.now()); + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_BRIDGED", call); + } + } + + /** + * 处理取消桥接事件 + */ + private void handleChannelUnbridge(Map headers) { + String uuid = headers.get("Unique-ID"); + + log.info("取消桥接: uuid={}", uuid); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.UNBRIDGED); + callService.updateCall(call); + + eventPushService.pushCallEvent("CALL_UNBRIDGED", call); + } + } + + /** + * 处理挂断事件 + */ + private void handleChannelHangup(Map headers) { + String uuid = headers.get("Unique-ID"); + String hangupCause = headers.get("Hangup-Cause"); + + log.info("通话挂断: uuid={}, cause={}", uuid, hangupCause); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setState(CallState.HANGUP); + call.setHangupCause(hangupCause); + call.setHangupTime(LocalDateTime.now()); + + // 计算通话时长 + if (call.getAnswerTime() != null) { + long duration = java.time.Duration.between( + call.getAnswerTime(), call.getHangupTime()).getSeconds(); + call.setDuration(duration); + } + + callService.updateCall(call); + + // 推送事件 + eventPushService.pushCallEvent("CALL_HANGUP", call); + + // 更新坐席状态 + if (call.getAgentId() != null) { + acdService.onCallEnd(call.getAgentId(), call); + } + } + } + + /** + * 处理挂断完成事件(获取完整话单) + */ + private void handleChannelHangupComplete(Map headers) { + String uuid = headers.get("Unique-ID"); + String billsec = headers.get("variable_billsec"); + String duration = headers.get("variable_duration"); + String recordingPath = headers.get("variable_recording_path"); + + log.info("通话结束: uuid={}, billsec={}, recording={}", + uuid, billsec, recordingPath); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setBillsec(billsec != null ? Long.parseLong(billsec) : 0); + call.setRecordingPath(recordingPath); + callService.updateCall(call); + + // 推送话单 + eventPushService.pushCdr(call); + } + } + + /** + * 处理DTMF按键事件 + */ + private void handleDtmf(Map headers) { + String uuid = headers.get("Unique-ID"); + String digit = headers.get("DTMF-Digit"); + + log.info("DTMF按键: uuid={}, digit={}", uuid, digit); + + // 推送按键事件 + eventPushService.pushDtmf(uuid, digit); + } + + /** + * 处理录音开始事件 + */ + private void handleRecordStart(Map headers) { + String uuid = headers.get("Unique-ID"); + String recordPath = headers.get("Record-File-Path"); + + log.info("开始录音: uuid={}, path={}", uuid, recordPath); + + Call call = callService.getCallByUuid(uuid); + if (call != null) { + call.setRecordingPath(recordPath); + callService.updateCall(call); + } + } + + /** + * 处理录音停止事件 + */ + private void handleRecordStop(Map headers) { + String uuid = headers.get("Unique-ID"); + String recordPath = headers.get("Record-File-Path"); + + log.info("停止录音: uuid={}, path={}", uuid, recordPath); + } + + /** + * 处理自定义事件 + */ + private void handleCustomEvent(Map headers) { + String subclass = headers.get("Event-Subclass"); + log.debug("自定义事件: subclass={}", subclass); + + // 处理特定的自定义事件 + if ("callcenter::info".equals(subclass)) { + // 处理呼叫中心相关事件 + } + } +} +``` + +#### 3.2.5 呼叫服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * 呼叫服务 + * 提供呼叫控制相关功能 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CallService { + + private final EslConnectionManager eslManager; + private final RedisTemplate redisTemplate; + + private static final String CALL_KEY_PREFIX = "call:"; + private static final int CALL_EXPIRE_HOURS = 24; + + /** + * 发起外呼 + * @param agentId 坐席ID + * @param callerNumber 主叫号码(外显号码) + * @param calleeNumber 被叫号码 + * @return 呼叫UUID + */ + public String originate(String agentId, String callerNumber, String calleeNumber) { + log.info("发起外呼: agent={}, caller={}, callee={}", + agentId, callerNumber, calleeNumber); + + // 获取坐席分机号 + Agent agent = getAgent(agentId); + if (agent == null || agent.getState() != AgentState.READY) { + throw new RuntimeException("坐席不可用"); + } + + // 构建originate命令 + // originate {变量}呼叫字符串 &应用(参数) + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("origination_caller_id_number=").append(callerNumber).append(","); + cmd.append("origination_caller_id_name=公司名称").append(","); + cmd.append("agent_id=").append(agentId).append(","); + cmd.append("direction=outbound").append(","); + cmd.append("RECORD_STEREO=true"); + cmd.append("}"); + + // A-leg: 先呼叫坐席分机 + cmd.append("user/").append(agent.getExtension()); + + // B-leg: 坐席接听后呼叫客户 + cmd.append(" &bridge({origination_caller_id_number=") + .append(callerNumber) + .append("}sofia/gateway/carrier_primary/") + .append(calleeNumber) + .append(")"); + + String result = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (result.startsWith("+OK")) { + String uuid = result.substring(4).trim(); + log.info("外呼成功: uuid={}", uuid); + return uuid; + } else { + log.error("外呼失败: {}", result); + throw new RuntimeException("外呼失败: " + result); + } + } + + /** + * 接听来电 + */ + public void answer(String uuid) { + log.info("接听来电: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_answer", uuid); + } + + /** + * 挂断通话 + */ + public void hangup(String uuid) { + log.info("挂断通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_kill", uuid); + } + + /** + * 挂断通话(带原因) + */ + public void hangup(String uuid, String cause) { + log.info("挂断通话: uuid={}, cause={}", uuid, cause); + eslManager.sendSyncApiCommand("uuid_kill", uuid + " " + cause); + } + + /** + * 保持通话 + */ + public void hold(String uuid) { + log.info("保持通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_hold", uuid); + } + + /** + * 取回保持 + */ + public void unhold(String uuid) { + log.info("取回通话: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_hold", "off " + uuid); + } + + /** + * 静音 + */ + public void mute(String uuid) { + log.info("静音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_audio", uuid + " start mute"); + } + + /** + * 取消静音 + */ + public void unmute(String uuid) { + log.info("取消静音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_audio", uuid + " stop"); + } + + /** + * 盲转(直接转接) + */ + public void blindTransfer(String uuid, String destination) { + log.info("盲转: uuid={}, dest={}", uuid, destination); + eslManager.sendSyncApiCommand("uuid_transfer", uuid + " " + destination); + } + + /** + * 咨询转(先咨询再转接) + */ + public String consultTransfer(String uuid, String destination) { + log.info("咨询转: uuid={}, dest={}", uuid, destination); + + // 先保持当前通话 + hold(uuid); + + // 发起咨询呼叫 + String consultCmd = "{origination_uuid=consult_" + uuid + "}user/" + destination; + String result = eslManager.sendSyncApiCommand("originate", + consultCmd + " &park()"); + + if (result.startsWith("+OK")) { + return result.substring(4).trim(); + } + throw new RuntimeException("咨询转失败"); + } + + /** + * 完成咨询转接 + */ + public void completeTransfer(String originalUuid, String consultUuid) { + log.info("完成咨询转: original={}, consult={}", originalUuid, consultUuid); + eslManager.sendSyncApiCommand("uuid_bridge", originalUuid + " " + consultUuid); + } + + /** + * 三方通话 + */ + public void conference(String uuid1, String uuid2, String conferenceName) { + log.info("三方通话: uuid1={}, uuid2={}, conf={}", uuid1, uuid2, conferenceName); + + // 将两个通道加入会议 + eslManager.sendSyncApiCommand("uuid_transfer", + uuid1 + " conference:" + conferenceName + " inline"); + eslManager.sendSyncApiCommand("uuid_transfer", + uuid2 + " conference:" + conferenceName + " inline"); + } + + /** + * 播放语音 + */ + public void playback(String uuid, String filePath) { + log.info("播放语音: uuid={}, file={}", uuid, filePath); + eslManager.sendSyncApiCommand("uuid_broadcast", + uuid + " " + filePath + " both"); + } + + /** + * 停止播放 + */ + public void stopPlayback(String uuid) { + log.info("停止播放: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_break", uuid); + } + + /** + * 开始录音 + */ + public void startRecording(String uuid, String filePath) { + log.info("开始录音: uuid={}, file={}", uuid, filePath); + eslManager.sendSyncApiCommand("uuid_record", uuid + " start " + filePath); + } + + /** + * 停止录音 + */ + public void stopRecording(String uuid, String filePath) { + log.info("停止录音: uuid={}", uuid); + eslManager.sendSyncApiCommand("uuid_record", uuid + " stop " + filePath); + } + + /** + * 桥接两个通道 + */ + public void bridge(String uuid1, String uuid2) { + log.info("桥接通道: uuid1={}, uuid2={}", uuid1, uuid2); + eslManager.sendSyncApiCommand("uuid_bridge", uuid1 + " " + uuid2); + } + + /** + * 设置通道变量 + */ + public void setVariable(String uuid, String name, String value) { + eslManager.sendSyncApiCommand("uuid_setvar", + uuid + " " + name + " " + value); + } + + /** + * 获取通道变量 + */ + public String getVariable(String uuid, String name) { + return eslManager.sendSyncApiCommand("uuid_getvar", uuid + " " + name); + } + + // ============ 呼叫记录管理 ============ + + /** + * 保存呼叫记录 + */ + public void saveCall(Call call) { + String key = CALL_KEY_PREFIX + call.getUuid(); + redisTemplate.opsForValue().set(key, call, CALL_EXPIRE_HOURS, TimeUnit.HOURS); + } + + /** + * 获取呼叫记录 + */ + public Call getCallByUuid(String uuid) { + String key = CALL_KEY_PREFIX + uuid; + return (Call) redisTemplate.opsForValue().get(key); + } + + /** + * 更新呼叫记录 + */ + public void updateCall(Call call) { + saveCall(call); + } + + /** + * 获取坐席信息 + */ + private Agent getAgent(String agentId) { + // TODO: 从坐席服务获取 + return null; + } +} +``` + +#### 3.2.6 ACD话务分配服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * ACD话务分配服务 + * 实现多种分配策略:历史服务优先、最长空闲优先、轮询 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AcdService { + + private final EslConnectionManager eslManager; + private final RedisTemplate redisTemplate; + private final AgentService agentService; + private final CallService callService; + + // 队列Key前缀 + private static final String QUEUE_KEY_PREFIX = "queue:"; + // 坐席最后服务客户映射 + private static final String AGENT_CUSTOMER_MAP_KEY = "agent:customer:map"; + // 坐席最后空闲时间 + private static final String AGENT_IDLE_TIME_KEY = "agent:idle:time:"; + // 轮询索引 + private final Map roundRobinIndex = new ConcurrentHashMap<>(); + + /** + * 呼叫入队 + */ + public void enqueue(Call call) { + String skillGroup = call.getSkillGroup(); + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + QueueItem item = new QueueItem(); + item.setCallUuid(call.getUuid()); + item.setCallerNumber(call.getCallerNumber()); + item.setSkillGroup(skillGroup); + item.setEnqueueTime(LocalDateTime.now()); + item.setPriority(calculatePriority(call)); + + // 使用SortedSet,按优先级和入队时间排序 + double score = item.getPriority() * 1000000000L + + System.currentTimeMillis() / 1000; + redisTemplate.opsForZSet().add(queueKey, item, score); + + log.info("呼叫入队: uuid={}, queue={}, priority={}", + call.getUuid(), skillGroup, item.getPriority()); + + // 尝试立即分配 + tryAssign(skillGroup); + } + + /** + * 计算优先级(数字越小优先级越高) + */ + private int calculatePriority(Call call) { + // VIP客户优先级高 + if ("VIP".equals(call.getCustomerLevel())) { + return 1; + } + // 投诉优先 + if ("complaint".equals(call.getBusinessType())) { + return 2; + } + // 普通客户 + return 5; + } + + /** + * 尝试分配队列中的呼叫 + */ + public void tryAssign(String skillGroup) { + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + // 获取可用坐席 + List availableAgents = agentService.getAvailableAgents(skillGroup); + if (availableAgents.isEmpty()) { + log.debug("技能组{}无可用坐席", skillGroup); + return; + } + + // 获取队列中的呼叫 + Set queueItems = redisTemplate.opsForZSet().range(queueKey, 0, 0); + if (queueItems == null || queueItems.isEmpty()) { + log.debug("技能组{}队列为空", skillGroup); + return; + } + + QueueItem item = (QueueItem) queueItems.iterator().next(); + Call call = callService.getCallByUuid(item.getCallUuid()); + + if (call == null || call.getState() == CallState.HANGUP) { + // 呼叫已结束,移除队列 + redisTemplate.opsForZSet().remove(queueKey, item); + tryAssign(skillGroup); // 继续处理下一个 + return; + } + + // 选择坐席 + Agent selectedAgent = selectAgent(availableAgents, call); + + if (selectedAgent != null) { + // 从队列移除 + redisTemplate.opsForZSet().remove(queueKey, item); + + // 分配呼叫 + assignCallToAgent(call, selectedAgent); + } + } + + /** + * 选择坐席(多策略) + */ + private Agent selectAgent(List agents, Call call) { + if (agents.isEmpty()) { + return null; + } + + // 策略1: 历史服务优先 + Agent lastAgent = findLastServingAgent(call.getCallerNumber(), agents); + if (lastAgent != null) { + log.info("历史服务优先: 客户{}分配给上次服务坐席{}", + call.getCallerNumber(), lastAgent.getId()); + return lastAgent; + } + + // 策略2: 最长空闲优先 + Agent longestIdleAgent = findLongestIdleAgent(agents); + if (longestIdleAgent != null) { + log.info("最长空闲优先: 选择坐席{}", longestIdleAgent.getId()); + return longestIdleAgent; + } + + // 策略3: 轮询 + Agent roundRobinAgent = selectByRoundRobin(agents, call.getSkillGroup()); + log.info("轮询分配: 选择坐席{}", roundRobinAgent.getId()); + return roundRobinAgent; + } + + /** + * 查找上次服务该客户的坐席 + */ + private Agent findLastServingAgent(String customerNumber, List agents) { + String lastAgentId = (String) redisTemplate.opsForHash() + .get(AGENT_CUSTOMER_MAP_KEY, customerNumber); + + if (lastAgentId != null) { + return agents.stream() + .filter(a -> a.getId().equals(lastAgentId)) + .findFirst() + .orElse(null); + } + return null; + } + + /** + * 查找空闲时间最长的坐席 + */ + private Agent findLongestIdleAgent(List agents) { + return agents.stream() + .max(Comparator.comparing(agent -> { + String key = AGENT_IDLE_TIME_KEY + agent.getId(); + Long idleTime = (Long) redisTemplate.opsForValue().get(key); + return idleTime != null ? idleTime : 0L; + })) + .orElse(null); + } + + /** + * 轮询选择坐席 + */ + private Agent selectByRoundRobin(List agents, String skillGroup) { + int currentIndex = roundRobinIndex.getOrDefault(skillGroup, 0); + int nextIndex = (currentIndex + 1) % agents.size(); + roundRobinIndex.put(skillGroup, nextIndex); + return agents.get(currentIndex); + } + + /** + * 分配呼叫给坐席 + */ + private void assignCallToAgent(Call call, Agent agent) { + log.info("分配呼叫: uuid={} -> agent={}", call.getUuid(), agent.getId()); + + // 更新坐席状态 + agentService.updateAgentState(agent.getId(), AgentState.RINGING); + + // 更新呼叫信息 + call.setAgentId(agent.getId()); + call.setAgentExtension(agent.getExtension()); + callService.updateCall(call); + + // 将呼叫转接到坐席分机 + // 方式1: 使用uuid_transfer + eslManager.sendSyncApiCommand("uuid_transfer", + call.getUuid() + " " + agent.getExtension() + " XML default"); + + // 方式2: 使用originate + bridge(更灵活) + // String bridgeCmd = String.format( + // "{origination_uuid=%s,agent_id=%s}user/%s", + // UUID.randomUUID(), agent.getId(), agent.getExtension()); + // eslManager.sendSyncApiCommand("uuid_broadcast", + // call.getUuid() + " bridge::" + bridgeCmd + " both"); + + // 记录坐席-客户关系(用于历史服务优先) + redisTemplate.opsForHash().put(AGENT_CUSTOMER_MAP_KEY, + call.getCallerNumber(), agent.getId()); + } + + /** + * 通话结束处理 + */ + public void onCallEnd(String agentId, Call call) { + log.info("通话结束: agent={}, call={}", agentId, call.getUuid()); + + // 更新坐席状态为后处理 + agentService.updateAgentState(agentId, AgentState.WRAP_UP); + + // 设置后处理超时(30秒后自动置闲) + // 由定时任务处理 + } + + /** + * 坐席置闲时记录空闲时间 + */ + public void onAgentReady(String agentId, String skillGroup) { + String key = AGENT_IDLE_TIME_KEY + agentId; + redisTemplate.opsForValue().set(key, System.currentTimeMillis()); + + // 尝试分配队列中的呼叫 + tryAssign(skillGroup); + } + + /** + * 定时处理队列 + */ + @Scheduled(fixedDelay = 1000) + public void processQueues() { + // 获取所有技能组 + Set skillGroups = agentService.getAllSkillGroups(); + for (String skillGroup : skillGroups) { + tryAssign(skillGroup); + } + } + + /** + * 获取队列状态 + */ + public QueueStatus getQueueStatus(String skillGroup) { + String queueKey = QUEUE_KEY_PREFIX + skillGroup; + + QueueStatus status = new QueueStatus(); + status.setSkillGroup(skillGroup); + status.setWaitingCount(redisTemplate.opsForZSet().size(queueKey)); + status.setAvailableAgents(agentService.getAvailableAgents(skillGroup).size()); + + // 计算最长等待时间 + Set items = redisTemplate.opsForZSet().range(queueKey, 0, 0); + if (items != null && !items.isEmpty()) { + QueueItem firstItem = (QueueItem) items.iterator().next(); + long waitSeconds = java.time.Duration.between( + firstItem.getEnqueueTime(), LocalDateTime.now()).getSeconds(); + status.setLongestWaitTime(waitSeconds); + } + + return status; + } + + /** + * 溢出处理 + * 当技能组无可用坐席且等待时间超过阈值时,溢出到其他技能组 + */ + @Scheduled(fixedDelay = 5000) + public void handleOverflow() { + // 获取溢出配置 + Map overflowConfig = getOverflowConfig(); + + for (Map.Entry entry : overflowConfig.entrySet()) { + String sourceGroup = entry.getKey(); + String targetGroup = entry.getValue(); + + String queueKey = QUEUE_KEY_PREFIX + sourceGroup; + Set items = redisTemplate.opsForZSet().range(queueKey, 0, -1); + + if (items == null || items.isEmpty()) { + continue; + } + + // 检查是否需要溢出 + List sourceAgents = agentService.getAvailableAgents(sourceGroup); + List targetAgents = agentService.getAvailableAgents(targetGroup); + + if (sourceAgents.isEmpty() && !targetAgents.isEmpty()) { + for (Object obj : items) { + QueueItem item = (QueueItem) obj; + long waitSeconds = java.time.Duration.between( + item.getEnqueueTime(), LocalDateTime.now()).getSeconds(); + + // 等待超过60秒,溢出 + if (waitSeconds > 60) { + log.info("溢出处理: uuid={}, {}->{}", + item.getCallUuid(), sourceGroup, targetGroup); + + // 移出原队列 + redisTemplate.opsForZSet().remove(queueKey, item); + + // 加入目标队列 + item.setSkillGroup(targetGroup); + String targetQueueKey = QUEUE_KEY_PREFIX + targetGroup; + double score = item.getPriority() * 1000000000L + + System.currentTimeMillis() / 1000; + redisTemplate.opsForZSet().add(targetQueueKey, item, score); + + // 尝试分配 + tryAssign(targetGroup); + } + } + } + } + } + + private Map getOverflowConfig() { + // 配置:源技能组 -> 目标技能组 + Map config = new HashMap<>(); + config.put("rescue_group", "default_group"); + config.put("housekeeping_group", "default_group"); + config.put("driver_group", "default_group"); + return config; + } +} +``` + +#### 3.2.7 坐席服务 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import com.callcenter.websocket.EventPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 坐席服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentService { + + private final RedisTemplate redisTemplate; + private final EslConnectionManager eslManager; + private final EventPushService eventPushService; + private final AcdService acdService; + + private static final String AGENT_KEY_PREFIX = "agent:"; + private static final String AGENT_SET_KEY = "agents:all"; + private static final String SKILL_GROUP_AGENTS_KEY = "skillgroup:agents:"; + + /** + * 坐席签入 + */ + public void signIn(String agentId, String extension, List skillGroups) { + log.info("坐席签入: agentId={}, extension={}, skills={}", + agentId, extension, skillGroups); + + // 检查分机是否注册 + String regStatus = eslManager.sendSyncApiCommand("sofia_contact", + "internal/" + extension + "@${domain}"); + if (regStatus.contains("error")) { + throw new RuntimeException("分机未注册: " + extension); + } + + Agent agent = new Agent(); + agent.setId(agentId); + agent.setExtension(extension); + agent.setSkillGroups(skillGroups); + agent.setState(AgentState.NOT_READY); + agent.setSignInTime(LocalDateTime.now()); + + // 保存坐席信息 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.opsForValue().set(key, agent); + + // 加入坐席集合 + redisTemplate.opsForSet().add(AGENT_SET_KEY, agentId); + + // 加入技能组 + for (String skillGroup : skillGroups) { + redisTemplate.opsForSet().add(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId); + } + + // 推送状态变更 + eventPushService.pushAgentState(agent); + } + + /** + * 坐席签出 + */ + public void signOut(String agentId) { + log.info("坐席签出: agentId={}", agentId); + + Agent agent = getAgent(agentId); + if (agent == null) { + return; + } + + // 检查是否在通话中 + if (agent.getState() == AgentState.BUSY || + agent.getState() == AgentState.RINGING) { + throw new RuntimeException("坐席正在通话中,无法签出"); + } + + // 从技能组移除 + for (String skillGroup : agent.getSkillGroups()) { + redisTemplate.opsForSet().remove(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId); + } + + // 从坐席集合移除 + redisTemplate.opsForSet().remove(AGENT_SET_KEY, agentId); + + // 删除坐席信息 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.delete(key); + + // 推送签出事件 + agent.setState(AgentState.OFFLINE); + eventPushService.pushAgentState(agent); + } + + /** + * 坐席置闲 + */ + public void ready(String agentId) { + log.info("坐席置闲: agentId={}", agentId); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + updateAgentState(agentId, AgentState.READY); + + // 通知ACD有坐席可用 + for (String skillGroup : agent.getSkillGroups()) { + acdService.onAgentReady(agentId, skillGroup); + } + } + + /** + * 坐席置忙 + */ + public void notReady(String agentId, String reason) { + log.info("坐席置忙: agentId={}, reason={}", agentId, reason); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + agent.setNotReadyReason(reason); + updateAgentState(agentId, AgentState.NOT_READY); + } + + /** + * 坐席小休 + */ + public void pause(String agentId, String reason) { + log.info("坐席小休: agentId={}, reason={}", agentId, reason); + + Agent agent = getAgent(agentId); + if (agent == null) { + throw new RuntimeException("坐席不存在"); + } + + if (agent.getState() == AgentState.BUSY) { + throw new RuntimeException("坐席正在通话中"); + } + + agent.setPauseReason(reason); + updateAgentState(agentId, AgentState.PAUSED); + } + + /** + * 更新坐席状态 + */ + public void updateAgentState(String agentId, AgentState newState) { + Agent agent = getAgent(agentId); + if (agent == null) { + return; + } + + AgentState oldState = agent.getState(); + agent.setState(newState); + agent.setStateTime(LocalDateTime.now()); + + // 保存 + String key = AGENT_KEY_PREFIX + agentId; + redisTemplate.opsForValue().set(key, agent); + + log.info("坐席状态变更: agent={}, {} -> {}", agentId, oldState, newState); + + // 推送状态变更 + eventPushService.pushAgentState(agent); + } + + /** + * 获取坐席信息 + */ + public Agent getAgent(String agentId) { + String key = AGENT_KEY_PREFIX + agentId; + return (Agent) redisTemplate.opsForValue().get(key); + } + + /** + * 获取技能组可用坐席 + */ + public List getAvailableAgents(String skillGroup) { + Set agentIds = redisTemplate.opsForSet() + .members(SKILL_GROUP_AGENTS_KEY + skillGroup); + + if (agentIds == null || agentIds.isEmpty()) { + return Collections.emptyList(); + } + + return agentIds.stream() + .map(id -> getAgent((String) id)) + .filter(agent -> agent != null && agent.getState() == AgentState.READY) + .collect(Collectors.toList()); + } + + /** + * 获取所有技能组 + */ + public Set getAllSkillGroups() { + Set keys = redisTemplate.keys(SKILL_GROUP_AGENTS_KEY + "*"); + if (keys == null) { + return Collections.emptySet(); + } + return keys.stream() + .map(key -> key.replace(SKILL_GROUP_AGENTS_KEY, "")) + .collect(Collectors.toSet()); + } + + /** + * 获取所有坐席状态(监控大屏) + */ + public List getAllAgents() { + Set agentIds = redisTemplate.opsForSet().members(AGENT_SET_KEY); + if (agentIds == null) { + return Collections.emptyList(); + } + return agentIds.stream() + .map(id -> getAgent((String) id)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 获取坐席状态统计 + */ + public Map getAgentStateStats() { + return getAllAgents().stream() + .collect(Collectors.groupingBy(Agent::getState, Collectors.counting())); + } +} +``` + +### 3.3 Go ESL开发 + +如果您的团队更熟悉Go语言,以下是Go版本的ESL开发示例: + +#### 3.3.1 项目结构 + +``` +callcenter-cti-go/ +├── go.mod +├── go.sum +├── main.go +├── config/ +│ └── config.go +├── esl/ +│ ├── client.go # ESL客户端 +│ ├── event.go # 事件定义 +│ └── handler.go # 事件处理器 +├── service/ +│ ├── agent.go # 坐席服务 +│ ├── call.go # 呼叫服务 +│ └── acd.go # ACD服务 +├── model/ +│ └── models.go # 数据模型 +├── api/ +│ ├── router.go # 路由 +│ ├── agent_handler.go # 坐席API +│ └── call_handler.go # 呼叫API +└── websocket/ + └── hub.go # WebSocket推送 +``` + +#### 3.3.2 ESL客户端 + +```go +// esl/client.go +package esl + +import ( + "bufio" + "fmt" + "log" + "net" + "net/textproto" + "strconv" + "strings" + "sync" + "time" +) + +// ESL客户端 +type Client struct { + conn net.Conn + reader *textproto.Reader + writer *bufio.Writer + mu sync.Mutex + connected bool + password string + eventChan chan *Event + handlers []EventHandler + reconnect bool +} + +// 事件处理器接口 +type EventHandler interface { + Handle(event *Event) +} + +// 创建ESL客户端 +func NewClient(host string, port int, password string) (*Client, error) { + client := &Client{ + password: password, + eventChan: make(chan *Event, 1000), + handlers: make([]EventHandler, 0), + reconnect: true, + } + + err := client.connect(host, port) + if err != nil { + return nil, err + } + + // 启动事件读取协程 + go client.readLoop() + + // 启动事件分发协程 + go client.dispatchLoop() + + return client, nil +} + +// 连接FreeSWITCH +func (c *Client) connect(host string, port int) error { + addr := fmt.Sprintf("%s:%d", host, port) + + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("连接失败: %v", err) + } + + c.conn = conn + c.reader = textproto.NewReader(bufio.NewReader(conn)) + c.writer = bufio.NewWriter(conn) + + // 读取欢迎消息 + _, err = c.readMessage() + if err != nil { + return err + } + + // 认证 + err = c.auth() + if err != nil { + return err + } + + c.connected = true + log.Println("ESL连接成功") + + return nil +} + +// 认证 +func (c *Client) auth() error { + _, err := c.sendCommand(fmt.Sprintf("auth %s", c.password)) + if err != nil { + return fmt.Errorf("认证失败: %v", err) + } + return nil +} + +// 发送命令 +func (c *Client) sendCommand(command string) (*Message, error) { + c.mu.Lock() + defer c.mu.Unlock() + + _, err := c.writer.WriteString(command + "\n\n") + if err != nil { + return nil, err + } + c.writer.Flush() + + return c.readMessage() +} + +// 发送API命令 +func (c *Client) API(command string, args string) (string, error) { + cmd := fmt.Sprintf("api %s %s", command, args) + msg, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return msg.Body, nil +} + +// 发送后台API命令 +func (c *Client) BgAPI(command string, args string) (string, error) { + cmd := fmt.Sprintf("bgapi %s %s", command, args) + msg, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return msg.GetHeader("Job-UUID"), nil +} + +// 订阅事件 +func (c *Client) Subscribe(events ...string) error { + eventStr := strings.Join(events, " ") + _, err := c.sendCommand(fmt.Sprintf("event plain %s", eventStr)) + return err +} + +// 订阅所有事件 +func (c *Client) SubscribeAll() error { + _, err := c.sendCommand("event plain ALL") + return err +} + +// 读取消息 +func (c *Client) readMessage() (*Message, error) { + msg := &Message{ + Headers: make(map[string]string), + } + + // 读取headers + for { + line, err := c.reader.ReadLine() + if err != nil { + return nil, err + } + + if line == "" { + break + } + + parts := strings.SplitN(line, ": ", 2) + if len(parts) == 2 { + msg.Headers[parts[0]] = parts[1] + } + } + + // 读取body + if lenStr := msg.GetHeader("Content-Length"); lenStr != "" { + length, _ := strconv.Atoi(lenStr) + if length > 0 { + body := make([]byte, length) + _, err := c.reader.R.Read(body) + if err != nil { + return nil, err + } + msg.Body = string(body) + } + } + + return msg, nil +} + +// 事件读取循环 +func (c *Client) readLoop() { + for c.connected { + msg, err := c.readMessage() + if err != nil { + log.Printf("读取消息错误: %v", err) + c.connected = false + if c.reconnect { + c.tryReconnect() + } + return + } + + contentType := msg.GetHeader("Content-Type") + if contentType == "text/event-plain" { + event := parseEvent(msg) + c.eventChan <- event + } + } +} + +// 事件分发循环 +func (c *Client) dispatchLoop() { + for event := range c.eventChan { + for _, handler := range c.handlers { + go handler.Handle(event) + } + } +} + +// 尝试重连 +func (c *Client) tryReconnect() { + for i := 0; i < 10; i++ { + time.Sleep(5 * time.Second) + log.Printf("尝试重连ESL (第%d次)...", i+1) + + // TODO: 重新连接 + } +} + +// 添加事件处理器 +func (c *Client) AddHandler(handler EventHandler) { + c.handlers = append(c.handlers, handler) +} + +// 关闭连接 +func (c *Client) Close() { + c.connected = false + c.reconnect = false + if c.conn != nil { + c.conn.Close() + } + close(c.eventChan) +} + +// 消息结构 +type Message struct { + Headers map[string]string + Body string +} + +func (m *Message) GetHeader(name string) string { + if v, ok := m.Headers[name]; ok { + return v + } + return "" +} + +// 解析事件 +func parseEvent(msg *Message) *Event { + event := &Event{ + Headers: make(map[string]string), + } + + // 解析body中的headers + lines := strings.Split(msg.Body, "\n") + for _, line := range lines { + parts := strings.SplitN(line, ": ", 2) + if len(parts) == 2 { + // URL解码 + value := strings.ReplaceAll(parts[1], "%20", " ") + event.Headers[parts[0]] = value + } + } + + event.Name = event.GetHeader("Event-Name") + event.UUID = event.GetHeader("Unique-ID") + + return event +} +``` + +#### 3.3.3 事件定义 + +```go +// esl/event.go +package esl + +// 事件结构 +type Event struct { + Name string + UUID string + Headers map[string]string +} + +func (e *Event) GetHeader(name string) string { + if v, ok := e.Headers[name]; ok { + return v + } + return "" +} + +// 事件名称常量 +const ( + EventChannelCreate = "CHANNEL_CREATE" + EventChannelAnswer = "CHANNEL_ANSWER" + EventChannelBridge = "CHANNEL_BRIDGE" + EventChannelUnbridge = "CHANNEL_UNBRIDGE" + EventChannelHangup = "CHANNEL_HANGUP" + EventChannelHangupComplete = "CHANNEL_HANGUP_COMPLETE" + EventChannelState = "CHANNEL_STATE" + EventDTMF = "DTMF" + EventRecordStart = "RECORD_START" + EventRecordStop = "RECORD_STOP" + EventCustom = "CUSTOM" +) +``` + +#### 3.3.4 事件处理器 + +```go +// esl/handler.go +package esl + +import ( + "callcenter/service" + "log" +) + +// 呼叫中心事件处理器 +type CallCenterHandler struct { + callService *service.CallService + agentService *service.AgentService + acdService *service.AcdService +} + +func NewCallCenterHandler( + callService *service.CallService, + agentService *service.AgentService, + acdService *service.AcdService, +) *CallCenterHandler { + return &CallCenterHandler{ + callService: callService, + agentService: agentService, + acdService: acdService, + } +} + +func (h *CallCenterHandler) Handle(event *Event) { + switch event.Name { + case EventChannelCreate: + h.handleChannelCreate(event) + case EventChannelAnswer: + h.handleChannelAnswer(event) + case EventChannelBridge: + h.handleChannelBridge(event) + case EventChannelHangup: + h.handleChannelHangup(event) + case EventChannelHangupComplete: + h.handleChannelHangupComplete(event) + case EventDTMF: + h.handleDTMF(event) + } +} + +func (h *CallCenterHandler) handleChannelCreate(event *Event) { + uuid := event.UUID + direction := event.GetHeader("Call-Direction") + callerNumber := event.GetHeader("Caller-Caller-ID-Number") + calleeNumber := event.GetHeader("Caller-Destination-Number") + + log.Printf("新呼叫: uuid=%s, direction=%s, caller=%s, callee=%s", + uuid, direction, callerNumber, calleeNumber) + + // 创建呼叫记录 + call := &service.Call{ + UUID: uuid, + Direction: direction, + CallerNumber: callerNumber, + CalleeNumber: calleeNumber, + State: "ringing", + } + + h.callService.SaveCall(call) + + // 呼入排队 + if direction == "inbound" { + skillGroup := event.GetHeader("variable_skill_group") + call.SkillGroup = skillGroup + h.acdService.Enqueue(call) + } +} + +func (h *CallCenterHandler) handleChannelAnswer(event *Event) { + uuid := event.UUID + log.Printf("通话接听: uuid=%s", uuid) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "answered" + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleChannelBridge(event *Event) { + uuid := event.UUID + otherUUID := event.GetHeader("Other-Leg-Unique-ID") + log.Printf("通道桥接: uuid=%s, other=%s", uuid, otherUUID) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "bridged" + call.BridgeUUID = otherUUID + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleChannelHangup(event *Event) { + uuid := event.UUID + hangupCause := event.GetHeader("Hangup-Cause") + log.Printf("通话挂断: uuid=%s, cause=%s", uuid, hangupCause) + + call := h.callService.GetCall(uuid) + if call != nil { + call.State = "hangup" + call.HangupCause = hangupCause + h.callService.UpdateCall(call) + + // 更新坐席状态 + if call.AgentID != "" { + h.acdService.OnCallEnd(call.AgentID, call) + } + } +} + +func (h *CallCenterHandler) handleChannelHangupComplete(event *Event) { + uuid := event.UUID + billsec := event.GetHeader("variable_billsec") + recordingPath := event.GetHeader("variable_recording_path") + + log.Printf("通话结束: uuid=%s, billsec=%s, recording=%s", + uuid, billsec, recordingPath) + + call := h.callService.GetCall(uuid) + if call != nil { + call.Billsec = billsec + call.RecordingPath = recordingPath + h.callService.UpdateCall(call) + } +} + +func (h *CallCenterHandler) handleDTMF(event *Event) { + uuid := event.UUID + digit := event.GetHeader("DTMF-Digit") + log.Printf("DTMF按键: uuid=%s, digit=%s", uuid, digit) +} +``` + +--- + +## 第四部分 呼叫中心场景开发实践 + +### 4.1 预测式外呼实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 预测式外呼服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PredictiveDialerService { + + private final EslConnectionManager eslManager; + private final AgentService agentService; + private final CallService callService; + + // 外呼任务 + private final Map activeTasks = new ConcurrentHashMap<>(); + // 外呼线程池 + private final ExecutorService dialerExecutor = Executors.newFixedThreadPool(10); + + /** + * 创建外呼任务 + */ + public String createTask(DialTaskRequest request) { + String taskId = UUID.randomUUID().toString(); + + DialTask task = new DialTask(); + task.setId(taskId); + task.setName(request.getName()); + task.setSkillGroup(request.getSkillGroup()); + task.setCallerIdNumber(request.getCallerIdNumber()); + task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers())); + task.setMaxConcurrent(request.getMaxConcurrent()); + task.setState(TaskState.CREATED); + task.setStats(new DialStats()); + + activeTasks.put(taskId, task); + log.info("创建外呼任务: taskId={}, name={}, numbers={}", + taskId, request.getName(), request.getNumbers().size()); + + return taskId; + } + + /** + * 启动任务 + */ + public void startTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task == null) { + throw new RuntimeException("任务不存在"); + } + + task.setState(TaskState.RUNNING); + log.info("启动外呼任务: {}", taskId); + + // 启动外呼循环 + dialerExecutor.submit(() -> dialLoop(task)); + } + + /** + * 暂停任务 + */ + public void pauseTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null) { + task.setState(TaskState.PAUSED); + log.info("暂停外呼任务: {}", taskId); + } + } + + /** + * 恢复任务 + */ + public void resumeTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null && task.getState() == TaskState.PAUSED) { + task.setState(TaskState.RUNNING); + dialerExecutor.submit(() -> dialLoop(task)); + log.info("恢复外呼任务: {}", taskId); + } + } + + /** + * 停止任务 + */ + public void stopTask(String taskId) { + DialTask task = activeTasks.get(taskId); + if (task != null) { + task.setState(TaskState.STOPPED); + log.info("停止外呼任务: {}", taskId); + } + } + + /** + * 外呼循环 + */ + private void dialLoop(DialTask task) { + log.info("开始外呼循环: taskId={}", task.getId()); + + while (task.getState() == TaskState.RUNNING && !task.getNumbers().isEmpty()) { + try { + // 计算应该发起的呼叫数 + int dialCount = calculateDialCount(task); + + if (dialCount <= 0) { + // 没有空闲坐席,等待 + Thread.sleep(1000); + continue; + } + + // 发起外呼 + for (int i = 0; i < dialCount; i++) { + String number = task.getNumbers().poll(); + if (number == null) { + break; + } + + dial(task, number); + } + + // 短暂等待,控制拨打速率 + Thread.sleep(500); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error("外呼异常: {}", e.getMessage(), e); + } + } + + // 任务完成 + if (task.getNumbers().isEmpty()) { + task.setState(TaskState.COMPLETED); + } + + log.info("外呼循环结束: taskId={}, state={}", task.getId(), task.getState()); + } + + /** + * 计算应该发起的呼叫数(预测算法) + */ + private int calculateDialCount(DialTask task) { + // 获取可用坐席数 + List availableAgents = agentService.getAvailableAgents(task.getSkillGroup()); + int availableAgentCount = availableAgents.size(); + + if (availableAgentCount == 0) { + return 0; + } + + // 当前正在拨打的数量 + int currentDialing = task.getStats().getCurrentDialing().get(); + + // 历史接通率(动态计算) + double connectRate = calculateConnectRate(task); + + // 预测式算法: + // 目标:让坐席接到电话时,刚好有客户接通 + // 预测需要拨打数 = 空闲坐席数 / 接通率 * 系数 + double factor = 1.2; // 超拨系数,避免坐席空闲 + int predictedDial = (int) Math.ceil(availableAgentCount / connectRate * factor); + + // 限制最大并发 + int maxAllowed = task.getMaxConcurrent() - currentDialing; + + return Math.min(predictedDial, maxAllowed); + } + + /** + * 计算接通率 + */ + private double calculateConnectRate(DialTask task) { + DialStats stats = task.getStats(); + int total = stats.getTotalDialed().get(); + int connected = stats.getConnected().get(); + + if (total < 10) { + // 样本太少,使用默认值 + return 0.3; + } + + double rate = (double) connected / total; + // 限制范围 + return Math.max(0.1, Math.min(0.8, rate)); + } + + /** + * 发起单个外呼 + */ + private void dial(DialTask task, String number) { + log.info("外呼: taskId={}, number={}", task.getId(), number); + + task.getStats().getTotalDialed().incrementAndGet(); + task.getStats().getCurrentDialing().incrementAndGet(); + + try { + // 构建originate命令 + // 先拨打客户,客户接听后转到队列等待坐席 + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("origination_caller_id_number=").append(task.getCallerIdNumber()).append(","); + cmd.append("task_id=").append(task.getId()).append(","); + cmd.append("dial_number=").append(number).append(","); + cmd.append("dialer_mode=predictive"); + cmd.append("}"); + + // 通过网关外呼 + cmd.append("sofia/gateway/carrier_primary/").append(number); + + // 客户接听后的处理 + cmd.append(" &lua(predictive_handler.lua)"); + + String result = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (!result.startsWith("+OK")) { + log.warn("外呼失败: number={}, result={}", number, result); + task.getStats().getFailed().incrementAndGet(); + } + + } catch (Exception e) { + log.error("外呼异常: number={}", number, e); + task.getStats().getFailed().incrementAndGet(); + } finally { + task.getStats().getCurrentDialing().decrementAndGet(); + } + } + + /** + * 客户接听回调(由事件触发) + */ + public void onCustomerAnswer(String uuid, String taskId) { + DialTask task = activeTasks.get(taskId); + if (task == null) { + eslManager.sendSyncApiCommand("uuid_kill", uuid + " NO_ROUTE_DESTINATION"); + return; + } + + task.getStats().getConnected().incrementAndGet(); + + // 查找可用坐席 + List agents = agentService.getAvailableAgents(task.getSkillGroup()); + + if (agents.isEmpty()) { + // 无可用坐席,播放等待音乐 + eslManager.sendSyncApiCommand("uuid_broadcast", + uuid + " ivr/please_wait.wav both"); + + // 放入等待队列 + // TODO: 实现等待逻辑 + + // 超时处理 + task.getStats().getAbandoned().incrementAndGet(); + } else { + // 转接坐席 + Agent agent = agents.get(0); + agentService.updateAgentState(agent.getId(), AgentState.RINGING); + + eslManager.sendSyncApiCommand("uuid_transfer", + uuid + " " + agent.getExtension() + " XML default"); + + task.getStats().getTransferred().incrementAndGet(); + } + } + + /** + * 获取任务统计 + */ + public DialStats getTaskStats(String taskId) { + DialTask task = activeTasks.get(taskId); + return task != null ? task.getStats() : null; + } +} + +/** + * 外呼任务 + */ +@Data +class DialTask { + private String id; + private String name; + private String skillGroup; + private String callerIdNumber; + private BlockingQueue numbers; + private int maxConcurrent; + private TaskState state; + private DialStats stats; +} + +/** + * 任务状态 + */ +enum TaskState { + CREATED, RUNNING, PAUSED, STOPPED, COMPLETED +} + +/** + * 外呼统计 + */ +@Data +class DialStats { + private AtomicInteger totalDialed = new AtomicInteger(0); + private AtomicInteger currentDialing = new AtomicInteger(0); + private AtomicInteger connected = new AtomicInteger(0); + private AtomicInteger transferred = new AtomicInteger(0); + private AtomicInteger failed = new AtomicInteger(0); + private AtomicInteger abandoned = new AtomicInteger(0); +} +``` + +### 4.2 机器人外呼实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import com.callcenter.model.*; +import com.callcenter.tts.TtsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.*; + +/** + * 机器人外呼服务 + * 用于批量通知、回访、满意度调查 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RobotDialerService { + + private final EslConnectionManager eslManager; + private final TtsService ttsService; + + private final Map activeTasks = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newFixedThreadPool(20); + + /** + * 创建机器人外呼任务 + */ + public String createTask(RobotTaskRequest request) { + String taskId = UUID.randomUUID().toString(); + + RobotTask task = new RobotTask(); + task.setId(taskId); + task.setType(request.getType()); // NOTIFICATION, SURVEY, CALLBACK + task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers())); + task.setTtsText(request.getTtsText()); + task.setVariables(request.getVariables()); // 每个号码的变量 + task.setMaxConcurrent(request.getMaxConcurrent()); + task.setRetryTimes(request.getRetryTimes()); + task.setCollectDtmf(request.isCollectDtmf()); + task.setState(TaskState.CREATED); + task.setResults(new ConcurrentHashMap<>()); + + activeTasks.put(taskId, task); + return taskId; + } + + /** + * 启动任务 + */ + public void startTask(String taskId) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + throw new RuntimeException("任务不存在"); + } + + task.setState(TaskState.RUNNING); + executor.submit(() -> robotDialLoop(task)); + } + + /** + * 机器人外呼循环 + */ + private void robotDialLoop(RobotTask task) { + log.info("开始机器人外呼: taskId={}", task.getId()); + + Semaphore semaphore = new Semaphore(task.getMaxConcurrent()); + + while (task.getState() == TaskState.RUNNING) { + String number = task.getNumbers().poll(); + if (number == null) { + break; + } + + try { + semaphore.acquire(); + executor.submit(() -> { + try { + robotCall(task, number); + } finally { + semaphore.release(); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // 等待所有呼叫完成 + try { + semaphore.acquire(task.getMaxConcurrent()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + task.setState(TaskState.COMPLETED); + log.info("机器人外呼完成: taskId={}", task.getId()); + } + + /** + * 单个机器人呼叫 + */ + private void robotCall(RobotTask task, String number) { + log.info("机器人呼叫: taskId={}, number={}", task.getId(), number); + + RobotCallResult result = new RobotCallResult(); + result.setNumber(number); + result.setStartTime(System.currentTimeMillis()); + + try { + // 替换TTS文本中的变量 + String ttsText = replaceTtsVariables(task.getTtsText(), + task.getVariables().get(number)); + + // 生成语音文件 + String audioFile = ttsService.synthesize(ttsText); + + // 构建外呼命令 + StringBuilder cmd = new StringBuilder(); + cmd.append("{"); + cmd.append("task_id=").append(task.getId()).append(","); + cmd.append("dial_number=").append(number).append(","); + cmd.append("robot_mode=true").append(","); + cmd.append("audio_file=").append(audioFile); + cmd.append("}"); + + cmd.append("sofia/gateway/carrier_primary/").append(number); + + if (task.isCollectDtmf()) { + // 需要收集按键 + cmd.append(" &lua(robot_survey.lua)"); + } else { + // 纯播报 + cmd.append(" &playback(").append(audioFile).append(")"); + } + + String apiResult = eslManager.sendSyncApiCommand("originate", cmd.toString()); + + if (apiResult.startsWith("+OK")) { + result.setStatus("SUCCESS"); + result.setCallUuid(apiResult.substring(4).trim()); + } else { + result.setStatus("FAILED"); + result.setError(apiResult); + + // 重试逻辑 + handleRetry(task, number); + } + + } catch (Exception e) { + log.error("机器人呼叫异常: number={}", number, e); + result.setStatus("ERROR"); + result.setError(e.getMessage()); + handleRetry(task, number); + } + + result.setEndTime(System.currentTimeMillis()); + task.getResults().put(number, result); + } + + /** + * 替换TTS变量 + */ + private String replaceTtsVariables(String template, Map vars) { + if (vars == null || vars.isEmpty()) { + return template; + } + + String result = template; + for (Map.Entry entry : vars.entrySet()) { + result = result.replace("${" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + /** + * 处理重试 + */ + private void handleRetry(RobotTask task, String number) { + int retryCount = task.getRetryCount().getOrDefault(number, 0); + if (retryCount < task.getRetryTimes()) { + task.getRetryCount().put(number, retryCount + 1); + task.getNumbers().offer(number); + log.info("加入重试队列: number={}, retry={}", number, retryCount + 1); + } + } + + /** + * 处理DTMF按键结果(由事件触发) + */ + public void onDtmfResult(String uuid, String taskId, String number, String digit) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + return; + } + + RobotCallResult result = task.getResults().get(number); + if (result != null) { + result.setDtmfDigit(digit); + log.info("收到按键: taskId={}, number={}, digit={}", taskId, number, digit); + } + } + + /** + * 获取任务结果 + */ + public List getTaskResults(String taskId) { + RobotTask task = activeTasks.get(taskId); + if (task == null) { + return Collections.emptyList(); + } + return new ArrayList<>(task.getResults().values()); + } +} +``` + +### 4.3 班长监控实现 + +```java +package com.callcenter.service; + +import com.callcenter.esl.EslConnectionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 班长监控服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SupervisorService { + + private final EslConnectionManager eslManager; + + /** + * 监听坐席通话 + * 班长可以听到双方说话,但双方听不到班长 + */ + public void spy(String supervisorExtension, String targetUuid) { + log.info("开始监听: supervisor={}, target={}", supervisorExtension, targetUuid); + + // 使用eavesdrop应用 + // originate user/supervisor &eavesdrop(target_uuid) + String cmd = String.format( + "{origination_caller_id_name=监听}user/%s &eavesdrop(%s)", + supervisorExtension, targetUuid + ); + + String result = eslManager.sendSyncApiCommand("originate", cmd); + + if (!result.startsWith("+OK")) { + throw new RuntimeException("监听失败: " + result); + } + } + + /** + * 强插通话 + * 班长加入通话,三方都可以听到 + */ + public void barge(String supervisorExtension, String targetUuid) { + log.info("强插通话: supervisor={}, target={}", supervisorExtension, targetUuid); + + // 使用三方会议实现 + String confName = "barge_" + targetUuid; + + // 1. 将目标通话转入会议 + eslManager.sendSyncApiCommand("uuid_transfer", + targetUuid + " conference:" + confName + "+flags{mute} inline"); + + // 2. 呼叫班长加入会议 + String cmd = String.format( + "{origination_caller_id_name=强插}user/%s &conference(%s)", + supervisorExtension, confName + ); + + eslManager.sendSyncApiCommand("originate", cmd); + } + + /** + * 强拆通话 + * 强制挂断坐席通话 + */ + public void hangup(String targetUuid, String reason) { + log.info("强拆通话: target={}, reason={}", targetUuid, reason); + + eslManager.sendSyncApiCommand("uuid_kill", targetUuid + " " + reason); + } + + /** + * 密语(耳语) + * 班长对坐席说话,客户听不到 + */ + public void whisper(String supervisorExtension, String agentUuid) { + log.info("密语: supervisor={}, agent={}", supervisorExtension, agentUuid); + + // 使用eavesdrop的whisper模式 + String cmd = String.format( + "{origination_caller_id_name=密语}user/%s &eavesdrop(%s whisper)", + supervisorExtension, agentUuid + ); + + eslManager.sendSyncApiCommand("originate", cmd); + } + + /** + * 获取实时通话列表 + */ + public String getActiveCalls() { + return eslManager.sendSyncApiCommand("show", "calls"); + } + + /** + * 获取坐席通道信息 + */ + public String getChannelInfo(String uuid) { + return eslManager.sendSyncApiCommand("uuid_dump", uuid); + } +} +``` + +--- + +## 第五部分 高可用部署方案 + +### 5.1 主备架构 + +``` + ┌─────────────────┐ + │ Keepalived │ + │ VIP漂移管理 │ + └────────┬────────┘ + │ + ┌───────────────────────────┴───────────────────────────┐ + │ VIP: 10.10.1.100 │ + │ (SIP/ESL统一入口) │ + └───────────────────────────┬───────────────────────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌────────────────────┐ + │ Master节点 │ 心跳检测 │ Backup节点 │ + │ 10.10.1.10 │◄──────────────────►│ 10.10.1.11 │ + ├────────────────────┤ ├────────────────────┤ + │ FreeSWITCH │ │ FreeSWITCH │ + │ CTI中间件 │ │ CTI中间件 │ + │ API服务 │ │ API服务 │ + └────────────────────┘ └────────────────────┘ + │ │ + │ 共享存储 / 数据同步 │ + │ │ + └─────────────────────┬─────────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MySQL 主从 │ │ Redis Sentinel │ │ NAS存储 │ +│ 10.10.2.10/11 │ │ 10.10.2.20-22 │ │ 10.10.2.30 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 5.2 Keepalived配置 + +**Master节点配置:** + +```bash +# /etc/keepalived/keepalived.conf (Master) + +global_defs { + router_id FS_MASTER +} + +# 检测FreeSWITCH状态的脚本 +vrrp_script check_freeswitch { + script "/etc/keepalived/check_freeswitch.sh" + interval 2 + weight -20 + fall 3 + rise 2 +} + +vrrp_instance VI_FS { + state MASTER + interface eth0 + virtual_router_id 51 + priority 100 + advert_int 1 + + authentication { + auth_type PASS + auth_pass your_password + } + + virtual_ipaddress { + 10.10.1.100/24 dev eth0 + } + + track_script { + check_freeswitch + } + + notify_master "/etc/keepalived/notify_master.sh" + notify_backup "/etc/keepalived/notify_backup.sh" + notify_fault "/etc/keepalived/notify_fault.sh" +} +``` + +**检测脚本:** + +```bash +#!/bin/bash +# /etc/keepalived/check_freeswitch.sh + +# 检测FreeSWITCH进程 +if ! pgrep -x "freeswitch" > /dev/null; then + exit 1 +fi + +# 检测ESL端口 +if ! nc -z localhost 8021; then + exit 1 +fi + +# 检测SIP端口 +if ! nc -z localhost 5060; then + exit 1 +fi + +# 执行简单API命令测试 +result=$(fs_cli -x "status" 2>/dev/null) +if [[ $? -ne 0 ]]; then + exit 1 +fi + +exit 0 +``` + +**主节点切换通知脚本:** + +```bash +#!/bin/bash +# /etc/keepalived/notify_master.sh + +# 记录日志 +echo "$(date): Becoming MASTER" >> /var/log/keepalived-state.log + +# 启动FreeSWITCH(如果未运行) +systemctl start freeswitch + +# 启动CTI服务 +systemctl start callcenter-cti + +# 发送告警通知 +curl -X POST "http://your-alert-api/notify" \ + -d "message=FreeSWITCH切换到Master节点" +``` + +### 5.3 配置同步方案 + +```bash +#!/bin/bash +# sync_config.sh - 配置同步脚本 + +MASTER_HOST="10.10.1.10" +BACKUP_HOST="10.10.1.11" +FS_CONF_DIR="/usr/local/freeswitch/conf" + +# 使用rsync同步配置 +sync_config() { + rsync -avz --delete \ + ${FS_CONF_DIR}/ \ + ${BACKUP_HOST}:${FS_CONF_DIR}/ + + # 通知备机重载配置 + ssh ${BACKUP_HOST} "fs_cli -x 'reloadxml'" +} + +# 监控配置变化 +inotifywait -m -r -e modify,create,delete ${FS_CONF_DIR} | while read path action file; do + echo "配置变化: $path$file ($action)" + sync_config +done +``` + +### 5.4 FreeSWITCH集群配置 + +对于需要更高并发的场景,可以部署FreeSWITCH集群: + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 第六部分 性能优化指南 + +### 6.1 FreeSWITCH优化 + +**系统级优化:** + +```bash +# /etc/security/limits.conf +freeswitch soft nofile 65536 +freeswitch hard nofile 65536 +freeswitch soft core unlimited +freeswitch hard core unlimited + +# /etc/sysctl.conf +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +net.core.rmem_default = 1048576 +net.core.wmem_default = 1048576 +net.core.netdev_max_backlog = 30000 +net.ipv4.tcp_max_syn_backlog = 10240 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 15 +``` + +**FreeSWITCH配置优化:** + +```xml + + + + + + + + + + + + + + + + +``` + +### 6.2 CTI中间件优化 + +```java +// 连接池配置 +@Configuration +public class ConnectionPoolConfig { + + @Bean + public ThreadPoolTaskExecutor eslEventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(32); + executor.setQueueCapacity(10000); + executor.setThreadNamePrefix("esl-event-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useClusterServers() + .addNodeAddress("redis://10.10.2.20:6379") + .addNodeAddress("redis://10.10.2.21:6379") + .addNodeAddress("redis://10.10.2.22:6379") + .setMasterConnectionPoolSize(64) + .setSlaveConnectionPoolSize(64); + return Redisson.create(config); + } +} +``` + +### 6.3 监控指标 + +```yaml +# prometheus配置 +scrape_configs: + - job_name: 'freeswitch' + static_configs: + - targets: ['10.10.1.10:9282', '10.10.1.11:9282'] + + - job_name: 'callcenter-cti' + static_configs: + - targets: ['10.10.1.10:8080', '10.10.1.11:8080'] + metrics_path: '/actuator/prometheus' +``` + +**关键监控指标:** + +| 指标 | 说明 | 阈值 | +|------|------|------| +| freeswitch_sessions_active | 当前活跃会话数 | <80%最大值 | +| freeswitch_sessions_per_second | 每秒新建会话数 | <100 | +| freeswitch_cpu_usage | CPU使用率 | <70% | +| freeswitch_memory_usage | 内存使用率 | <80% | +| esl_event_queue_size | 事件队列大小 | <5000 | +| acd_queue_wait_time | 平均等待时间 | <30s | +| agent_utilization | 坐席利用率 | 目标>70% | + +--- + +## 总结 + +本文档详细介绍了FreeSWITCH的技术架构和ESL开发实践,涵盖了: + +1. **核心概念**:Channel、Session、Event等基础概念 +2. **模块详解**:mod_sofia、Dialplan、ESL等核心模块 +3. **ESL开发**:Java和Go两种语言的开发示例 +4. **场景实践**:ACD、预测式外呼、机器人外呼、班长监控 +5. **高可用部署**:主备架构、Keepalived配置 +6. **性能优化**:系统优化、应用优化、监控指标 + +建议开发团队在正式开发前: +- 搭建FreeSWITCH测试环境熟悉操作 +- 仔细研读FreeSWITCH官方文档 +- 从简单场景(如点击拨号)开始,逐步实现复杂功能 +- 重视测试,特别是高可用切换测试和压力测试 + +如有任何问题,欢迎继续交流! + +### USER +给出一个粗略的开发方案 + +### ASSISTANT +# 呼叫中心系统开发方案 + +--- + +**文档版本**:V1.0 +**编制日期**:2024年1月 +**项目周期**:4个月(17周) + +--- + +## 一、开发方案概述 + +### 1.1 项目背景回顾 + +| 项目要素 | 内容 | +|---------|------| +| 业务范围 | 道路救援、家政服务、代驾服务 | +| 坐席规模 | 150坐席 | +| 日话务量 | 50,000通 | +| 技术路线 | 自研(基于FreeSWITCH) | +| 部署方式 | 私有化 + 主备高可用 | + +### 1.2 开发目标 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 系统能力目标 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ ✓ 150坐席并发 ✓ 120路并发通话 ✓ 99.9%可用性 │ +│ ✓ 7×24小时服务 ✓ 故障30秒内切换 ✓ API响应<200ms │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、系统架构设计 + +### 2.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 外部系统 │ +│ CRM系统 业务系统 阿里云语音 │ +└──────────┬───────────────────────┬────────────────────┬─────────────────┘ + │ │ │ + │ REST API / WebSocket │ + │ │ │ +┌──────────▼───────────────────────▼────────────────────▼─────────────────┐ +│ CTI中间件层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ API网关 │ │ 坐席服务 │ │ 呼叫服务 │ │ 外呼引擎 │ │ +│ │ (Gateway) │ │ (Agent) │ │ (Call) │ │ (Dialer) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ ACD服务 │ │ 队列服务 │ │ 监控服务 │ │ 机器人服务 │ │ +│ │ (ACD) │ │ (Queue) │ │ (Monitor) │ │ (Robot) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ ESL连接池 │ │ 事件分发器 │ │ +│ │ (ESL Pool) │ │ (Dispatcher)│ │ +│ └──────┬──────┘ └──────┬──────┘ │ +└─────────┼────────────────┼──────────────────────────────────────────────┘ + │ │ + │ ESL协议 │ + │ │ +┌─────────▼────────────────▼──────────────────────────────────────────────┐ +│ FreeSWITCH 媒体层 │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │mod_sofia│ │mod_dptools│ │mod_record│ │mod_lua │ │mod_event│ │ │ +│ │ │ (SIP) │ │(Dialplan)│ │ (录音) │ │ (IVR) │ │ (ESL) │ │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ FreeSWITCH Core │ +└─────────────────────────────────┬───────────────────────────────────────┘ + │ + SIP Trunk + │ +┌─────────────────────────────────▼───────────────────────────────────────┐ +│ 运营商网络 │ +│ 400号码 / SIP中继线路 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 技术选型 + +| 层级 | 组件 | 技术选型 | 说明 | +|------|------|---------|------| +| **媒体层** | 软交换 | FreeSWITCH 1.10.x | 开源、稳定、功能完善 | +| | SIP协议 | mod_sofia | FreeSWITCH内置 | +| | IVR脚本 | Lua | 灵活、性能好 | +| **中间件层** | 开发语言 | Java 17 + Spring Boot 3 | 团队熟悉、生态成熟 | +| | ESL客户端 | 自研(基于Netty) | 更好的连接管理 | +| | API框架 | Spring WebFlux | 响应式、高性能 | +| | WebSocket | Spring WebSocket | 事件实时推送 | +| **数据层** | 关系数据库 | MySQL 8.0 | 主从复制、读写分离 | +| | 缓存 | Redis 7.0 | Sentinel高可用 | +| | 消息队列 | RabbitMQ | 异步解耦 | +| | 文件存储 | MinIO / NAS | 录音存储 | +| **前端** | 软电话条 | Vue 3 + TypeScript | 组件化、类型安全 | +| | 监控大屏 | Vue 3 + ECharts | 数据可视化 | +| **运维** | 容器化 | Docker + Docker Compose | 简化部署 | +| | 监控 | Prometheus + Grafana | 指标监控 | +| | 日志 | ELK Stack | 日志收集分析 | +| **外部服务** | ASR/TTS | 阿里云智能语音 | 按需调用 | +| | NLU | 指定对话平台 | 意图识别 | + +### 2.3 模块划分 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 系统模块划分 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ callcenter-platform │ │ +│ │ (父工程) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────┼──────────────────────────┐ │ +│ │ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ common │ │ esl │ │ core │ │ api │ │ web │ │ +│ │ (公共) │ │(ESL封装)│ │(核心业务)│ │(REST API)│ │(前端) │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ │ │ │ │ │ +│ └──────────────┴───────────┴───────────┴──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ gateway │ │ +│ │ (启动入口) │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**模块职责:** + +| 模块 | 职责 | 主要内容 | +|------|------|---------| +| **common** | 公共组件 | 工具类、常量、异常、通用模型 | +| **esl** | ESL封装 | ESL连接管理、命令封装、事件解析 | +| **core** | 核心业务 | 坐席管理、呼叫控制、ACD、队列、外呼、录音 | +| **api** | REST接口 | 对外API、WebSocket推送 | +| **web** | 前端工程 | 软电话条、监控大屏 | +| **gateway** | 应用入口 | 配置、启动类 | + +--- + +## 三、功能模块设计 + +### 3.1 模块清单与优先级 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 功能模块优先级 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ P0 - 核心功能(必须完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ ESL连接管理 ☐ 坐席签入签出 ☐ 呼入呼出控制 │ │ +│ │ ☐ ACD话务分配 ☐ 技能组管理 ☐ 排队等待 │ │ +│ │ ☐ 全程录音 ☐ 软电话条 ☐ API接口 │ │ +│ │ ☐ 事件推送 ☐ CRM集成 ☐ 基础IVR │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ P1 - 重要功能(应该完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ 预测式外呼 ☐ 机器人外呼 ☐ 语音识别IVR │ │ +│ │ ☐ 呼入机器人 ☐ 班长监控 ☐ 实时监控大屏 │ │ +│ │ ☐ 溢出策略 ☐ 主备高可用 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ P2 - 增强功能(尽量完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ VIP优先 ☐ 黑名单 ☐ 满意度调查 │ │ +│ │ ☐ 多轮对话 ☐ 话务预测 ☐ 密语功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 核心模块设计 + +#### 3.2.1 ESL模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ESL模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslConnectionManager │ │ +│ │ (连接管理器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - connect() 建立ESL连接 │ │ +│ │ - disconnect() 断开连接 │ │ +│ │ - reconnect() 重连机制 │ │ +│ │ - heartbeat() 心跳检测 │ │ +│ │ - getConnection() 获取连接 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslCommandExecutor │ │ +│ │ (命令执行器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - api(cmd, args) 同步API命令 │ │ +│ │ - bgapi(cmd, args) 异步API命令 │ │ +│ │ - sendMsg(uuid, app) 发送消息 │ │ +│ │ - originate(...) 发起呼叫 │ │ +│ │ - bridge(uuid1, uuid2) 桥接通道 │ │ +│ │ - hangup(uuid) 挂断通话 │ │ +│ │ - transfer(uuid, dest) 转接 │ │ +│ │ - hold/unhold(uuid) 保持/取回 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslEventDispatcher │ │ +│ │ (事件分发器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - subscribe(events) 订阅事件 │ │ +│ │ - addHandler(handler) 注册处理器 │ │ +│ │ - dispatch(event) 分发事件 │ │ +│ │ - filter(uuid) 过滤事件 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 关键事件处理: │ +│ ┌────────────────┬────────────────────────────────────────────────┐ │ +│ │ CHANNEL_CREATE │ → 创建呼叫记录,呼入触发ACD排队 │ │ +│ │ CHANNEL_ANSWER │ → 更新状态为已接听,开始计时 │ │ +│ │ CHANNEL_BRIDGE │ → 更新状态为桥接,记录桥接信息 │ │ +│ │ CHANNEL_HANGUP │ → 更新坐席状态,生成话单 │ │ +│ │ DTMF │ → IVR按键处理,满意度调查收集 │ │ +│ └────────────────┴────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.2 坐席管理模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 坐席管理模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 坐席状态机: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────┐ │ │ +│ │ │ OFFLINE │ ◄────────────────────────┐ │ │ +│ │ └────┬────┘ │ │ │ +│ │ │ signIn │ signOut │ │ +│ │ ▼ │ │ │ +│ │ ┌─────────┐ ready ┌────┴────┐ │ │ +│ │ ┌───►│NOT_READY│─────────────────────►│ READY │◄───┐ │ │ +│ │ │ └─────────┘ └────┬────┘ │ │ │ +│ │ │ ▲ │ │ │ │ +│ │ wrapUp │ │ notReady │ assign │ │ │ +│ │ timeout│ │ ▼ │ │ │ +│ │ │ ┌─────────┐ ┌─────────┐ │ │ │ +│ │ │ │ PAUSED │ │ RINGING │ │ │ │ +│ │ │ │ (小休) │ └────┬────┘ │ │ │ +│ │ │ └─────────┘ │ │ │ │ +│ │ │ │ answer │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌─────────┐ │ │ │ +│ │ │ │ BUSY │ │ │ │ +│ │ │ │ (通话中) │ │ │ │ +│ │ │ └────┬────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ hangup │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌─────────┐ │ │ │ +│ │ └────────────────────────────────────│ WRAP_UP │────┘ │ │ +│ │ │ (后处理) │ │ │ +│ │ └─────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据模型: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Agent { │ │ +│ │ id: String // 坐席ID │ │ +│ │ name: String // 坐席姓名 │ │ +│ │ extension: String // 分机号 │ │ +│ │ skillGroups: List // 技能组列表 │ │ +│ │ state: AgentState // 当前状态 │ │ +│ │ stateTime: DateTime // 状态开始时间 │ │ +│ │ currentCallUuid: String // 当前通话UUID │ │ +│ │ signInTime: DateTime // 签入时间 │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 存储设计(Redis): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ agent:{agentId} → Hash 坐席详细信息 │ │ +│ │ agents:online → Set 在线坐席ID集合 │ │ +│ │ skillgroup:{name}:agents → Set 技能组坐席集合 │ │ +│ │ agent:{agentId}:idle_time → String 空闲开始时间 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.3 ACD模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ACD模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 分配流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 来电 ──► IVR ──► 确定技能组 ──► 入队 ──► 分配 ──► 振铃 ──► 接听 │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌──────────┐ │ │ +│ │ │ 排队等待 │ │ │ +│ │ │ 播放音乐 │ │ │ +│ │ └──────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 分配策略(按优先级): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 1. 历史服务优先 │ │ +│ │ └─► 查找该客户上次服务的坐席,若空闲则分配 │ │ +│ │ │ │ +│ │ 2. 最长空闲优先 │ │ +│ │ └─► 选择空闲时间最长的坐席 │ │ +│ │ │ │ +│ │ 3. 轮询分配 │ │ +│ │ └─► 按顺序轮流分配 │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 溢出策略: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 条件:技能组无空闲坐席 + 等待时间 > 阈值(如60秒) │ │ +│ │ │ │ +│ │ 溢出配置: │ │ +│ │ ┌─────────────┬─────────────┬───────────┐ │ │ +│ │ │ 源技能组 │ 目标技能组 │ 等待阈值 │ │ │ +│ │ ├─────────────┼─────────────┼───────────┤ │ │ +│ │ │ rescue │ default │ 60秒 │ │ │ +│ │ │ housekeeping│ default │ 90秒 │ │ │ +│ │ │ driver │ default │ 60秒 │ │ │ +│ │ └─────────────┴─────────────┴───────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 队列数据结构(Redis Sorted Set): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ queue:{skillGroup} │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐│ │ +│ │ │ Score = 优先级 × 10^9 + 入队时间戳 ││ │ +│ │ │ Member = QueueItem(callUuid, callerNumber, enqueueTime...) ││ │ +│ │ └──────────────────────────────────────────────────────────────┘│ │ +│ │ │ │ +│ │ 优先级越小越优先,同优先级按入队时间排序 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.4 呼叫控制模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 呼叫控制模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 呼叫状态机: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ CREATED │────►│ RINGING │────►│ ANSWERED│────►│ BRIDGED │ │ │ +│ │ └─────────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ +│ │ │ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ ▼ │ │ +│ │ │ │ ┌─────────┐ │ │ +│ │ │ │ │ HELD │ │ │ +│ │ │ │ └────┬────┘ │ │ +│ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ │ │ +│ │ ┌────────────────────────────────────────┐ │ │ +│ │ │ HANGUP │ │ │ +│ │ └────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 核心操作: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 点击外呼: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 1. 检查坐席状态(必须READY) │ │ │ +│ │ │ 2. 更新坐席状态为BUSY │ │ │ +│ │ │ 3. originate呼叫坐席分机 │ │ │ +│ │ │ 4. 坐席接听后bridge呼叫客户 │ │ │ +│ │ │ 5. 开始录音 │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 转接: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 盲转:uuid_transfer直接转接 │ │ │ +│ │ │ 咨询转:先hold,originate咨询方,确认后bridge │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 三方通话: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 1. 创建会议室 conference │ │ │ +│ │ │ 2. 将原通话双方加入会议 │ │ │ +│ │ │ 3. originate第三方加入会议 │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据模型: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Call { │ │ +│ │ uuid: String // 呼叫唯一标识 │ │ +│ │ direction: Enum // INBOUND/OUTBOUND │ │ +│ │ callerNumber: String // 主叫号码 │ │ +│ │ calleeNumber: String // 被叫号码 │ │ +│ │ state: CallState // 呼叫状态 │ │ +│ │ agentId: String // 服务坐席 │ │ +│ │ skillGroup: String // 技能组 │ │ +│ │ createTime: DateTime // 创建时间 │ │ +│ │ answerTime: DateTime // 接听时间 │ │ +│ │ hangupTime: DateTime // 挂断时间 │ │ +│ │ duration: Long // 通话时长(秒) │ │ +│ │ recordingPath: String // 录音路径 │ │ +│ │ hangupCause: String // 挂断原因 │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.5 外呼模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 外呼模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 预测式外呼流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 业务系统 ──► 创建任务 ──► 导入号码 ──► 启动任务 │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ 外呼循环 │ │ │ +│ │ └───────┬───────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────────────────────────────┴───────────────┐ │ │ +│ │ │ │ │ │ +│ │ ▼ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ 计算拨打数 │───►│ 批量拨打 │───►│ 接通转坐席 │ │ │ │ +│ │ │(预测算法) │ │ │ │ │ │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ +│ │ ▲ │ │ │ │ +│ │ │ │ │ │ │ +│ │ └──────────────────────────────────────┘ │ │ │ +│ │ 循环 │ │ │ +│ │ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 预测算法: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 目标:让坐席接听时刚好有客户接通,减少等待 │ │ +│ │ │ │ +│ │ 预测拨打数 = 空闲坐席数 / 历史接通率 × 超拨系数 │ │ +│ │ │ │ +│ │ 示例:空闲5人,接通率30%,超拨系数1.2 │ │ +│ │ 拨打数 = 5 / 0.3 × 1.2 ≈ 20 │ │ +│ │ │ │ +│ │ 动态调整:根据实时接通率动态调整拨打速度 │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 机器人外呼流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 创建任务 │───►│ 生成TTS │───►│ 批量拨打 │───►│ 播放语音 │ │ │ +│ │ │ (含话术) │ │ 音频文件 │ │ │ │ │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └────┬─────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────┴───────┐ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌──────────┐ ┌──────────┐│ +│ │ │ 纯播报 │ │ 收集按键 ││ +│ │ │ 挂断 │ │ 回传结果 ││ +│ │ └──────────┘ └──────────┘│ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 接口设计 + +#### 3.3.1 API接口清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ REST API 设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 坐席接口:/api/v1/agent │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /signin 坐席签入 │ │ +│ │ POST /signout 坐席签出 │ │ +│ │ POST /ready 置闲 │ │ +│ │ POST /not-ready 置忙 │ │ +│ │ POST /pause 小休 │ │ +│ │ GET /{agentId}/status 查询坐席状态 │ │ +│ │ GET /list 查询坐席列表 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 呼叫接口:/api/v1/call │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /originate 发起外呼 │ │ +│ │ POST /{uuid}/answer 接听 │ │ +│ │ POST /{uuid}/hangup 挂断 │ │ +│ │ POST /{uuid}/hold 保持 │ │ +│ │ POST /{uuid}/unhold 取回 │ │ +│ │ POST /{uuid}/mute 静音 │ │ +│ │ POST /{uuid}/unmute 取消静音 │ │ +│ │ POST /{uuid}/transfer 转接 │ │ +│ │ POST /{uuid}/conference 三方通话 │ │ +│ │ GET /{uuid} 查询呼叫信息 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 外呼任务接口:/api/v1/task │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /predictive 创建预测式外呼任务 │ │ +│ │ POST /robot 创建机器人外呼任务 │ │ +│ │ POST /{taskId}/start 启动任务 │ │ +│ │ POST /{taskId}/pause 暂停任务 │ │ +│ │ POST /{taskId}/resume 恢复任务 │ │ +│ │ POST /{taskId}/stop 停止任务 │ │ +│ │ GET /{taskId}/status 查询任务状态 │ │ +│ │ GET /{taskId}/result 查询任务结果 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 监控接口:/api/v1/monitor │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /spy 监听 │ │ +│ │ POST /barge 强插 │ │ +│ │ POST /hangup 强拆 │ │ +│ │ GET /realtime 实时统计数据 │ │ +│ │ GET /queue/{skillGroup} 队列状态 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 录音接口:/api/v1/recording │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ GET /list 录音列表查询 │ │ +│ │ GET /{uuid}/url 获取录音下载地址 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.3.2 WebSocket事件推送 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WebSocket 事件设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 连接地址:ws://host:port/ws/events?token={token} │ +│ │ +│ 事件格式: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "eventType": "CALL_RINGING", │ │ +│ │ "timestamp": 1704067200000, │ │ +│ │ "data": { │ │ +│ │ "uuid": "xxx", │ │ +│ │ "callerNumber": "13800138000", │ │ +│ │ "calleeNumber": "4001234567", │ │ +│ │ ... │ │ +│ │ } │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 事件类型: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 呼叫事件 │ │ +│ │ ├── CALL_RINGING 来电振铃 │ │ +│ │ ├── CALL_ANSWERED 通话接听 │ │ +│ │ ├── CALL_BRIDGED 双方接通 │ │ +│ │ ├── CALL_HANGUP 通话挂断 │ │ +│ │ ├── CALL_TRANSFER 通话转接 │ │ +│ │ └── CALL_HOLD 通话保持 │ │ +│ │ │ │ +│ │ 坐席事件 │ │ +│ │ ├── AGENT_STATE_CHANGE 坐席状态变更 │ │ +│ │ ├── AGENT_SIGNIN 坐席签入 │ │ +│ │ └── AGENT_SIGNOUT 坐席签出 │ │ +│ │ │ │ +│ │ 话单事件 │ │ +│ │ └── CDR_GENERATED 话单生成 │ │ +│ │ │ │ +│ │ DTMF事件 │ │ +│ │ └── DTMF_RECEIVED 收到按键 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.4 数据库设计 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据库设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ cc_agent (坐席表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ agent_id VARCHAR(32) 坐席工号 │ │ +│ │ agent_name VARCHAR(64) 坐席姓名 │ │ +│ │ extension VARCHAR(16) 分机号 │ │ +│ │ password VARCHAR(128) 密码 │ │ +│ │ skill_groups VARCHAR(256) 技能组(逗号分隔) │ │ +│ │ status TINYINT 状态(0禁用1启用) │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ │ update_time DATETIME 更新时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_skill_group (技能组表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ group_code VARCHAR(32) 技能组编码 │ │ +│ │ group_name VARCHAR(64) 技能组名称 │ │ +│ │ overflow_group VARCHAR(32) 溢出技能组 │ │ +│ │ overflow_seconds INT 溢出等待秒数 │ │ +│ │ status TINYINT 状态 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_cdr (话单表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ uuid VARCHAR(64) 呼叫UUID │ │ +│ │ direction VARCHAR(16) 呼叫方向 │ │ +│ │ caller_number VARCHAR(32) 主叫号码 │ │ +│ │ callee_number VARCHAR(32) 被叫号码 │ │ +│ │ agent_id VARCHAR(32) 坐席ID │ │ +│ │ skill_group VARCHAR(32) 技能组 │ │ +│ │ queue_time INT 排队时长(秒) │ │ +│ │ ring_time INT 振铃时长(秒) │ │ +│ │ talk_time INT 通话时长(秒) │ │ +│ │ start_time DATETIME 开始时间 │ │ +│ │ answer_time DATETIME 接听时间 │ │ +│ │ end_time DATETIME 结束时间 │ │ +│ │ hangup_cause VARCHAR(32) 挂断原因 │ │ +│ │ recording_path VARCHAR(256) 录音路径 │ │ +│ │ business_type VARCHAR(32) 业务类型 │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ │ INDEX idx_start_time (start_time) │ │ +│ │ INDEX idx_agent (agent_id, start_time) │ │ +│ │ INDEX idx_caller (caller_number, start_time) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_dial_task (外呼任务表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ task_id VARCHAR(64) 任务ID │ │ +│ │ task_name VARCHAR(128) 任务名称 │ │ +│ │ task_type VARCHAR(16) 类型(PREDICTIVE/ROBOT) │ │ +│ │ skill_group VARCHAR(32) 技能组 │ │ +│ │ caller_id VARCHAR(32) 外显号码 │ │ +│ │ total_count INT 总号码数 │ │ +│ │ dialed_count INT 已拨打数 │ │ +│ │ connected_count INT 接通数 │ │ +│ │ state VARCHAR(16) 状态 │ │ +│ │ start_time DATETIME 开始时间 │ │ +│ │ end_time DATETIME 结束时间 │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_dial_detail (外呼明细表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ task_id VARCHAR(64) 任务ID │ │ +│ │ phone VARCHAR(32) 号码 │ │ +│ │ call_uuid VARCHAR(64) 呼叫UUID │ │ +│ │ status VARCHAR(16) 状态 │ │ +│ │ dial_time DATETIME 拨打时间 │ │ +│ │ answer_time DATETIME 接听时间 │ │ +│ │ hangup_time DATETIME 挂断时间 │ │ +│ │ dtmf_result VARCHAR(16) 按键结果 │ │ +│ │ retry_count INT 重试次数 │ │ +│ │ INDEX idx_task (task_id) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、开发计划 + +### 4.1 阶段划分 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 开发阶段划分(17周) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第一阶段:基础环境与核心框架(Week 1-4) │ │ +│ │ │ │ +│ │ Week 1-2: 环境搭建 + 技术预研 │ │ +│ │ Week 3-4: ESL模块 + 基础框架 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第二阶段:核心功能开发(Week 5-10) │ │ +│ │ │ │ +│ │ Week 5-6: 坐席管理 + 呼叫控制 │ │ +│ │ Week 7-8: ACD分配 + 排队等待 │ │ +│ │ Week 9-10: 录音 + 软电话条 + CRM集成 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第三阶段:高级功能开发(Week 11-14) │ │ +│ │ │ │ +│ │ Week 11-12: 预测式外呼 + 机器人外呼 │ │ +│ │ Week 13: IVR + 呼入机器人 │ │ +│ │ Week 14: 监控大屏 + 班长监控 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第四阶段:集成测试与上线(Week 15-17) │ │ +│ │ │ │ +│ │ Week 15: 集成联调 + 高可用部署 │ │ +│ │ Week 16: 测试 + Bug修复 │ │ +│ │ Week 17: 试运行 + 正式上线 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 详细开发计划 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 详细开发计划 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第一阶段:基础环境与核心框架(Week 1-4) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 1: 环境准备 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 服务器环境准备(开发、测试环境) │ │ +│ │ □ FreeSWITCH安装配置 │ │ +│ │ □ 开发环境搭建(IDE、Git、Maven) │ │ +│ │ □ MySQL、Redis环境搭建 │ │ +│ │ □ 测试SIP话机注册与基础通话 │ │ +│ │ │ │ +│ │ 交付物:环境部署文档、FreeSWITCH基础配置 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 2: 技术预研 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ ESL协议学习与Demo开发 │ │ +│ │ □ FreeSWITCH Dialplan配置学习 │ │ +│ │ □ Lua IVR脚本编写测试 │ │ +│ │ □ 项目工程结构搭建 │ │ +│ │ □ 技术方案评审 │ │ +│ │ │ │ +│ │ 交付物:技术预研报告、Demo代码 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 3-4: ESL模块开发 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ ESL连接管理(连接池、心跳、重连) │ │ +│ │ □ ESL命令封装(API、SendMsg) │ │ +│ │ □ 事件订阅与分发机制 │ │ +│ │ □ 常用命令测试验证 │ │ +│ │ □ 基础API框架搭建 │ │ +│ │ □ WebSocket服务搭建 │ │ +│ │ │ │ +│ │ 交付物:ESL模块代码、单元测试 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第二阶段:核心功能开发(Week 5-10) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 5-6: 坐席管理 + 呼叫控制 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 坐席数据模型设计 │ │ +│ │ □ 坐席签入/签出 │ │ +│ │ □ 坐席状态管理(置闲、置忙、小休) │ │ +│ │ □ 技能组管理 │ │ +│ │ □ 点击外呼功能 │ │ +│ │ □ 接听/挂断/保持/取回 │ │ +│ │ □ 转接(盲转、咨询转) │ │ +│ │ □ 坐席API接口 │ │ +│ │ □ 呼叫API接口 │ │ +│ │ │ │ +│ │ 交付物:坐席模块、呼叫控制模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 7-8: ACD分配 + 排队等待 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 队列数据结构设计 │ │ +│ │ □ 呼叫入队逻辑 │ │ +│ │ □ 历史服务优先策略 │ │ +│ │ □ 最长空闲优先策略 │ │ +│ │ □ 轮询分配策略 │ │ +│ │ □ 坐席选择与分配 │ │ +│ │ □ 排队等待音乐 │ │ +│ │ □ 溢出策略实现 │ │ +│ │ □ 排队状态查询 │ │ +│ │ │ │ +│ │ 交付物:ACD模块、队列管理模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 9-10: 录音 + 软电话条 + CRM集成 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 录音启动/停止控制 │ │ +│ │ □ 录音文件存储管理 │ │ +│ │ □ 录音查询与下载API │ │ +│ │ □ 软电话条前端开发(Vue组件) │ │ +│ │ □ WebSocket事件接收与处理 │ │ +│ │ □ 软电话条UI(状态、按钮、来电显示) │ │ +│ │ □ CRM弹屏集成(事件推送) │ │ +│ │ □ 话单推送至CRM │ │ +│ │ □ 联调测试 │ │ +│ │ │ │ +│ │ 交付物:录音模块、软电话条组件、CRM集成文档 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第三阶段:高级功能开发(Week 11-14) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 11-12: 预测式外呼 + 机器人外呼 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 外呼任务管理(创建、启动、暂停、停止) │ │ +│ │ □ 号码导入与管理 │ │ +│ │ □ 预测式拨号算法实现 │ │ +│ │ □ 接通后转坐席逻辑 │ │ +│ │ □ 外呼统计(接通率、坐席利用率) │ │ +│ │ □ 阿里云TTS集成 │ │ +│ │ □ 机器人外呼流程(播报、按键收集) │ │ +│ │ □ 外呼结果回传 │ │ +│ │ □ 任务API接口 │ │ +│ │ │ │ +│ │ 交付物:外呼引擎、机器人外呼模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 13: IVR + 呼入机器人 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ IVR Dialplan配置(多业务线) │ │ +│ │ □ 阿里云ASR集成 │ │ +│ │ □ 语音识别导航实现 │ │ +│ │ □ NLU对接(意图识别) │ │ +│ │ □ FAQ问答流程 │ │ +│ │ □ 转人工逻辑 │ │ +│ │ □ Lua脚本开发 │ │ +│ │ │ │ +│ │ 交付物:IVR配置、呼入机器人模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 14: 监控大屏 + 班长监控 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 实时数据统计API │ │ +│ │ □ 监控大屏前端开发 │ │ +│ │ □ 坐席状态分布展示 │ │ +│ │ □ 队列状态展示 │ │ +│ │ □ 话务趋势图表 │ │ +│ │ □ 监听功能实现 │ │ +│ │ □ 强插功能实现 │ │ +│ │ □ 强拆功能实现 │ │ +│ │ │ │ +│ │ 交付物:监控大屏、班长监控功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第四阶段:集成测试与上线(Week 15-17) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 15: 集成联调 + 高可用部署 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ CRM系统全流程联调 │ │ +│ │ □ 业务系统对接联调 │ │ +│ │ □ 运营商线路对接 │ │ +│ │ □ 主备FreeSWITCH部署 │ │ +│ │ □ Keepalived配置 │ │ +│ │ □ 故障切换测试 │ │ +│ │ □ 数据库主从配置 │ │ +│ │ □ Redis Sentinel配置 │ │ +│ │ │ │ +│ │ 交付物:联调测试报告、高可用部署文档 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 16: 测试 + Bug修复 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 功能测试(全流程) │ │ +│ │ □ 性能测试(150坐席并发) │ │ +│ │ □ 压力测试(峰值话务) │ │ +│ │ □ 故障恢复测试 │ │ +│ │ □ Bug修复 │ │ +│ │ □ 性能优化 │ │ +│ │ □ 上线检查清单确认 │ │ +│ │ │ │ +│ │ 交付物:测试报告、Bug清单、优化记录 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 17: 试运行 + 正式上线 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 生产环境部署 │ │ +│ │ □ 数据初始化 │ │ +│ │ □ 小范围试运行(一条业务线) │ │ +│ │ □ 问题收集与修复 │ │ +│ │ □ 全量上线 │ │ +│ │ □ 运维交接 │ │ +│ │ □ 用户培训 │ │ +│ │ │ │ +│ │ 交付物:上线报告、运维手册、培训材料 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.3 甘特图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ 项目甘特图 │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 任务 │ W1 │ W2 │ W3 │ W4 │ W5 │ W6 │ W7 │ W8 │ W9 │W10│W11│W12│W13│W14│W15│W16│W17│ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第一阶段 │████│████│████│████│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ 环境搭建 │████│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ 技术预研 │ │████│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ ESL模块开发 │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第二阶段 │ │ │ │ │████│████│████│████│████│███│ │ │ │ │ │ │ │ +│ 坐席管理 │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ +│ 呼叫控制 │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ +│ ACD分配 │ │ │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ +│ 排队等待 │ │ │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ +│ 录音功能 │ │ │ │ │ │ │ │ │████│ │ │ │ │ │ │ │ │ +│ 软电话条 │ │ │ │ │ │ │ │ │████│███│ │ │ │ │ │ │ │ +│ CRM集成 │ │ │ │ │ │ │ │ │ │███│ │ │ │ │ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第三阶段 │ │ │ │ │ │ │ │ │ │ │███│███│███│███│ │ │ │ +│ 预测式外呼 │ │ │ │ │ │ │ │ │ │ │███│███│ │ │ │ │ │ +│ 机器人外呼 │ │ │ │ │ │ │ │ │ │ │███│███│ │ │ │ │ │ +│ IVR+呼入机器人 │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ │ +│ 监控大屏 │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ +│ 班长监控 │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第四阶段 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│███│███│ +│ 集成联调 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ +│ 高可用部署 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ +│ 测试+Bug修复 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ +│ 试运行 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │██ │ +│ 正式上线 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ██│ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 里程碑 │ │ M1 │ │ M2 │ │ │ │ │ │M3 │ │ │ │M4 │M5 │M6 │M7 │ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M1: 需求确认 │ │ ◆ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M2: 环境就绪 │ │ │ │ ◆ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M3: 核心功能完成 │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ │ │ │ │ │ +│ M4: 全功能完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ │ +│ M5: 联调测试完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ +│ M6: 试运行完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ +│ M7: 正式上线 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、团队组织 + +### 5.1 团队配置 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 团队组织架构 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ │ +│ │ 项目经理 │ │ +│ │ (1人) │ │ +│ └──────┬──────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 开发组 │ │ 测试组 │ │ 运维组 │ │ +│ │ (8-10人) │ │ (1-2人) │ │ (1人) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ +│ ├── 架构师 (1人) │ +│ ├── 后端开发 (4-5人) │ +│ ├── 前端开发 (2人) │ +│ └── FreeSWITCH工程师 (1-2人) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 角色职责 + +| 角色 | 人数 | 职责 | 技能要求 | +|------|------|------|---------| +| **项目经理** | 1 | 项目管理、进度控制、风险管理 | 项目管理经验、沟通协调能力 | +| **架构师** | 1 | 架构设计、技术选型、核心代码 | FreeSWITCH、分布式系统 | +| **后端开发** | 4-5 | 业务模块开发、API开发 | Java、Spring Boot、Redis | +| **前端开发** | 2 | 软电话条、监控大屏 | Vue3、TypeScript、WebSocket | +| **FS工程师** | 1-2 | FreeSWITCH配置、Lua脚本 | FreeSWITCH、SIP协议、Lua | +| **测试** | 1-2 | 功能测试、性能测试 | 测试用例设计、性能测试工具 | +| **运维** | 1 | 环境部署、监控运维 | Linux、Docker、运维经验 | + +### 5.3 人员分工 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 开发任务分工 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 架构师 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 整体架构设计 │ │ +│ │ • ESL模块核心代码 │ │ +│ │ • ACD核心算法 │ │ +│ │ • 技术难点攻关 │ │ +│ │ • 代码Review │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发A (资深) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 坐席管理模块 │ │ +│ │ • 呼叫控制模块 │ │ +│ │ • WebSocket推送服务 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发B │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • ACD分配模块 │ │ +│ │ • 队列管理模块 │ │ +│ │ • 溢出策略 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发C │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 预测式外呼模块 │ │ +│ │ • 机器人外呼模块 │ │ +│ │ • TTS集成 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发D │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 录音管理模块 │ │ +│ │ • 监控统计模块 │ │ +│ │ • 班长监控功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发E (可选) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • API接口开发 │ │ +│ │ • CRM集成对接 │ │ +│ │ • 单元测试 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 前端开发A │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 软电话条组件开发 │ │ +│ │ • WebSocket客户端 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 前端开发B │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 实时监控大屏 │ │ +│ │ • 数据可视化图表 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ FreeSWITCH工程师 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • FreeSWITCH配置优化 │ │ +│ │ • Dialplan编写 │ │ +│ │ • Lua IVR脚本开发 │ │ +│ │ • 线路对接 │ │ +│ │ • 高可用部署 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、风险控制 + +### 6.1 风险清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 风险清单 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险1: FreeSWITCH技术门槛高 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 高 │ │ +│ │ 影响: 开发进度延迟,功能实现困难 │ │ +│ │ 应对: │ │ +│ │ • Week 1-2 集中学习FreeSWITCH和ESL │ │ +│ │ • 考虑引入外部FreeSWITCH顾问 │ │ +│ │ • 预留20%时间缓冲 │ │ +│ │ • 简化复杂功能,优先实现核心流程 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险2: 运营商线路对接周期长 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 高 │ │ +│ │ 影响: 无法进行真实环境测试,上线延迟 │ │ +│ │ 应对: │ │ +│ │ • 项目启动即开始申请线路 │ │ +│ │ • 开发阶段使用模拟线路/测试线路 │ │ +│ │ • 准备备选运营商 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险3: CRM系统对接复杂 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 集成联调时间延长 │ │ +│ │ 应对: │ │ +│ │ • 提前与CRM团队沟通,明确接口规范 │ │ +│ │ • 提供详细API文档和示例代码 │ │ +│ │ • 预留充足联调时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险4: 并发性能不达标 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 系统无法支撑业务量 │ │ +│ │ 应对: │ │ +│ │ • Week 16 进行完整压力测试 │ │ +│ │ • 预先设计水平扩展方案 │ │ +│ │ • 关键代码Review,避免性能瓶颈 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险5: ASR/TTS识别效果不佳 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 机器人/IVR体验差 │ │ +│ │ 应对: │ │ +│ │ • 提前进行ASR效果测试 │ │ +│ │ • 准备热词优化方案 │ │ +│ │ • 设计按键兜底方案 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险6: 核心人员离职 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 知识断层,进度受阻 │ │ +│ │ 应对: │ │ +│ │ • 重要模块双人备份 │ │ +│ │ • 代码规范、文档完善 │ │ +│ │ • 定期代码Review和知识分享 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 应急预案 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 应急预案 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 场景1: 开发进度严重滞后 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 核心功能延迟超过2周 │ │ +│ │ 应对措施: │ │ +│ │ • 评估是否可以简化功能范围 │ │ +│ │ • 考虑增加外部资源支持 │ │ +│ │ • 与业务方协商分阶段上线 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 场景2: FreeSWITCH关键问题无法解决 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 技术问题阻塞超过1周 │ │ +│ │ 应对措施: │ │ +│ │ • 联系FreeSWITCH社区或付费支持 │ │ +│ │ • 寻求外部FreeSWITCH专家支援 │ │ +│ │ • 评估替代方案可行性 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 场景3: 上线后系统不稳定 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 故障频发或性能严重不足 │ │ +│ │ 应对措施: │ │ +│ │ • 启用回退方案(如有旧系统) │ │ +│ │ • 降级部分功能,保障核心业务 │ │ +│ │ • 紧急扩容或优化 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、交付物清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 项目交付物清单 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 阶段 │ 交付物 │ 负责人 │ +│ ──────────────│───────────────────────────────────────│───────────│ +│ │ │ │ +│ 需求阶段 │ 需求规格说明书 │ 产品 │ +│ │ 接口设计文档 │ 架构师 │ +│ │ 数据库设计文档 │ 架构师 │ +│ │ │ │ +│ 开发阶段 │ 源代码(Git仓库) │ 开发团队 │ +│ │ 单元测试代码 │ 开发团队 │ +│ │ API接口文档 │ 后端开发 │ +│ │ 前端组件文档 │ 前端开发 │ +│ │ │ │ +│ 测试阶段 │ 测试用例文档 │ 测试 │ +│ │ 测试报告 │ 测试 │ +│ │ 性能测试报告 │ 测试 │ +│ │ │ │ +│ 部署阶段 │ 部署手册 │ 运维 │ +│ │ 运维手册 │ 运维 │ +│ │ 高可用配置文档 │ 运维 │ +│ │ │ │ +│ 上线阶段 │ 上线检查清单 │ 项目经理 │ +│ │ 培训材料 │ 产品 │ +│ │ 用户操作手册 │ 产品 │ +│ │ 项目总结报告 │ 项目经理 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 八、总结 + +### 8.1 方案要点总结 + +| 维度 | 要点 | +|------|------| +| **技术架构** | FreeSWITCH + Java中间件 + Vue前端,ESL控制模式 | +| **开发周期** | 4个月(17周),分4阶段 | +| **团队配置** | 11-14人(含项目经理、开发、测试、运维) | +| **核心功能** | 呼入呼出、ACD分配、预测式外呼、机器人、录音、监控 | +| **风险控制** | FreeSWITCH技术预研、线路提前申请、预留缓冲时间 | + +### 8.2 关键成功因素 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 关键成功因素 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 技术准备充分 │ +│ └─► Week 1-2 完成FreeSWITCH学习和技术预研 │ +│ │ +│ 2. 核心功能优先 │ +│ └─► 先完成P0功能,确保基础可用 │ +│ │ +│ 3. 持续集成测试 │ +│ └─► 每完成一个模块立即测试,避免问题堆积 │ +│ │ +│ 4. CRM团队紧密配合 │ +│ └─► 提前沟通接口,并行开发 │ +│ │ +│ 5. 线路提前就位 │ +│ └─► 项目启动即申请运营商线路 │ +│ │ +│ 6. 分阶段上线 │ +│ └─► 先上线一条业务线验证,再全量推广 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 8.3 下一步行动 + +| 序号 | 行动项 | 负责人 | 完成时间 | +|------|-------|-------|---------| +| 1 | 确认团队人员到位 | 项目经理 | 项目启动前 | +| 2 | 采购/准备开发测试环境 | 运维 | Week 1 | +| 3 | 启动运营商线路申请 | 项目经理 | Week 1 | +| 4 | 组织FreeSWITCH技术培训 | 架构师 | Week 1-2 | +| 5 | 与CRM团队召开接口对接会议 | 架构师 | Week 2 | +| 6 | 完成技术方案评审 | 架构师 | Week 2 | + +--- + +**文档结束** + +如需进一步细化某个模块的设计或有其他问题,请随时告知! \ No newline at end of file diff --git a/cc-center/企业呼叫中心系统需求规格说明书.txt b/cc-center/企业呼叫中心系统需求规格说明书.txt new file mode 100644 index 0000000..193b481 --- /dev/null +++ b/cc-center/企业呼叫中心系统需求规格说明书.txt @@ -0,0 +1,871 @@ +**文档版本**:V1.0 +**编制日期**:2024年1月 +**文档状态**:初稿 + +--- + +## 第一章 项目概述 + +### 1.1 项目背景 + +本企业主营道路救援、家政服务、代驾服务三大业务板块,面向个人消费者(ToC)提供服务。随着业务规模扩大,现需建设一套专业的呼叫中心系统, +以提升客户服务体验、提高服务响应效率、降低人工成本。 + +### 1.2 项目目标 + +| 目标维度 | 具体目标 | +|---------|---------| +| **服务能力** | 支持150坐席同时在线,日处理呼叫量50,000通 | +| **响应效率** | 道路救援等紧急服务实现快速响应,7×24小时不间断服务 | +| **人工效率** | 通过预测式外呼提升坐席利用率,减少空闲等待时间 | +| **自动化水平** | 通过智能IVR和机器人实现常见问题自助解答、批量通知自动化 | +| **系统稳定性** | 主备高可用架构,确保核心业务(道路救援)不中断 | + +### 1.3 项目范围 + +**纳入范围:** +- 基础呼叫能力(呼入/呼出) +- 智能IVR与语音导航 +- 呼入/呼出机器人 +- 预测式外呼 +- 无人化批量通知 +- CRM系统集成(API对接) +- 软电话条组件 +- 实时监控大屏 +- 班长监控功能 +- 全程录音 +- 主备高可用部署 + +**不纳入本期范围:** +- 统计报表系统(二期规划) +- 智能质检系统(二期规划) +- 短信功能(由第三方平台提供) + +### 1.4 术语定义 + +| 术语 | 定义 | +|------|------| +| **ACD** | 自动呼叫分配(Automatic Call Distribution) | +| **IVR** | 交互式语音应答(Interactive Voice Response) | +| **ASR** | 自动语音识别(Automatic Speech Recognition) | +| **TTS** | 语音合成(Text To Speech) | +| **NLU** | 自然语言理解(Natural Language Understanding) | +| **CTI** | 计算机电话集成(Computer Telephony Integration) | +| **SIP** | 会话发起协议(Session Initiation Protocol) | + +--- + +## 第二章 业务需求 + +### 2.1 业务架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户触点 │ +│ 道路救援热线 家政服务热线 代驾服务热线 │ +│ (400-XXX-XXXX) (400-XXX-XXXX) (固话/400) │ +└───────────────┬─────────────────┬─────────────────┬─────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 呼叫中心系统 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 智能IVR │ │ 呼入机器人│ │ ACD分配 │ │ 外呼系统 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 坐席管理 │ │ 录音系统 │ │ 实时监控 │ │ API网关 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└───────────────────────────┬─────────────────────────────────────┘ + │ API + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CRM/业务系统 │ +│ 客户管理 │ 工单管理 │ 派单系统 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 呼入业务场景 + +#### 2.2.1 道路救援来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(语音识别:"道路救援") + → 机器人收集初步信息(可选) + → 转救援技能组 + → ACD分配(历史服务优先 > 最长空闲 > 轮询) + → 技能组全忙时溢出到备用组 + → 坐席接听 + CRM弹屏 + → 坐席在CRM创建工单/派单 + → 通话结束 → 录音保存 +``` + +**业务要求:** +- 响应时效要求高,需优先保障接通率 +- 支持7×24小时服务 +- 来电需快速识别客户身份,展示历史服务记录 + +#### 2.2.2 家政服务来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(按键或语音选择"家政服务") + → 常见问题解答(服务项目、价格咨询等) + → 客户需要人工服务 → 转家政技能组 + → 坐席接听 + CRM弹屏 + → 预约服务/创建工单 + → 通话结束 → 录音保存 +``` + +#### 2.2.3 代驾服务来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(语音识别:"叫代驾") + → 转代驾技能组 + → 坐席接听 + CRM弹屏 + → 创建代驾订单/派单 + → 通话结束 → 录音保存 +``` + +#### 2.2.4 投诉来电场景 + +**业务流程:** + +``` +客户来电 + → 智能IVR(识别"投诉"意图) + → 直接转人工(投诉不经机器人处理) + → 分配至对应业务技能组或投诉专席 + → CRM弹屏 + 标记投诉标签 + → 坐席记录投诉内容 +``` + +### 2.3 呼出业务场景 + +#### 2.3.1 预测式外呼(营销/回访) + +**业务流程:** + +``` +业务系统推送外呼任务(客户名单) + → 呼叫中心批量拨打 + → 客户接听 → 自动转接空闲坐席 + → CRM弹屏展示客户信息 + → 坐席进行营销/回访 + → 通话结束 → 录音保存 → 话单回传业务系统 +``` + +**业务要求:** +- 系统自动拨打,接通后才转坐席,减少坐席等待时间 +- 支持接通率、坐席利用率统计 +- 外呼任务支持暂停、恢复、停止 + +#### 2.3.2 机器人自动外呼(通知类) + +**场景一:服务人员派出/即将到达通知** + +``` +业务系统触发通知(API调用) + → 呼叫中心自动拨打客户电话 + → 播放TTS语音:"您好,您预约的道路救援服务,师傅已出发,预计10分钟到达..." + → 通话结束 → 结果回传业务系统 +``` + +**场景二:账单催缴提醒** + +``` +业务系统推送催缴名单 + → 批量自动拨打 + → 播放TTS语音:"您好,您有一笔待支付账单..." + → 纯语音播报,无需交互 + → 拨打结果回传(接通/未接通/关机等) +``` + +#### 2.3.3 机器人自动外呼(回访/满意度调查) + +**业务流程:** + +``` +业务系统推送回访任务 + → 呼叫中心自动拨打 + → 播放TTS语音:"您好,请问您对本次服务满意吗?满意请按1,不满意请按2" + → 采集客户按键 + → 按键结果回传业务系统 + → 不满意客户可选择转人工跟进 +``` + +### 2.4 机器人客服场景 + +#### 2.4.1 呼入机器人功能定位 + +| 功能 | 说明 | +|------|------| +| **语音导航** | 支持客户语音说出需求,自动识别并转接对应技能组 | +| **常见问题解答** | 支持50个以上FAQ,自动语音解答 | +| **转人工预处理** | 收集客户初步信息后转人工,提高坐席效率 | + +#### 2.4.2 转人工触发条件 + +| 触发条件 | 说明 | +|---------|------| +| 客户主动要求 | 客户说"转人工"或按键选择转人工 | +| 识别失败 | 机器人连续2-3次无法识别客户意图 | +| 特定问题类型 | 投诉类问题直接转人工,不经机器人处理 | + +--- + +## 第三章 功能需求 + +### 3.1 智能IVR模块 + +#### 3.1.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IVR-001 | 多级语音菜单 | 支持配置多级IVR语音菜单,按键导航 | P0 | +| IVR-002 | 语音识别导航 | 集成阿里云ASR,支持客户语音说出需求自动识别 | P0 | +| IVR-003 | 多业务线路由 | 根据来电号码(400/固话)自动进入对应业务IVR流程 | P0 | +| IVR-004 | 时间条件路由 | 支持按工作时间/节假日配置不同IVR流程 | P1 | +| IVR-005 | 黑名单拦截 | 来电号码在黑名单中自动播放提示后挂断 | P1 | +| IVR-006 | VIP客户识别 | VIP客户来电自动优先排队或转专属坐席 | P2 | +| IVR-007 | IVR流程可视化配置 | 提供可视化拖拽界面配置IVR流程 | P1 | + +#### 3.1.2 语音识别要求 + +- **ASR供应商**:阿里云语音识别 +- **识别准确率**:≥90% +- **响应时间**:≤500ms +- **支持方言**:普通话为主,可选支持粤语、四川话等 + +### 3.2 ACD自动分配模块 + +#### 3.2.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| ACD-001 | 技能组管理 | 支持创建多个技能组(救援组、家政组、代驾组) | P0 | +| ACD-002 | 坐席技能配置 | 坐席可属于一个或多个技能组,配置技能等级 | P0 | +| ACD-003 | 历史服务优先 | 来电客户优先分配给上次服务的坐席 | P0 | +| ACD-004 | 最长空闲优先 | 同技能组内优先分配给空闲时间最长的坐席 | P0 | +| ACD-005 | 轮询分配 | 同技能组内按顺序轮流分配 | P0 | +| ACD-006 | 分配策略优先级 | 支持配置分配策略优先级:历史服务 > 最长空闲 > 轮询 | P0 | +| ACD-007 | 技能组溢出 | 技能组全忙时,来电溢出到配置的备用技能组 | P0 | +| ACD-008 | 排队等待 | 所有坐席忙时,来电进入排队,播放等待音乐 | P0 | +| ACD-009 | 排队超时处理 | 排队超时可配置:继续等待/溢出/留言/回拨 | P1 | +| ACD-010 | 排队位置播报 | 向等待客户播报当前排队位置 | P2 | + +### 3.3 坐席管理模块 + +#### 3.3.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| AGT-001 | 坐席签入/签出 | 坐席上下班签入签出系统 | P0 | +| AGT-002 | 坐席状态管理 | 支持状态:空闲、通话中、振铃中、后处理、小休、忙碌 | P0 | +| AGT-003 | 小休原因 | 小休时需选择原因(用餐、洗手间、培训等) | P1 | +| AGT-004 | 状态自动切换 | 通话结束自动进入后处理状态,超时自动转空闲 | P0 | +| AGT-005 | 后处理时长配置 | 可配置后处理时长(如30秒) | P1 | +| AGT-006 | 强制签出 | 班长可强制签出坐席 | P1 | + +#### 3.3.2 坐席工作台要求 + +- 坐席不使用呼叫中心独立工作台 +- 坐席在CRM系统中操作业务 +- 呼叫中心提供**软电话条组件**嵌入CRM页面 +- 软电话条通过WebSocket与呼叫中心通信 +- 同时支持IP话机(SIP协议注册) + +### 3.4 软电话条组件 + +#### 3.4.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| SFT-001 | 签入/签出 | 坐席登录CRM后通过软电话条签入呼叫中心 | P0 | +| SFT-002 | 状态切换 | 置忙/置闲/小休状态切换按钮 | P0 | +| SFT-003 | 来电弹屏触发 | 来电时向CRM发送弹屏事件,携带来电号码、客户ID等 | P0 | +| SFT-004 | 接听/挂断 | 点击接听/挂断按钮控制通话 | P0 | +| SFT-005 | 保持/取回 | 通话保持(播放等待音乐)和取回 | P0 | +| SFT-006 | 静音 | 坐席侧静音 | P1 | +| SFT-007 | 转接 | 转接至其他坐席/技能组/外线号码 | P0 | +| SFT-008 | 咨询转 | 先与第三方通话咨询,再决定是否转接 | P1 | +| SFT-009 | 三方通话 | 坐席、客户、第三方三方通话 | P1 | +| SFT-010 | 点击拨号 | 在CRM中点击号码呼出 | P0 | +| SFT-011 | 手动拨号 | 手动输入号码拨打 | P0 | +| SFT-012 | 通话计时 | 显示当前通话时长 | P0 | +| SFT-013 | 来电信息展示 | 显示来电号码、归属地、技能组等 | P0 | + +#### 3.4.2 软电话条技术要求 + +| 要求项 | 说明 | +|-------|------| +| **前端技术** | Web组件,支持iframe嵌入或JS SDK集成 | +| **通信协议** | WebSocket长连接,实时接收呼叫事件 | +| **语音方案** | WebRTC(浏览器软电话)或SIP话机(硬件话机) | +| **浏览器支持** | Chrome 70+、Firefox 65+、Edge 79+ | + +### 3.5 呼入通话控制 + +#### 3.5.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IBC-001 | 来电接入 | 支持SIP中继接入来电 | P0 | +| IBC-002 | 来电排队 | 坐席全忙时来电进入队列等待 | P0 | +| IBC-003 | 等待音乐 | 排队等待时播放等待音乐 | P0 | +| IBC-004 | 来电转接 | 支持盲转、咨询转、转技能组、转外线 | P0 | +| IBC-005 | 三方通话 | 支持三方会议通话 | P1 | +| IBC-006 | 通话保持 | 通话保持并播放等待音乐 | P0 | +| IBC-007 | 通话录音 | 全程录音,支持双声道(坐席/客户分轨)| P0 | + +### 3.6 呼出通话控制 + +#### 3.6.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| OBC-001 | 点击外呼 | 坐席点击号码发起外呼 | P0 | +| OBC-002 | 外显号码设置 | 支持配置外呼显示的主叫号码 | P0 | +| OBC-003 | 外呼转接 | 外呼通话中支持转接 | P0 | +| OBC-004 | 通话录音 | 外呼通话全程录音 | P0 | + +### 3.7 预测式外呼模块 + +#### 3.7.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| PDL-001 | 外呼任务创建 | 通过API创建外呼任务,导入号码名单 | P0 | +| PDL-002 | 任务状态控制 | 支持任务启动、暂停、恢复、停止 | P0 | +| PDL-003 | 预测式拨号算法 | 根据坐席空闲情况自动计算拨打频率 | P0 | +| PDL-004 | 接通转坐席 | 客户接听后自动转接空闲坐席 | P0 | +| PDL-005 | 无人接听处理 | 无人接听/忙音/关机等自动标记状态 | P0 | +| PDL-006 | 外呼时段限制 | 可配置允许外呼的时间段(如9:00-21:00) | P1 | +| PDL-007 | 号码去重 | 同一任务内号码自动去重 | P1 | +| PDL-008 | 重试策略 | 未接通号码可配置自动重试次数和间隔 | P1 | +| PDL-009 | 坐席利用率统计 | 统计坐席有效通话时长占比 | P0 | +| PDL-010 | 接通率统计 | 统计任务接通率 | P0 | + +### 3.8 机器人外呼模块 + +#### 3.8.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| RBT-001 | 批量通知任务 | 通过API创建批量语音通知任务 | P0 | +| RBT-002 | TTS语音播放 | 集成阿里云TTS,文本转语音播放 | P0 | +| RBT-003 | 变量替换 | 支持语音内容中插入变量(姓名、时间、金额等) | P0 | +| RBT-004 | 纯播报模式 | 播放语音后直接挂断 | P0 | +| RBT-005 | 按键采集模式 | 播放语音后等待客户按键,采集结果 | P0 | +| RBT-006 | 按键结果回传 | 按键结果通过回调接口推送至业务系统 | P0 | +| RBT-007 | 无应答处理 | 客户无应答/超时可配置重试或标记 | P1 | +| RBT-008 | 并发控制 | 可配置任务最大并发拨打数 | P1 | +| RBT-009 | 任务进度查询 | API查询任务进度和结果 | P0 | + +### 3.9 呼入机器人模块 + +#### 3.9.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| IRB-001 | 语音识别 | 集成阿里云ASR识别客户语音 | P0 | +| IRB-002 | 意图识别 | 对接NLU平台,识别客户意图 | P0 | +| IRB-003 | FAQ问答 | 支持配置50+常见问题及答案 | P0 | +| IRB-004 | 语音合成应答 | 集成阿里云TTS合成语音应答 | P0 | +| IRB-005 | 多轮对话 | 支持多轮对话收集信息 | P1 | +| IRB-006 | 转人工-主动请求 | 客户说"转人工"触发转接 | P0 | +| IRB-007 | 转人工-识别失败 | 连续N次识别失败自动转人工 | P0 | +| IRB-008 | 转人工-特定意图 | 识别到投诉等意图直接转人工 | P0 | +| IRB-009 | 转接携带信息 | 转人工时携带已收集的信息至坐席 | P1 | +| IRB-010 | 话术配置 | 支持配置机器人话术和回复内容 | P0 | + +### 3.10 录音管理模块 + +#### 3.10.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| REC-001 | 全程录音 | 所有呼入呼出通话全程录音 | P0 | +| REC-002 | 双声道录音 | 坐席和客户分轨录音(便于质检) | P1 | +| REC-003 | 录音存储 | 录音文件存储至NAS/分布式存储 | P0 | +| REC-004 | 长期保存 | 支持录音长期保存,定期归档 | P0 | +| REC-005 | 录音检索 | 按通话ID、时间、号码等检索录音 | P0 | +| REC-006 | 录音下载 | 通过API获取录音文件下载地址 | P0 | +| REC-007 | 录音权限 | 录音访问权限控制 | P1 | + +### 3.11 班长监控模块 + +#### 3.11.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| SUP-001 | 坐席状态监控 | 实时查看所有坐席当前状态 | P0 | +| SUP-002 | 实时监听 | 监听坐席与客户通话,双方不知情 | P0 | +| SUP-003 | 强插通话 | 加入通话,三方均可听到 | P0 | +| SUP-004 | 强拆通话 | 强制结束坐席通话 | P0 | +| SUP-005 | 密语(可选) | 对坐席单向说话,客户听不到(二期) | P2 | +| SUP-006 | 强制签出 | 强制将坐席签出系统 | P1 | +| SUP-007 | 强制置忙 | 强制将坐席状态设为忙碌 | P1 | + +### 3.12 实时监控大屏 + +#### 3.12.1 功能清单 + +| 功能编号 | 功能名称 | 功能描述 | 优先级 | +|---------|---------|---------|--------| +| DSP-001 | 整体概览 | 展示当前呼入量、呼出量、接通率等 | P0 | +| DSP-002 | 坐席状态分布 | 展示各状态坐席数量(空闲、通话、小休等) | P0 | +| DSP-003 | 技能组监控 | 各技能组排队数、等待时长、可用坐席数 | P0 | +| DSP-004 | 实时告警 | 排队超阈值、坐席空闲过少等告警提示 | P1 | +| DSP-005 | 趋势图表 | 当日话务量趋势图(按小时) | P1 | +| DSP-006 | 大屏适配 | 支持大屏显示器展示 | P0 | + +--- + +## 第四章 接口需求 + +### 4.1 接口概述 + +呼叫中心系统作为通信能力中台,通过API方式为CRM/业务系统提供呼叫能力。接口采用RESTful API + WebSocket事件推送模式。 + +### 4.2 接口清单 + +#### 4.2.1 呼入控制接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-IN-001 | 接听来电 | POST | 坐席接听当前来电 | +| API-IN-002 | 挂断通话 | POST | 挂断当前通话 | +| API-IN-003 | 保持通话 | POST | 将通话置于保持状态 | +| API-IN-004 | 取回通话 | POST | 取回保持的通话 | +| API-IN-005 | 转接通话 | POST | 转接至指定坐席/技能组/外线 | +| API-IN-006 | 咨询通话 | POST | 发起咨询通话 | +| API-IN-007 | 咨询转移 | POST | 咨询后转移 | +| API-IN-008 | 三方通话 | POST | 建立三方通话 | + +#### 4.2.2 呼出控制接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-OUT-001 | 点击外呼 | POST | 坐席发起外呼 | +| API-OUT-002 | 取消外呼 | POST | 取消正在拨打的外呼 | +| API-OUT-003 | 预览式外呼确认 | POST | 坐席确认拨打预览号码 | + +#### 4.2.3 坐席状态接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-AGT-001 | 坐席签入 | POST | 坐席签入系统 | +| API-AGT-002 | 坐席签出 | POST | 坐席签出系统 | +| API-AGT-003 | 置忙 | POST | 坐席设置为忙碌状态 | +| API-AGT-004 | 置闲 | POST | 坐席设置为空闲状态 | +| API-AGT-005 | 小休 | POST | 坐席进入小休状态 | +| API-AGT-006 | 查询坐席状态 | GET | 查询指定坐席当前状态 | +| API-AGT-007 | 批量查询坐席状态 | GET | 批量查询坐席状态 | + +#### 4.2.4 外呼任务接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-TASK-001 | 创建外呼任务 | POST | 创建预测式/机器人外呼任务 | +| API-TASK-002 | 导入号码 | POST | 向任务导入外呼号码 | +| API-TASK-003 | 启动任务 | POST | 启动外呼任务 | +| API-TASK-004 | 暂停任务 | POST | 暂停外呼任务 | +| API-TASK-005 | 恢复任务 | POST | 恢复暂停的任务 | +| API-TASK-006 | 停止任务 | POST | 停止外呼任务 | +| API-TASK-007 | 查询任务进度 | GET | 查询任务执行进度 | +| API-TASK-008 | 查询任务结果 | GET | 查询任务拨打结果明细 | + +#### 4.2.5 单条外呼接口(通知类) + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-NOTIFY-001 | 发起语音通知 | POST | 触发单条语音通知外呼 | +| API-NOTIFY-002 | 查询通知结果 | GET | 查询通知拨打结果 | + +#### 4.2.6 录音接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-REC-001 | 查询录音列表 | GET | 按条件查询录音记录 | +| API-REC-002 | 获取录音下载地址 | GET | 获取录音文件下载URL | + +#### 4.2.7 监控接口 + +| 接口编号 | 接口名称 | 请求方式 | 说明 | +|---------|---------|---------|------| +| API-MON-001 | 监听通话 | POST | 班长监听指定坐席通话 | +| API-MON-002 | 强插通话 | POST | 班长强插指定坐席通话 | +| API-MON-003 | 强拆通话 | POST | 班长强制挂断指定坐席通话 | +| API-MON-004 | 实时统计数据 | GET | 获取实时监控统计数据 | +| API-MON-005 | 技能组排队信息 | GET | 获取各技能组排队情况 | + +### 4.3 事件推送(WebSocket) + +#### 4.3.1 通话事件 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-001 | 来电振铃 | 来电分配到坐席时 | 来电号码、客户ID、技能组、IVR收集信息等 | +| EVT-002 | 通话接通 | 坐席接听后 | 通话ID、接通时间 | +| EVT-003 | 通话挂断 | 通话结束后 | 通话ID、挂断方、通话时长 | +| EVT-004 | 转接振铃 | 转接到新坐席振铃时 | 转接来源、来电信息 | +| EVT-005 | 外呼振铃 | 外呼拨打中 | 外呼号码、坐席ID | +| EVT-006 | 外呼接通 | 客户接听 | 通话ID | +| EVT-007 | 外呼失败 | 外呼未接通 | 失败原因(无人接听/忙/关机等) | + +#### 4.3.2 坐席状态事件 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-101 | 状态变更 | 坐席状态变化时 | 坐席ID、新状态、旧状态、时间 | +| EVT-102 | 签入成功 | 坐席签入成功 | 坐席ID、技能组 | +| EVT-103 | 签出成功 | 坐席签出成功 | 坐席ID | + +#### 4.3.3 话单推送 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-201 | 话单推送 | 通话结束后 | 通话ID、主被叫、开始时间、结束时间、时长、录音地址、坐席ID、技能组、呼叫类型等 | + +#### 4.3.4 机器人交互结果推送 + +| 事件编号 | 事件名称 | 触发时机 | 推送内容 | +|---------|---------|---------|---------| +| EVT-301 | 按键结果 | 客户按键后 | 任务ID、号码、按键值、时间 | +| EVT-302 | 机器人通话结果 | 机器人外呼结束 | 任务ID、号码、接通状态、通话时长、按键结果 | + +--- + +## 第五章 非功能需求 + +### 5.1 性能需求 + +| 指标项 | 要求 | +|-------|------| +| **并发坐席数** | 支持150坐席同时在线 | +| **并发通话数** | 支持120路并发通话(含呼入+呼出) | +| **日处理量** | 支持日均50,000通呼叫 | +| **呼叫建立时延** | 来电从接入到振铃坐席 ≤ 1秒 | +| **API响应时间** | 95%请求响应 ≤ 200ms | +| **事件推送时延** | 事件产生到推送至CRM ≤ 500ms | +| **ASR识别延迟** | 语音识别延迟 ≤ 500ms | +| **系统可用性** | ≥ 99.9%(全年停机不超过8.76小时) | + +### 5.2 高可用需求 + +| 要求项 | 说明 | +|-------|------| +| **主备部署** | FreeSWITCH主备双机部署,主机故障自动切换至备机 | +| **切换时间** | 故障切换时间 ≤ 30秒 | +| **数据同步** | 主备配置实时同步 | +| **心跳检测** | 主备心跳检测间隔 ≤ 5秒 | +| **数据库高可用** | 数据库采用主从复制或集群部署 | +| **录音存储** | 录音存储采用RAID或分布式存储,防止数据丢失 | + +### 5.3 安全需求 + +| 要求项 | 说明 | +|-------|------| +| **网络安全** | SIP通信支持TLS加密;API支持HTTPS | +| **API认证** | API调用需Token认证,支持IP白名单 | +| **权限控制** | 支持角色权限管理(管理员、班长、坐席) | +| **录音安全** | 录音文件访问需权限验证 | +| **操作审计** | 关键操作记录审计日志 | + +### 5.4 兼容性需求 + +| 要求项 | 说明 | +|-------|------| +| **SIP话机** | 兼容主流SIP话机品牌(如:Yealink、Grandstream、Fanvil等) | +| **SIP协议** | 符合RFC 3261标准 | +| **浏览器** | 软电话条支持Chrome 70+、Firefox 65+、Edge 79+ | +| **编解码** | 语音编解码支持G.711a、G.711u、G.729(可选) | + +### 5.5 可扩展性需求 + +| 要求项 | 说明 | +|-------|------| +| **坐席扩展** | 架构支持水平扩展至300+坐席 | +| **业务线扩展** | 支持新增业务线/技能组,无需改动核心架构 | +| **线路扩展** | 支持新增SIP中继线路 | + +--- + +## 第六章 系统架构设计建议 + +### 6.1 整体架构图 + +``` + ┌──────────────────┐ + │ 运营商网络 │ + │ (SIP Trunk/400) │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ SBC/边界网关 │ + │ (可选,安全防护) │ + └────────┬─────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + │ ┌──────────────────────▼─────────────────────┐ │ + │ │ FreeSWITCH │ │ + │ │ (主备双机部署) │ │ + │ │ ┌─────────┬─────────┬─────────┬─────────┐ │ │ + │ │ │ IVR │ ACD │ 录音模块 │ 外呼引擎│ │ │ + │ │ └─────────┴─────────┴─────────┴─────────┘ │ │ + │ └───────────────────────┬────────────────────┘ │ + │ │ │ + │ ┌───────────────────────┼───────────────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌──────────┐ ┌──────────────┐ ┌────────┐│ + │ │ CTI中间件 │ │ API Gateway │ │ WS推送 ││ + │ │(ESL连接) │ │ (REST API) │ │ 服务 ││ + │ └────┬─────┘ └──────┬───────┘ └───┬────┘│ + │ │ │ │ │ + │ └────────────────────┼────────────────────┘ │ + │ │ │ + │ ┌────────────────┼────────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ + │ │ MySQL │ │ Redis │ │ 文件存储 │ │ + │ │ (话单/配置)│ │ (缓存/状态)│ │ (录音) │ │ + │ └──────────┘ └──────────┘ └───────────┘ │ + │ │ + │ 呼叫中心系统边界 │ + └───────────────────────────┬──────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌────────────┐ + │ CRM系统 │ │ 业务系统 │ │ 阿里云语音 │ + │(弹屏/工单) │ │(派单/订单)│ │(ASR/TTS) │ + └──────────┘ └──────────┘ └────────────┘ +``` + +### 6.2 核心组件说明 + +| 组件 | 技术选型 | 说明 | +|------|---------|------| +| **媒体服务器** | FreeSWITCH | 核心呼叫控制、媒体处理、IVR、录音 | +| **CTI中间件** | 自研(基于ESL) | 封装FreeSWITCH ESL接口,提供业务逻辑 | +| **API网关** | Nginx/Kong/自研 | RESTful API统一入口 | +| **WebSocket服务** | Node.js/Golang | 实时事件推送 | +| **数据库** | MySQL 8.0 | 话单、配置、坐席信息存储 | +| **缓存** | Redis | 坐席状态、会话信息缓存 | +| **录音存储** | NAS/MinIO/对象存储 | 录音文件长期存储 | +| **ASR/TTS** | 阿里云智能语音 | 语音识别与合成 | +| **NLU** | 外部指定平台 | 意图识别与对话管理 | + +### 6.3 部署架构(主备高可用) + +``` + ┌─────────────────────────────────────┐ + │ VIP(虚拟IP) │ + └──────────────────┬──────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ │ ▼ + ┌─────────────────┐ │ ┌─────────────────┐ + │ FreeSWITCH │◄─────Keepalived心跳──────────────►│ FreeSWITCH │ + │ (主机) │ │ │ (备机) │ + │ CTI中间件 │ │ │ CTI中间件 │ + │ API服务 │ │ │ API服务 │ + └─────────────────┘ │ └─────────────────┘ + │ │ │ + └──────────────────────────┼──────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + ┌──────────────┐ ┌───────────────┐ + │ MySQL主从集群 │ │ Redis Sentinel│ + └──────────────┘ └───────────────┘ + │ + ▼ + ┌───────────────┐ + │ NAS/分布式存储 │ + │ (录音文件) │ + └───────────────┘ +``` + +--- + +## 第七章 硬件与软件清单 + +### 7.1 硬件清单 + +| 序号 | 设备类型 | 规格配置 | 数量 | 用途 | 参考单价(万元) | 合计(万元) | +|-----|---------|---------|------|------|---------------|-------------| +| 1 | 应用服务器(主) | 32核CPU/64GB内存/500GB SSD+2TB HDD | 1 | FreeSWITCH+CTI+API | 5-8 | 5-8 | +| 2 | 应用服务器(备) | 32核CPU/64GB内存/500GB SSD+2TB HDD | 1 | FreeSWITCH备机 | 5-8 | 5-8 | +| 3 | 数据库服务器 | 16核CPU/64GB内存/1TB SSD | 2 | MySQL主从 | 4-6 | 8-12 | +| 4 | Redis服务器 | 8核CPU/32GB内存/500GB SSD | 3 | Redis Sentinel集群 | 2-3 | 6-9 | +| 5 | NAS存储 | 96TB可用空间(RAID) | 1 | 录音长期存储 | 8-15 | 8-15 | +| 6 | 网络交换机 | 千兆三层交换机 | 2 | 网络通信 | 1-2 | 2-4 | +| 7 | 防火墙 | 企业级防火墙 | 1 | 安全防护 | 2-5 | 2-5 | +| 8 | IP话机 | SIP话机(如Yealink T46U) | 150 | 坐席通话终端 | 0.05-0.08 | 7.5-12 | +| 9 | 坐席耳麦 | 专业话务耳麦 | 150 | 坐席配件 | 0.02-0.05 | 3-7.5 | +| **硬件合计** | | | | | | **47-80.5** | + +### 7.2 软件清单 + +| 序号 | 软件/平台 | 说明 | 授权方式 | 预估费用(万元) | +|-----|---------|------|---------|----------------| +| 1 | FreeSWITCH | 开源软交换平台 | 开源免费 | 0 | +| 2 | Linux操作系统 | CentOS/Rocky Linux | 开源免费 | 0 | +| 3 | MySQL数据库 | 社区版 | 开源免费 | 0 | +| 4 | Redis | 缓存 | 开源免费 | 0 | +| 5 | Nginx | 反向代理/负载均衡 | 开源免费 | 0 | +| 6 | 阿里云ASR | 语音识别 | 按量付费 | 详见运营费用 | +| 7 | 阿里云TTS | 语音合成 | 按量付费 | 详见运营费用 | +| 8 | NLU对话平台 | 意图识别 | 按需对接 | 视平台定价 | +| 9 | 监控软件(Zabbix/Prometheus) | 系统监控 | 开源免费 | 0 | +| **软件合计** | | | | **0(开源为主)** | + +### 7.3 开发成本估算 + +| 模块 | 预估人天 | 说明 | +|------|---------|------| +| FreeSWITCH环境搭建与配置 | 15 | 主备部署、线路对接、编解码配置 | +| IVR流程开发 | 20 | 多业务线IVR、语音导航 | +| ACD分配模块 | 25 | 多种分配策略、溢出逻辑 | +| CTI中间件开发 | 40 | ESL封装、业务逻辑 | +| 坐席状态管理 | 15 | 状态机、持久化 | +| API服务开发 | 30 | 全部API接口 | +| WebSocket推送服务 | 15 | 事件推送 | +| 软电话条组件 | 25 | 前端组件、WebRTC集成 | +| 预测式外呼引擎 | 30 | 外呼算法、任务管理 | +| 机器人外呼模块 | 20 | TTS集成、按键采集 | +| 呼入机器人模块 | 25 | ASR/NLU集成、对话流程 | +| 录音管理模块 | 10 | 录音存储、检索 | +| 班长监控模块 | 15 | 监听、强插、强拆 | +| 实时监控大屏 | 20 | 前端大屏、实时数据 | +| 高可用部署 | 15 | 主备切换、Keepalived | +| 联调测试 | 30 | 全流程测试 | +| **合计** | **350人天** | | + +**按人天单价1500-2500元估算,开发成本约:52.5-87.5万元** + +--- + +## 第八章 预算汇总 + +### 8.1 一次性投入 + +| 项目 | 费用(万元) | +|------|-------------| +| 硬件采购 | 47-80.5 | +| 软件(开源为主) | 0 | +| 开发成本 | 52.5-87.5 | +| 实施部署 | 5-10 | +| **一次性投入合计** | **104.5-178** | + +### 8.2 年度运营费用 + +| 项目 | 月费用(万元) | 年费用(万元) | 备注 | +|------|--------------|---------------|------| +| **线路月租** | | | | +| 400号码(3个) | 0.3-1.5 | 3.6-18 | 3条业务线独立号码 | +| SIP中继并发费(120路) | 0.6-1.2 | 7.2-14.4 | 按50-100元/路/月 | +| **通信费** | | | | +| 呼入通话费 | 5-9 | 60-108 | 180万分钟/月×0.03-0.05元 | +| 呼出通话费 | 21.6-40.5 | 259-486 | 270万分钟/月×0.08-0.15元 | +| **云服务费** | | | | +| 阿里云ASR | 2-5 | 24-60 | 按使用量 | +| 阿里云TTS | 1-3 | 12-36 | 按使用量 | +| **其他** | | | | +| 机房/带宽 | 1-2 | 12-24 | 如自有机房可降低 | +| 系统维护 | 1-2 | 12-24 | 人员/备件 | +| **年度运营合计** | | **390-770** | | + +### 8.3 总预算汇总 + +| 项目 | 费用(万元) | +|------|-------------| +| **首年投入** | 494.5-948(一次性+首年运营) | +| **后续每年** | 390-770(运营费用) | + +> ⚠️ **说明:** +> - 通信费用是大头,约占年度成本的80%以上 +> - 具体费用取决于运营商议价、实际话务量 +> - 可通过接通率优化、IVR分流等降低通信成本 + +--- + +## 第九章 项目里程碑建议 + +按照3-4个月上线周期规划: + +| 阶段 | 周期 | 主要工作 | 交付物 | +|------|------|---------|--------| +| **一、需求确认与设计** | 第1-2周 | 需求评审、技术方案设计、架构设计 | 技术方案文档、接口设计文档 | +| **二、环境搭建** | 第3-4周 | 硬件采购到货、FreeSWITCH环境部署、线路对接 | 基础环境就绪 | +| **三、核心功能开发** | 第5-10周 | CTI中间件、API服务、坐席管理、呼入呼出基础功能 | 核心功能可用 | +| **四、高级功能开发** | 第11-14周 | 预测式外呼、机器人模块、IVR、监控大屏 | 全功能开发完成 | +| **五、集成联调** | 第15-16周 | CRM对接、全流程联调、高可用测试 | 联调通过 | +| **六、测试与优化** | 第17-18周 | 功能测试、性能测试、Bug修复 | 测试报告 | +| **七、试运行** | 第19-20周 | 小范围上线、问题收集、优化调整 | 试运行报告 | +| **八、正式上线** | 第21周 | 全面上线、监控保障 | 系统上线 | + +``` +第1月 第2月 第3月 第4月 第5月 +|----|----|----|----|----|----|----|----|----|----| +[需求设计][环境搭建][====核心功能开发====][==高级功能==] + [集成联调][测试] + [试运行][上线] +``` + +--- + +## 第十章 风险与建议 + +### 10.1 项目风险 + +| 风险项 | 风险等级 | 影响 | 应对措施 | +|-------|---------|------|---------| +| FreeSWITCH技术门槛高 | 高 | 开发进度延迟 | 建议团队提前学习ESL开发,或引入有经验顾问 | +| 线路对接周期不可控 | 中 | 影响联调测试 | 提前与运营商沟通,预留缓冲时间 | +| ASR识别效果不及预期 | 中 | 影响机器人体验 | 提前做语料测试,必要时做热词优化 | +| 高可用切换影响业务 | 高 | 道路救援中断 | 充分测试切换机制,演练故障场景 | +| 录音存储空间不足 | 低 | 录音丢失 | 监控存储空间,提前扩容 | + +### 10.2 建议事项 + +1. **团队能力准备**:建议团队提前熟悉FreeSWITCH、ESL、SIP协议相关知识 +2. **分阶段上线**:可先上线基础呼入呼出功能,机器人功能迭代上线 +3. **压力测试**:上线前务必进行150坐席并发的压力测试 +4. **灰度发布**:建议先在一条业务线(如家政)试运行,再推广到道路救援 +5. **监控告警**:完善监控体系,特别是对道路救援线路的可用性监控 + +--- + +## 附录 + +### 附录A:术语表 +(见第一章1.4节) + +### 附录B:接口详细设计 +(需在技术设计阶段细化) + +### 附录C:IVR流程图 +(需在需求确认阶段补充) + +### 附录D:机器人话术模板 +(需在需求确认阶段补充) + +--- + +**文档结束** \ No newline at end of file diff --git a/cc-center/企业呼叫中心系统项目立项书.txt b/cc-center/企业呼叫中心系统项目立项书.txt new file mode 100644 index 0000000..5c42e3f --- /dev/null +++ b/cc-center/企业呼叫中心系统项目立项书.txt @@ -0,0 +1,711 @@ +**项目名称**:企业呼叫中心系统建设项目 +**项目编号**:CC-2024-001 +**编制日期**:2024年1月 +**编 制 人**:_________________ +**审 核 人**:_________________ +**批 准 人**:_________________ + +--- + +## 文档修订记录 + +| 版本 | 日期 | 修订内容 | 修订人 | 审核人 | +|------|------|---------|--------|--------| +| V1.0 | 2024-01-XX | 初稿 | | | + +--- + +## 目录 + +1. [项目概述](#第一章-项目概述) +2. [项目背景与必要性](#第二章-项目背景与必要性) +3. [项目目标](#第三章-项目目标) +4. [项目范围](#第四章-项目范围) +5. [项目建设方案](#第五章-项目建设方案) +6. [项目投资预算](#第六章-项目投资预算) +7. [项目效益分析](#第七章-项目效益分析) +8. [项目实施计划](#第八章-项目实施计划) +9. [项目组织架构](#第九章-项目组织架构) +10. [风险分析与应对](#第十章-风险分析与应对) +11. [结论与建议](#第十一章-结论与建议) + +--- + +## 第一章 项目概述 + +### 1.1 项目名称 + +企业呼叫中心系统建设项目 + +### 1.2 项目建设单位 + +**单位名称**:_________________(企业名称) +**负 责 人**:_________________ +**联系方式**:_________________ + +### 1.3 项目概要 + +本项目旨在建设一套集**呼入呼出、智能IVR、机器人客服、预测式外呼**于一体的企业级呼叫中心系统,支撑公司道路救援、家政服务、代驾服务三大业务板块的客户服务与营销运营工作。 + +| 项目要素 | 内容描述 | +|---------|---------| +| **服务对象** | 个人消费者(ToC) | +| **业务范围** | 道路救援、家政服务、代驾服务 | +| **坐席规模** | 150坐席 | +| **日话务量** | 50,000通/日 | +| **服务时间** | 7×24小时 | +| **建设周期** | 3-4个月 | +| **建设方式** | 自主研发(基于FreeSWITCH开源平台) | +| **部署方式** | 私有化部署(主备高可用架构) | + +### 1.4 项目总投资 + +| 投资类别 | 金额(万元) | +|---------|-------------| +| 一次性建设投资 | 104.5-178 | +| 首年运营费用 | 390-770 | +| **首年总投入** | **494.5-948** | + +--- + +## 第二章 项目背景与必要性 + +### 2.1 企业现状 + +本企业主营**道路救援、家政服务、代驾服务**三大业务,面向个人消费者提供上门服务。随着业务规模的持续扩大,现有的客户服务模式已无法满足业务发展需求: + +**现状问题:** + +| 问题领域 | 具体问题 | 影响 | +|---------|---------|------| +| **客服能力** | 无统一呼叫平台,多部门分散接听 | 客户体验差,管理困难 | +| **响应效率** | 道路救援等紧急需求响应慢 | 客户流失,品牌受损 | +| **服务时间** | 夜间/节假日服务能力不足 | 错失订单,客户投诉 | +| **外呼效率** | 人工逐一拨打,效率低下 | 人力成本高,触达率低 | +| **数据管理** | 通话无录音,无法追溯 | 纠纷处理困难,质量无法管控 | +| **系统集成** | 与CRM系统无法打通 | 信息孤岛,坐席重复操作 | + +### 2.2 建设必要性 + +#### 2.2.1 业务发展的迫切需要 + +``` + 业务增长趋势 + 话务量 + ↑ + 60000│ ╭──── 预测增长 + │ ╭────╯ + 50000│ ╭────╯ ←── 当前日均50000通 + │ ╭────╯ + 40000│ ╭────╯ + │ ╭────╯ + 30000│╭────╯ + │ + └────────────────────────────────────→ 时间 + 2022 2023 2024 2025 +``` + +随着业务规模扩大,日话务量已达**50,000通**,预计未来2年将持续增长。迫切需要建设专业呼叫中心系统支撑业务发展。 + +#### 2.2.2 客户体验提升的需要 + +| 客户痛点 | 系统解决方案 | +|---------|-------------| +| 打电话难以接通 | 150坐席+智能排队+溢出机制 | +| 等待时间长 | IVR自助分流+机器人预处理 | +| 反复描述问题 | CRM弹屏,坐席即时了解客户信息 | +| 投诉处理无反馈 | 录音追溯+工单闭环 | + +#### 2.2.3 运营效率提升的需要 + +| 运营痛点 | 系统解决方案 | +|---------|-------------| +| 外呼效率低 | 预测式外呼,坐席利用率提升50%+ | +| 通知类工作繁重 | 机器人自动批量通知 | +| 回访覆盖率低 | 机器人自动回访+满意度调查 | +| 管理缺乏抓手 | 实时监控大屏+班长监控 | + +#### 2.2.4 合规与风控的需要 + +- 道路救援涉及紧急服务,需确保系统**高可用** +- 服务过程需**全程录音**,便于纠纷处理 +- 需与现有CRM系统**无缝集成**,确保数据安全 + +### 2.3 建设可行性 + +#### 2.3.1 技术可行性 + +| 技术领域 | 可行性分析 | +|---------|-----------| +| **呼叫平台** | FreeSWITCH开源成熟,已广泛应用于企业呼叫中心 | +| **智能语音** | 阿里云ASR/TTS技术成熟,识别准确率>95% | +| **系统集成** | 标准API/WebSocket接口,与现有CRM可顺利对接 | +| **高可用** | 主备架构+Keepalived方案成熟可靠 | + +#### 2.3.2 团队可行性 + +| 条件 | 评估 | +|------|------| +| 自有技术团队 | ✅ 具备自主开发能力 | +| FreeSWITCH经验 | ⚠️ 需提前培训或引入顾问支持 | +| 项目管理能力 | ✅ 具备中大型项目实施经验 | + +#### 2.3.3 经济可行性 + +- 自主研发相比采购商业产品**节省授权费用** +- 私有化部署**长期成本可控** +- 项目投资回报分析详见第七章 + +--- + +## 第三章 项目目标 + +### 3.1 总体目标 + +建设一套**安全、稳定、高效、智能**的企业级呼叫中心系统,实现客户服务统一接入、智能化分流、高效外呼,全面提升客户服务体验与运营效率。 + +### 3.2 具体目标 + +#### 3.2.1 能力目标 + +| 目标项 | 指标要求 | +|-------|---------| +| 坐席并发能力 | 支持150坐席同时在线 | +| 通话并发能力 | 支持120路并发通话 | +| 日处理能力 | 支持50,000通/日 | +| 系统可用性 | ≥99.9%(全年停机<8.76小时) | +| 故障切换时间 | ≤30秒 | + +#### 3.2.2 效率目标 + +| 目标项 | 当前值 | 目标值 | 提升幅度 | +|-------|-------|-------|---------| +| 人工坐席利用率 | 约40% | ≥70% | +75% | +| 外呼接通后转接时间 | N/A(人工拨号) | ≤3秒 | - | +| 批量通知人工耗时 | 100%人工 | 0%(全自动) | 释放人力 | +| 客户等待时长 | 无统计 | 平均<30秒 | - | + +#### 3.2.3 体验目标 + +| 目标项 | 指标要求 | +|-------|---------| +| 首次呼叫解决率 | ≥80% | +| IVR自助分流率 | ≥30% | +| 客户满意度 | ≥90% | + +### 3.3 建设原则 + +| 原则 | 说明 | +|------|------| +| **统一规划** | 三大业务线统一平台,避免重复建设 | +| **安全可靠** | 主备高可用,确保道路救援等紧急服务不中断 | +| **开放集成** | 标准API接口,与CRM/业务系统无缝对接 | +| **智能高效** | 引入AI能力,提升服务效率与体验 | +| **弹性扩展** | 架构支持未来扩展至300+坐席 | +| **自主可控** | 自主研发,核心技术自主掌握 | + +--- + +## 第四章 项目范围 + +### 4.1 业务范围 + +| 业务线 | 呼入场景 | 呼出场景 | +|-------|---------|---------| +| **道路救援** | 叫救援、咨询、投诉 | 回访、满意度调查、到达通知 | +| **家政服务** | 预约服务、咨询、投诉 | 回访、营销推广、上门提醒 | +| **代驾服务** | 叫代驾、咨询、投诉 | 回访、营销推广、到达通知 | + +### 4.2 功能范围 + +#### 4.2.1 本期建设范围(一期) + +| 模块 | 功能项 | 优先级 | +|------|-------|--------| +| **基础通话** | 呼入接听、呼出拨打、转接、保持、三方通话 | P0 | +| **智能IVR** | 语音导航、按键导航、语音识别、多业务分流 | P0 | +| **ACD分配** | 技能组管理、多种分配策略、排队溢出 | P0 | +| **坐席管理** | 签入签出、状态管理、软电话条 | P0 | +| **预测式外呼** | 批量外呼任务、自动拨打、接通转坐席 | P0 | +| **机器人外呼** | 批量通知、TTS播报、按键采集 | P0 | +| **呼入机器人** | FAQ问答、意图识别、转人工 | P0 | +| **录音管理** | 全程录音、录音存储、录音检索下载 | P0 | +| **班长监控** | 监听、强插、强拆 | P0 | +| **监控大屏** | 实时数据展示、坐席状态、排队监控 | P0 | +| **系统集成** | CRM弹屏、API接口、事件推送 | P0 | +| **高可用** | 主备部署、故障切换 | P0 | + +#### 4.2.2 二期规划范围(本期不含) + +| 模块 | 功能项 | 说明 | +|------|-------|------| +| **统计报表** | 话务报表、坐席绩效、外呼分析 | 二期建设 | +| **智能质检** | 语音转写、关键词检测、情绪分析 | 二期建设 | +| **密语功能** | 班长对坐席单向指导 | 二期建设 | +| **短信功能** | 不涉及,由第三方平台提供 | 不纳入 | + +### 4.3 集成范围 + +| 集成系统 | 集成方式 | 集成内容 | +|---------|---------|---------| +| **CRM系统** | API双向对接 | 来电弹屏、客户资料、工单创建 | +| **业务系统** | API对接 | 外呼任务推送、通知触发、结果回传 | +| **阿里云语音** | API调用 | ASR语音识别、TTS语音合成 | +| **NLU平台** | API对接 | 意图识别、对话管理 | + +--- + +## 第五章 项目建设方案 + +### 5.1 总体架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 用户层 │ +│ 道路救援客户 家政服务客户 代驾服务客户 │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ 400-XXX-XXX1 400-XXX-XXX2 固话/400 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 接入层 │ +│ 运营商SIP中继线路 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 呼叫控制层 │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ FreeSWITCH (主备高可用) │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ │ 呼入控制│ │ 呼出控制│ │智能IVR │ │ ACD分配│ │ 录音模块│ │ │ +│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 业务服务层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │CTI中间件 │ │API Gateway│ │ WS推送 │ │ 外呼引擎 │ │ 机器人引擎│ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ MySQL │ │ Redis │ │ NAS存储 │ │ +│ │ (主从集群) │ │(Sentinel)│ │ (录音) │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 集成层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ CRM系统 │ │ 业务系统 │ │ 阿里云ASR/TTS│ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 终端层 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ IP话机 │ │ 软电话条 │ │ +│ │ (150台) │ │ (嵌入CRM) │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 部署架构 + +采用**私有化部署+主备高可用**架构: + +``` + ┌─────────────────┐ + │ 虚拟IP(VIP) │ + │ SIP线路接入 │ + └────────┬────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ 主服务器 │◄──── Keepalived ────►│ 备服务器 │ + ├─────────────────┤ 心跳检测 ├─────────────────┤ + │ FreeSWITCH │ │ FreeSWITCH │ + │ CTI中间件 │ │ CTI中间件 │ + │ API服务 │ │ API服务 │ + │ WebSocket服务 │ │ WebSocket服务 │ + └────────┬────────┘ └────────┬────────┘ + │ │ + └─────────────────────┬─────────────────────┘ + │ + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MySQL主从集群 │ │ Redis Sentinel │ │ NAS存储 │ +│ (2节点) │ │ (3节点) │ │ (RAID冗余) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 5.3 技术选型 + +| 组件类型 | 技术选型 | 选型理由 | +|---------|---------|---------| +| **媒体服务器** | FreeSWITCH 1.10+ | 开源成熟、性能优异、功能完善 | +| **操作系统** | Rocky Linux 8/CentOS 8 | 稳定可靠、长期支持 | +| **开发语言** | Java/Golang | CTI中间件、API服务 | +| **前端框架** | Vue.js 3.x | 软电话条、监控大屏 | +| **数据库** | MySQL 8.0 | 成熟稳定、主从复制 | +| **缓存** | Redis 6.x | 高性能、Sentinel高可用 | +| **消息队列** | RabbitMQ/Kafka | 事件异步处理(可选) | +| **负载均衡** | Nginx | 反向代理、SSL卸载 | +| **高可用** | Keepalived | VIP漂移、故障切换 | +| **监控** | Prometheus + Grafana | 系统监控、告警 | +| **ASR/TTS** | 阿里云智能语音 | 识别准确率高、服务稳定 | + +### 5.4 网络规划 + +| 网络区域 | 网段规划 | 用途 | +|---------|---------|------| +| **业务网段** | 10.10.1.0/24 | 呼叫中心服务器 | +| **数据网段** | 10.10.2.0/24 | 数据库、存储 | +| **坐席网段** | 10.10.10.0/23 | 坐席终端、IP话机 | +| **管理网段** | 10.10.100.0/24 | 运维管理 | +| **SIP信令** | 独立VLAN | SIP中继对接 | + +### 5.5 号码规划 + +| 业务线 | 号码类型 | 号码 | 用途 | +|-------|---------|------|------| +| 道路救援 | 400号码 | 400-XXX-XXX1 | 客户呼入 | +| 家政服务 | 400号码 | 400-XXX-XXX2 | 客户呼入 | +| 代驾服务 | 400/固话 | 400-XXX-XXX3 | 客户呼入 | +| 外呼显号 | 固话 | 0XX-XXXXXXXX | 外呼主叫显示 | + +--- + +## 第六章 项目投资预算 + +### 6.1 投资概算汇总 + +| 投资类别 | 金额(万元) | 占比 | +|---------|-------------|------| +| 硬件设备 | 47-80.5 | 33%-45% | +| 软件及开发 | 52.5-87.5 | 37%-49% | +| 实施部署 | 5-10 | 4%-6% | +| **一次性投资合计** | **104.5-178** | **100%** | + +### 6.2 硬件投资明细 + +| 序号 | 设备名称 | 规格 | 数量 | 单价(万) | 合计(万) | +|-----|---------|------|------|---------|---------| +| 1 | 应用服务器 | 32核/64GB/500GB SSD+2TB | 2 | 5-8 | 10-16 | +| 2 | 数据库服务器 | 16核/64GB/1TB SSD | 2 | 4-6 | 8-12 | +| 3 | Redis服务器 | 8核/32GB/500GB SSD | 3 | 2-3 | 6-9 | +| 4 | NAS存储 | 96TB可用(RAID) | 1 | 8-15 | 8-15 | +| 5 | 网络交换机 | 千兆三层 | 2 | 1-2 | 2-4 | +| 6 | 防火墙 | 企业级 | 1 | 2-5 | 2-5 | +| 7 | IP话机 | SIP话机 | 150 | 0.05-0.08 | 7.5-12 | +| 8 | 坐席耳麦 | 专业话务耳麦 | 150 | 0.02-0.05 | 3-7.5 | +| | **硬件合计** | | | | **47-80.5** | + +### 6.3 软件及开发投资明细 + +| 序号 | 项目 | 说明 | 费用(万) | +|-----|------|------|---------| +| 1 | FreeSWITCH | 开源免费 | 0 | +| 2 | 操作系统 | Rocky Linux开源 | 0 | +| 3 | MySQL/Redis | 社区版开源 | 0 | +| 4 | 系统开发 | 350人天×1500-2500元 | 52.5-87.5 | +| | **软件及开发合计** | | **52.5-87.5** | + +### 6.4 实施部署投资 + +| 序号 | 项目 | 费用(万) | +|-----|------|---------| +| 1 | 硬件安装调试 | 1-2 | +| 2 | 网络部署配置 | 1-2 | +| 3 | 系统联调测试 | 2-4 | +| 4 | 培训及上线支持 | 1-2 | +| | **实施部署合计** | **5-10** | + +### 6.5 年度运营费用 + +| 项目 | 月费用(万) | 年费用(万) | +|------|-----------|-----------| +| **线路费用** | | | +| 400号码月租(3个) | 0.3-1.5 | 3.6-18 | +| SIP并发费(120路) | 0.6-1.2 | 7.2-14.4 | +| **通信费用** | | | +| 呼入通话费 | 5-9 | 60-108 | +| 呼出通话费 | 21.6-40.5 | 259-486 | +| **云服务费用** | | | +| 阿里云ASR | 2-5 | 24-60 | +| 阿里云TTS | 1-3 | 12-36 | +| **运维费用** | | | +| 机房/带宽 | 1-2 | 12-24 | +| 系统维护 | 1-2 | 12-24 | +| **年度运营合计** | | **390-770** | + +### 6.6 投资估算汇总表 + +| 项目 | 首年(万) | 第二年(万) | 第三年(万) | +|------|---------|-----------|-----------| +| 一次性建设投入 | 104.5-178 | - | - | +| 年度运营费用 | 390-770 | 390-770 | 390-770 | +| **年度总计** | **494.5-948** | **390-770** | **390-770** | +| **三年累计** | | | **1274.5-2488** | + +--- + +## 第七章 项目效益分析 + +### 7.1 经济效益 + +#### 7.1.1 人力成本节约 + +| 节约项 | 计算依据 | 年节约(万) | +|-------|---------|-----------| +| **外呼效率提升** | 坐席利用率从40%→70%,相当于节约45个坐席人力 | 45×8=360 | +| **批量通知自动化** | 原需5人专职,现全自动 | 5×8=40 | +| **回访自动化** | 原需10人,现机器人承担80% | 8×8=64 | +| **IVR自助分流** | 30%简单咨询自助解决,节约人力 | 15×8=120 | +| **年人力成本节约** | | **约584万** | + +> 注:按坐席人均年成本8万元估算 + +#### 7.1.2 业务增收 + +| 增收项 | 计算依据 | 年增收(万) | +|-------|---------|-----------| +| **接通率提升** | 减少客户流失,预计增收5% | 约200 | +| **外呼营销转化** | 新增营销触达能力 | 约150 | +| **年业务增收** | | **约350万** | + +#### 7.1.3 投资回报分析 + +| 指标 | 数值 | +|------|------| +| 一次性投入 | 141万(取中值) | +| 年运营成本 | 580万(取中值) | +| 年成本节约 | 584万 | +| 年业务增收 | 350万 | +| **年净收益** | **354万** | +| **投资回收期** | **约4.8个月** | + +### 7.2 管理效益 + +| 效益项 | 说明 | +|-------|------| +| **服务可追溯** | 全程录音,服务质量可追溯,纠纷可举证 | +| **管理可视化** | 实时监控大屏,管理者实时掌握运营状态 | +| **决策有依据** | 话务数据积累,为排班、扩容提供数据支撑 | +| **流程标准化** | IVR+机器人固化服务流程,服务质量稳定 | + +### 7.3 客户效益 + +| 效益项 | 说明 | +|-------|------| +| **接通率提升** | 专业排队机制,减少占线挂断 | +| **等待时间缩短** | IVR分流+智能分配,平均等待<30秒 | +| **服务体验提升** | 来电自动识别,坐席了解客户历史,避免重复描述 | +| **服务时间扩展** | 7×24小时服务,紧急需求随时响应 | + +--- + +## 第八章 项目实施计划 + +### 8.1 项目周期 + +**总工期:4个月(17周)** + +### 8.2 阶段划分 + +``` + 1月 2月 3月 4月 5月 + |----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----| +W1 W2 W3 W4 W5 W6 W7 W8 W9 W10 W11 W12 W13 W14 W15 W16 W17 W18 + +[需求设计 ] + [环境搭建 ] + [========= 核心功能开发 =========] + [=== 高级功能开发 ===] + [集成联调] + [测试优化] + [试运行][上线] +``` + +### 8.3 里程碑计划 + +| 里程碑 | 计划时间 | 交付物 | 验收标准 | +|-------|---------|-------|---------| +| **M1: 需求确认** | 第2周末 | 需求规格说明书、技术方案 | 评审通过 | +| **M2: 环境就绪** | 第4周末 | 硬件到位、基础环境部署完成 | FreeSWITCH可接打电话 | +| **M3: 核心功能完成** | 第10周末 | 呼入呼出、ACD、坐席管理、API | 核心流程跑通 | +| **M4: 全功能完成** | 第14周末 | 外呼、机器人、监控大屏 | 全部功能可用 | +| **M5: 联调测试完成** | 第16周末 | CRM集成完成、测试报告 | 全流程联调通过 | +| **M6: 试运行完成** | 第18周末 | 试运行报告 | 问题收敛,达到上线标准 | +| **M7: 正式上线** | 第19周 | 上线报告 | 系统平稳运行 | + +### 8.4 详细进度计划 + +| 阶段 | 时间 | 主要任务 | 交付物 | +|------|------|---------|--------| +| **一、需求设计** | W1-W2 | 需求评审、架构设计、接口设计、详细设计 | 技术方案、接口文档、设计文档 | +| **二、环境搭建** | W3-W4 | 硬件采购到货、网络部署、FreeSWITCH安装、线路对接 | 基础环境就绪 | +| **三、核心开发** | W5-W10 | CTI中间件、API服务、坐席管理、ACD、录音、软电话条 | 核心功能可用 | +| **四、高级开发** | W11-W14 | 预测式外呼、机器人模块、IVR语音识别、监控大屏 | 高级功能可用 | +| **五、集成联调** | W15-W16 | CRM对接、全流程联调、高可用测试 | 联调通过 | +| **六、测试优化** | W16-W17 | 功能测试、性能测试、安全测试、Bug修复 | 测试报告 | +| **七、试运行** | W18 | 小范围上线、问题收集 | 试运行报告 | +| **八、正式上线** | W19 | 全面上线、监控保障 | 上线完成 | + +--- + +## 第九章 项目组织架构 + +### 9.1 组织架构图 + +``` + ┌─────────────────┐ + │ 项目发起人 │ + │ (公司高层) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ 项目经理 │ + │ (总体负责) │ + └────────┬────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 技术开发组 │ │ 业务需求组 │ │ 测试实施组 │ +├───────────────┤ ├───────────────┤ ├───────────────┤ +│ · 架构师 │ │ · 业务分析师 │ │ · 测试工程师 │ +│ · 后端开发 │ │ · 产品经理 │ │ · 实施工程师 │ +│ · 前端开发 │ │ · 业务对接人 │ │ · 运维工程师 │ +│ · FreeSWITCH │ │ │ │ │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 9.2 角色职责 + +| 角色 | 人数 | 职责 | +|------|------|------| +| **项目发起人** | 1 | 项目决策、资源协调、重大问题决策 | +| **项目经理** | 1 | 项目整体管理、进度控制、风险管理、对外沟通 | +| **架构师** | 1 | 技术架构设计、技术选型、技术难点攻关 | +| **后端开发** | 3-4 | CTI中间件、API服务、外呼引擎、机器人模块开发 | +| **前端开发** | 1-2 | 软电话条、监控大屏开发 | +| **FreeSWITCH工程师** | 1-2 | FreeSWITCH配置、IVR开发、ESL对接 | +| **业务分析师** | 1 | 需求分析、业务流程梳理 | +| **测试工程师** | 1-2 | 测试用例、功能测试、性能测试 | +| **实施工程师** | 1 | 环境部署、系统上线 | +| **运维工程师** | 1 | 系统运维、监控告警 | +| **合计** | **12-16人** | | + +### 9.3 沟通机制 + +| 会议类型 | 频率 | 参与人 | 内容 | +|---------|------|-------|------| +| **项目周会** | 每周一次 | 全体成员 | 进度汇报、问题协调、下周计划 | +| **每日站会** | 每日 | 开发+测试 | 当日任务、阻塞问题 | +| **技术评审会** | 按需 | 技术组 | 技术方案评审、代码评审 | +| **里程碑汇报** | 每里程碑 | 项目经理+发起人 | 阶段成果、风险汇报 | +| **上线评审会** | 上线前 | 全体+业务方 | 上线准备度检查 | + +--- + +## 第十章 风险分析与应对 + +### 10.1 风险清单 + +| 风险编号 | 风险描述 | 可能性 | 影响程度 | 风险等级 | +|---------|---------|--------|---------|---------| +| R01 | FreeSWITCH技术门槛高,团队经验不足 | 高 | 高 | **高** | +| R02 | 运营商线路对接周期长 | 中 | 高 | **高** | +| R03 | CRM系统对接复杂度超预期 | 中 | 中 | **中** | +| R04 | ASR识别效果不及预期 | 中 | 中 | **中** | +| R05 | 硬件采购/到货延迟 | 低 | 高 | **中** | +| R06 | 并发性能不达标 | 低 | 高 | **中** | +| R07 | 关键人员离职 | 低 | 高 | **中** | +| R08 | 需求变更频繁 | 中 | 中 | **中** | + +### 10.2 应对措施 + +| 风险编号 | 应对措施 | 责任人 | +|---------|---------|--------| +| **R01** | 1. 项目前期组织FreeSWITCH专项培训
2. 考虑引入外部FreeSWITCH顾问支持
3. 预留20%时间缓冲 | 项目经理 | +| **R02** | 1. 提前2个月启动线路申请
2. 准备备选运营商
3. 先用测试线路开发,并行对接正式线路 | 项目经理 | +| **R03** | 1. 需求阶段明确接口规范
2. 预留接口联调时间
3. CRM团队提前介入 | 架构师 | +| **R04** | 1. 提前进行ASR效果测试
2. 准备热词优化方案
3. 设计降级策略(按键兜底) | 技术组 | +| **R05** | 1. 提前下单采购
2. 准备备选供应商
3. 先用云主机开发 | 项目经理 | +| **R06** | 1. 上线前进行压力测试
2. 准备扩容方案
3. 架构支持水平扩展 | 架构师 | +| **R07** | 1. 知识文档化
2. 关键模块双人备份
3. 提前储备人员 | 项目经理 | +| **R08** | 1. 需求基线管理
2. 变更走评审流程
3. 评估变更影响后决策 | 产品经理 | + +### 10.3 风险监控 + +| 风险等级 | 监控频率 | 汇报层级 | +|---------|---------|---------| +| **高** | 每周跟踪 | 项目发起人 | +| **中** | 双周跟踪 | 项目经理 | +| **低** | 月度跟踪 | 项目组 | + +--- + +## 第十一章 结论与建议 + +### 11.1 结论 + +经过全面分析,本项目具备以下条件: + +| 维度 | 评估结论 | +|------|---------| +| **必要性** | ✅ 业务发展迫切需要,现有模式已无法支撑 | +| **可行性** | ✅ 技术方案成熟,团队具备基本能力 | +| **经济性** | ✅ 投资回报期短(约4.8个月),效益显著 | +| **风险性** | ⚠️ 存在技术风险,但可通过措施控制 | + +**综合结论:项目具备立项条件,建议批准立项。** + +### 11.2 建议 + +1. **尽快启动**:业务增长迅速,建议尽快批准立项,启动实施 +2. **技术准备**:建议在项目正式启动前,组织FreeSWITCH技术培训 +3. **线路先行**:建议立即启动运营商线路申请,避免成为关键路径 +4. **分步上线**:建议优先上线家政业务,验证后再推广至道路救援 +5. **预留缓冲**:建议预留10%-20%预算和时间缓冲,应对不确定性 +6. **运维同步**:建议运维人员全程参与,确保上线后平稳运行 + +### 11.3 审批意见 + +| 审批环节 | 审批人 | 日期 | 意见 | +|---------|-------|------|------| +| **技术评审** | | | | +| **财务评审** | | | | +| **业务评审** | | | | +| **最终审批** | | | | + +--- + +## 附件清单 + +| 附件编号 | 附件名称 | 说明 | +|---------|---------|------| +| 附件1 | 《呼叫中心系统需求规格说明书》 | 详细需求文档 | +| 附件2 | 硬件选型参考清单 | 硬件详细配置及备选品牌 | +| 附件3 | 运营商线路资费对比 | 各运营商资费方案对比 | +| 附件4 | FreeSWITCH技术评估报告 | 技术可行性详细分析(如有) | + +--- + +**文档结束** diff --git a/cc-center/呼叫中心系统开发方案.txt b/cc-center/呼叫中心系统开发方案.txt new file mode 100644 index 0000000..c7ee270 --- /dev/null +++ b/cc-center/呼叫中心系统开发方案.txt @@ -0,0 +1,1321 @@ +**文档版本**:V1.0 +**编制日期**:2024年1月 +**项目周期**:4个月(17周) + +--- + +## 一、开发方案概述 + +### 1.1 项目背景回顾 + +| 项目要素 | 内容 | +|---------|------| +| 业务范围 | 道路救援、家政服务、代驾服务 | +| 坐席规模 | 150坐席 | +| 日话务量 | 50,000通 | +| 技术路线 | 自研(基于FreeSWITCH) | +| 部署方式 | 私有化 + 主备高可用 | + +### 1.2 开发目标 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 系统能力目标 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ ✓ 150坐席并发 ✓ 120路并发通话 ✓ 99.9%可用性 │ +│ ✓ 7×24小时服务 ✓ 故障30秒内切换 ✓ API响应<200ms │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、系统架构设计 + +### 2.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 外部系统 │ +│ CRM系统 业务系统 阿里云语音 │ +└──────────┬───────────────────────┬────────────────────┬─────────────────┘ + │ │ │ + │ REST API / WebSocket │ + │ │ │ +┌──────────▼───────────────────────▼────────────────────▼─────────────────┐ +│ CTI中间件层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ API网关 │ │ 坐席服务 │ │ 呼叫服务 │ │ 外呼引擎 │ │ +│ │ (Gateway) │ │ (Agent) │ │ (Call) │ │ (Dialer) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ ACD服务 │ │ 队列服务 │ │ 监控服务 │ │ 机器人服务 │ │ +│ │ (ACD) │ │ (Queue) │ │ (Monitor) │ │ (Robot) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ ESL连接池 │ │ 事件分发器 │ │ +│ │ (ESL Pool) │ │ (Dispatcher)│ │ +│ └──────┬──────┘ └──────┬──────┘ │ +└─────────┼────────────────┼──────────────────────────────────────────────┘ + │ │ + │ ESL协议 │ + │ │ +┌─────────▼────────────────▼──────────────────────────────────────────────┐ +│ FreeSWITCH 媒体层 │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │mod_sofia│ │mod_dptools│ │mod_record│ │mod_lua │ │mod_event│ │ │ +│ │ │ (SIP) │ │(Dialplan)│ │ (录音) │ │ (IVR) │ │ (ESL) │ │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ FreeSWITCH Core │ +└─────────────────────────────────┬───────────────────────────────────────┘ + │ + SIP Trunk + │ +┌─────────────────────────────────▼───────────────────────────────────────┐ +│ 运营商网络 │ +│ 400号码 / SIP中继线路 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 技术选型 + +| 层级 | 组件 | 技术选型 | 说明 | +|------|------|---------|------| +| **媒体层** | 软交换 | FreeSWITCH 1.10.x | 开源、稳定、功能完善 | +| | SIP协议 | mod_sofia | FreeSWITCH内置 | +| | IVR脚本 | Lua | 灵活、性能好 | +| **中间件层** | 开发语言 | Java 17 + Spring Boot 3 | 团队熟悉、生态成熟 | +| | ESL客户端 | 自研(基于Netty) | 更好的连接管理 | +| | API框架 | Spring WebFlux | 响应式、高性能 | +| | WebSocket | Spring WebSocket | 事件实时推送 | +| **数据层** | 关系数据库 | MySQL 8.0 | 主从复制、读写分离 | +| | 缓存 | Redis 7.0 | Sentinel高可用 | +| | 消息队列 | RabbitMQ | 异步解耦 | +| | 文件存储 | MinIO / NAS | 录音存储 | +| **前端** | 软电话条 | Vue 3 + TypeScript | 组件化、类型安全 | +| | 监控大屏 | Vue 3 + ECharts | 数据可视化 | +| **运维** | 容器化 | Docker + Docker Compose | 简化部署 | +| | 监控 | Prometheus + Grafana | 指标监控 | +| | 日志 | ELK Stack | 日志收集分析 | +| **外部服务** | ASR/TTS | 阿里云智能语音 | 按需调用 | +| | NLU | 指定对话平台 | 意图识别 | + +### 2.3 模块划分 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 系统模块划分 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ callcenter-platform │ │ +│ │ (父工程) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────┼──────────────────────────┐ │ +│ │ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ common │ │ esl │ │ core │ │ api │ │ web │ │ +│ │ (公共) │ │(ESL封装)│ │(核心业务)│ │(REST API)│ │(前端) │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ │ │ │ │ │ +│ └──────────────┴───────────┴───────────┴──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ gateway │ │ +│ │ (启动入口) │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**模块职责:** + +| 模块 | 职责 | 主要内容 | +|------|------|---------| +| **common** | 公共组件 | 工具类、常量、异常、通用模型 | +| **esl** | ESL封装 | ESL连接管理、命令封装、事件解析 | +| **core** | 核心业务 | 坐席管理、呼叫控制、ACD、队列、外呼、录音 | +| **api** | REST接口 | 对外API、WebSocket推送 | +| **web** | 前端工程 | 软电话条、监控大屏 | +| **gateway** | 应用入口 | 配置、启动类 | + +--- + +## 三、功能模块设计 + +### 3.1 模块清单与优先级 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 功能模块优先级 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ P0 - 核心功能(必须完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ ESL连接管理 ☐ 坐席签入签出 ☐ 呼入呼出控制 │ │ +│ │ ☐ ACD话务分配 ☐ 技能组管理 ☐ 排队等待 │ │ +│ │ ☐ 全程录音 ☐ 软电话条 ☐ API接口 │ │ +│ │ ☐ 事件推送 ☐ CRM集成 ☐ 基础IVR │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ P1 - 重要功能(应该完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ 预测式外呼 ☐ 机器人外呼 ☐ 语音识别IVR │ │ +│ │ ☐ 呼入机器人 ☐ 班长监控 ☐ 实时监控大屏 │ │ +│ │ ☐ 溢出策略 ☐ 主备高可用 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ P2 - 增强功能(尽量完成) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ☐ VIP优先 ☐ 黑名单 ☐ 满意度调查 │ │ +│ │ ☐ 多轮对话 ☐ 话务预测 ☐ 密语功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 核心模块设计 + +#### 3.2.1 ESL模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ESL模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslConnectionManager │ │ +│ │ (连接管理器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - connect() 建立ESL连接 │ │ +│ │ - disconnect() 断开连接 │ │ +│ │ - reconnect() 重连机制 │ │ +│ │ - heartbeat() 心跳检测 │ │ +│ │ - getConnection() 获取连接 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslCommandExecutor │ │ +│ │ (命令执行器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - api(cmd, args) 同步API命令 │ │ +│ │ - bgapi(cmd, args) 异步API命令 │ │ +│ │ - sendMsg(uuid, app) 发送消息 │ │ +│ │ - originate(...) 发起呼叫 │ │ +│ │ - bridge(uuid1, uuid2) 桥接通道 │ │ +│ │ - hangup(uuid) 挂断通话 │ │ +│ │ - transfer(uuid, dest) 转接 │ │ +│ │ - hold/unhold(uuid) 保持/取回 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ EslEventDispatcher │ │ +│ │ (事件分发器) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - subscribe(events) 订阅事件 │ │ +│ │ - addHandler(handler) 注册处理器 │ │ +│ │ - dispatch(event) 分发事件 │ │ +│ │ - filter(uuid) 过滤事件 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 关键事件处理: │ +│ ┌────────────────┬────────────────────────────────────────────────┐ │ +│ │ CHANNEL_CREATE │ → 创建呼叫记录,呼入触发ACD排队 │ │ +│ │ CHANNEL_ANSWER │ → 更新状态为已接听,开始计时 │ │ +│ │ CHANNEL_BRIDGE │ → 更新状态为桥接,记录桥接信息 │ │ +│ │ CHANNEL_HANGUP │ → 更新坐席状态,生成话单 │ │ +│ │ DTMF │ → IVR按键处理,满意度调查收集 │ │ +│ └────────────────┴────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.2 坐席管理模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 坐席管理模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 坐席状态机: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────┐ │ │ +│ │ │ OFFLINE │ ◄────────────────────────┐ │ │ +│ │ └────┬────┘ │ │ │ +│ │ │ signIn │ signOut │ │ +│ │ ▼ │ │ │ +│ │ ┌─────────┐ ready ┌────┴────┐ │ │ +│ │ ┌───►│NOT_READY│─────────────────────►│ READY │◄───┐ │ │ +│ │ │ └─────────┘ └────┬────┘ │ │ │ +│ │ │ ▲ │ │ │ │ +│ │ wrapUp │ │ notReady │ assign │ │ │ +│ │ timeout│ │ ▼ │ │ │ +│ │ │ ┌─────────┐ ┌─────────┐ │ │ │ +│ │ │ │ PAUSED │ │ RINGING │ │ │ │ +│ │ │ │ (小休) │ └────┬────┘ │ │ │ +│ │ │ └─────────┘ │ │ │ │ +│ │ │ │ answer │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌─────────┐ │ │ │ +│ │ │ │ BUSY │ │ │ │ +│ │ │ │ (通话中) │ │ │ │ +│ │ │ └────┬────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ hangup │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌─────────┐ │ │ │ +│ │ └────────────────────────────────────│ WRAP_UP │────┘ │ │ +│ │ │ (后处理) │ │ │ +│ │ └─────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据模型: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Agent { │ │ +│ │ id: String // 坐席ID │ │ +│ │ name: String // 坐席姓名 │ │ +│ │ extension: String // 分机号 │ │ +│ │ skillGroups: List // 技能组列表 │ │ +│ │ state: AgentState // 当前状态 │ │ +│ │ stateTime: DateTime // 状态开始时间 │ │ +│ │ currentCallUuid: String // 当前通话UUID │ │ +│ │ signInTime: DateTime // 签入时间 │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 存储设计(Redis): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ agent:{agentId} → Hash 坐席详细信息 │ │ +│ │ agents:online → Set 在线坐席ID集合 │ │ +│ │ skillgroup:{name}:agents → Set 技能组坐席集合 │ │ +│ │ agent:{agentId}:idle_time → String 空闲开始时间 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.3 ACD模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ACD模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 分配流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 来电 ──► IVR ──► 确定技能组 ──► 入队 ──► 分配 ──► 振铃 ──► 接听 │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌──────────┐ │ │ +│ │ │ 排队等待 │ │ │ +│ │ │ 播放音乐 │ │ │ +│ │ └──────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 分配策略(按优先级): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 1. 历史服务优先 │ │ +│ │ └─► 查找该客户上次服务的坐席,若空闲则分配 │ │ +│ │ │ │ +│ │ 2. 最长空闲优先 │ │ +│ │ └─► 选择空闲时间最长的坐席 │ │ +│ │ │ │ +│ │ 3. 轮询分配 │ │ +│ │ └─► 按顺序轮流分配 │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 溢出策略: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 条件:技能组无空闲坐席 + 等待时间 > 阈值(如60秒) │ │ +│ │ │ │ +│ │ 溢出配置: │ │ +│ │ ┌─────────────┬─────────────┬───────────┐ │ │ +│ │ │ 源技能组 │ 目标技能组 │ 等待阈值 │ │ │ +│ │ ├─────────────┼─────────────┼───────────┤ │ │ +│ │ │ rescue │ default │ 60秒 │ │ │ +│ │ │ housekeeping│ default │ 90秒 │ │ │ +│ │ │ driver │ default │ 60秒 │ │ │ +│ │ └─────────────┴─────────────┴───────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 队列数据结构(Redis Sorted Set): │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ queue:{skillGroup} │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐│ │ +│ │ │ Score = 优先级 × 10^9 + 入队时间戳 ││ │ +│ │ │ Member = QueueItem(callUuid, callerNumber, enqueueTime...) ││ │ +│ │ └──────────────────────────────────────────────────────────────┘│ │ +│ │ │ │ +│ │ 优先级越小越优先,同优先级按入队时间排序 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.4 呼叫控制模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 呼叫控制模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 呼叫状态机: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ CREATED │────►│ RINGING │────►│ ANSWERED│────►│ BRIDGED │ │ │ +│ │ └─────────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ +│ │ │ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ ▼ │ │ +│ │ │ │ ┌─────────┐ │ │ +│ │ │ │ │ HELD │ │ │ +│ │ │ │ └────┬────┘ │ │ +│ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ │ │ +│ │ ┌────────────────────────────────────────┐ │ │ +│ │ │ HANGUP │ │ │ +│ │ └────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 核心操作: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 点击外呼: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 1. 检查坐席状态(必须READY) │ │ │ +│ │ │ 2. 更新坐席状态为BUSY │ │ │ +│ │ │ 3. originate呼叫坐席分机 │ │ │ +│ │ │ 4. 坐席接听后bridge呼叫客户 │ │ │ +│ │ │ 5. 开始录音 │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 转接: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 盲转:uuid_transfer直接转接 │ │ │ +│ │ │ 咨询转:先hold,originate咨询方,确认后bridge │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 三方通话: │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 1. 创建会议室 conference │ │ │ +│ │ │ 2. 将原通话双方加入会议 │ │ │ +│ │ │ 3. originate第三方加入会议 │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据模型: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Call { │ │ +│ │ uuid: String // 呼叫唯一标识 │ │ +│ │ direction: Enum // INBOUND/OUTBOUND │ │ +│ │ callerNumber: String // 主叫号码 │ │ +│ │ calleeNumber: String // 被叫号码 │ │ +│ │ state: CallState // 呼叫状态 │ │ +│ │ agentId: String // 服务坐席 │ │ +│ │ skillGroup: String // 技能组 │ │ +│ │ createTime: DateTime // 创建时间 │ │ +│ │ answerTime: DateTime // 接听时间 │ │ +│ │ hangupTime: DateTime // 挂断时间 │ │ +│ │ duration: Long // 通话时长(秒) │ │ +│ │ recordingPath: String // 录音路径 │ │ +│ │ hangupCause: String // 挂断原因 │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.2.5 外呼模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 外呼模块设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 预测式外呼流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 业务系统 ──► 创建任务 ──► 导入号码 ──► 启动任务 │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ 外呼循环 │ │ │ +│ │ └───────┬───────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────────────────────────────┴───────────────┐ │ │ +│ │ │ │ │ │ +│ │ ▼ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ 计算拨打数 │───►│ 批量拨打 │───►│ 接通转坐席 │ │ │ │ +│ │ │(预测算法) │ │ │ │ │ │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ +│ │ ▲ │ │ │ │ +│ │ │ │ │ │ │ +│ │ └──────────────────────────────────────┘ │ │ │ +│ │ 循环 │ │ │ +│ │ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 预测算法: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 目标:让坐席接听时刚好有客户接通,减少等待 │ │ +│ │ │ │ +│ │ 预测拨打数 = 空闲坐席数 / 历史接通率 × 超拨系数 │ │ +│ │ │ │ +│ │ 示例:空闲5人,接通率30%,超拨系数1.2 │ │ +│ │ 拨打数 = 5 / 0.3 × 1.2 ≈ 20 │ │ +│ │ │ │ +│ │ 动态调整:根据实时接通率动态调整拨打速度 │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 机器人外呼流程: │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 创建任务 │───►│ 生成TTS │───►│ 批量拨打 │───►│ 播放语音 │ │ │ +│ │ │ (含话术) │ │ 音频文件 │ │ │ │ │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └────┬─────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────┴───────┐ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌──────────┐ ┌──────────┐│ +│ │ │ 纯播报 │ │ 收集按键 ││ +│ │ │ 挂断 │ │ 回传结果 ││ +│ │ └──────────┘ └──────────┘│ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 接口设计 + +#### 3.3.1 API接口清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ REST API 设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 坐席接口:/api/v1/agent │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /signin 坐席签入 │ │ +│ │ POST /signout 坐席签出 │ │ +│ │ POST /ready 置闲 │ │ +│ │ POST /not-ready 置忙 │ │ +│ │ POST /pause 小休 │ │ +│ │ GET /{agentId}/status 查询坐席状态 │ │ +│ │ GET /list 查询坐席列表 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 呼叫接口:/api/v1/call │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /originate 发起外呼 │ │ +│ │ POST /{uuid}/answer 接听 │ │ +│ │ POST /{uuid}/hangup 挂断 │ │ +│ │ POST /{uuid}/hold 保持 │ │ +│ │ POST /{uuid}/unhold 取回 │ │ +│ │ POST /{uuid}/mute 静音 │ │ +│ │ POST /{uuid}/unmute 取消静音 │ │ +│ │ POST /{uuid}/transfer 转接 │ │ +│ │ POST /{uuid}/conference 三方通话 │ │ +│ │ GET /{uuid} 查询呼叫信息 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 外呼任务接口:/api/v1/task │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /predictive 创建预测式外呼任务 │ │ +│ │ POST /robot 创建机器人外呼任务 │ │ +│ │ POST /{taskId}/start 启动任务 │ │ +│ │ POST /{taskId}/pause 暂停任务 │ │ +│ │ POST /{taskId}/resume 恢复任务 │ │ +│ │ POST /{taskId}/stop 停止任务 │ │ +│ │ GET /{taskId}/status 查询任务状态 │ │ +│ │ GET /{taskId}/result 查询任务结果 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 监控接口:/api/v1/monitor │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ POST /spy 监听 │ │ +│ │ POST /barge 强插 │ │ +│ │ POST /hangup 强拆 │ │ +│ │ GET /realtime 实时统计数据 │ │ +│ │ GET /queue/{skillGroup} 队列状态 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 录音接口:/api/v1/recording │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ GET /list 录音列表查询 │ │ +│ │ GET /{uuid}/url 获取录音下载地址 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3.3.2 WebSocket事件推送 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WebSocket 事件设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 连接地址:ws://host:port/ws/events?token={token} │ +│ │ +│ 事件格式: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "eventType": "CALL_RINGING", │ │ +│ │ "timestamp": 1704067200000, │ │ +│ │ "data": { │ │ +│ │ "uuid": "xxx", │ │ +│ │ "callerNumber": "13800138000", │ │ +│ │ "calleeNumber": "4001234567", │ │ +│ │ ... │ │ +│ │ } │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 事件类型: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 呼叫事件 │ │ +│ │ ├── CALL_RINGING 来电振铃 │ │ +│ │ ├── CALL_ANSWERED 通话接听 │ │ +│ │ ├── CALL_BRIDGED 双方接通 │ │ +│ │ ├── CALL_HANGUP 通话挂断 │ │ +│ │ ├── CALL_TRANSFER 通话转接 │ │ +│ │ └── CALL_HOLD 通话保持 │ │ +│ │ │ │ +│ │ 坐席事件 │ │ +│ │ ├── AGENT_STATE_CHANGE 坐席状态变更 │ │ +│ │ ├── AGENT_SIGNIN 坐席签入 │ │ +│ │ └── AGENT_SIGNOUT 坐席签出 │ │ +│ │ │ │ +│ │ 话单事件 │ │ +│ │ └── CDR_GENERATED 话单生成 │ │ +│ │ │ │ +│ │ DTMF事件 │ │ +│ │ └── DTMF_RECEIVED 收到按键 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.4 数据库设计 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据库设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ cc_agent (坐席表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ agent_id VARCHAR(32) 坐席工号 │ │ +│ │ agent_name VARCHAR(64) 坐席姓名 │ │ +│ │ extension VARCHAR(16) 分机号 │ │ +│ │ password VARCHAR(128) 密码 │ │ +│ │ skill_groups VARCHAR(256) 技能组(逗号分隔) │ │ +│ │ status TINYINT 状态(0禁用1启用) │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ │ update_time DATETIME 更新时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_skill_group (技能组表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ group_code VARCHAR(32) 技能组编码 │ │ +│ │ group_name VARCHAR(64) 技能组名称 │ │ +│ │ overflow_group VARCHAR(32) 溢出技能组 │ │ +│ │ overflow_seconds INT 溢出等待秒数 │ │ +│ │ status TINYINT 状态 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_cdr (话单表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ uuid VARCHAR(64) 呼叫UUID │ │ +│ │ direction VARCHAR(16) 呼叫方向 │ │ +│ │ caller_number VARCHAR(32) 主叫号码 │ │ +│ │ callee_number VARCHAR(32) 被叫号码 │ │ +│ │ agent_id VARCHAR(32) 坐席ID │ │ +│ │ skill_group VARCHAR(32) 技能组 │ │ +│ │ queue_time INT 排队时长(秒) │ │ +│ │ ring_time INT 振铃时长(秒) │ │ +│ │ talk_time INT 通话时长(秒) │ │ +│ │ start_time DATETIME 开始时间 │ │ +│ │ answer_time DATETIME 接听时间 │ │ +│ │ end_time DATETIME 结束时间 │ │ +│ │ hangup_cause VARCHAR(32) 挂断原因 │ │ +│ │ recording_path VARCHAR(256) 录音路径 │ │ +│ │ business_type VARCHAR(32) 业务类型 │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ │ INDEX idx_start_time (start_time) │ │ +│ │ INDEX idx_agent (agent_id, start_time) │ │ +│ │ INDEX idx_caller (caller_number, start_time) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_dial_task (外呼任务表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ task_id VARCHAR(64) 任务ID │ │ +│ │ task_name VARCHAR(128) 任务名称 │ │ +│ │ task_type VARCHAR(16) 类型(PREDICTIVE/ROBOT) │ │ +│ │ skill_group VARCHAR(32) 技能组 │ │ +│ │ caller_id VARCHAR(32) 外显号码 │ │ +│ │ total_count INT 总号码数 │ │ +│ │ dialed_count INT 已拨打数 │ │ +│ │ connected_count INT 接通数 │ │ +│ │ state VARCHAR(16) 状态 │ │ +│ │ start_time DATETIME 开始时间 │ │ +│ │ end_time DATETIME 结束时间 │ │ +│ │ create_time DATETIME 创建时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ cc_dial_detail (外呼明细表) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ id BIGINT PRIMARY KEY │ │ +│ │ task_id VARCHAR(64) 任务ID │ │ +│ │ phone VARCHAR(32) 号码 │ │ +│ │ call_uuid VARCHAR(64) 呼叫UUID │ │ +│ │ status VARCHAR(16) 状态 │ │ +│ │ dial_time DATETIME 拨打时间 │ │ +│ │ answer_time DATETIME 接听时间 │ │ +│ │ hangup_time DATETIME 挂断时间 │ │ +│ │ dtmf_result VARCHAR(16) 按键结果 │ │ +│ │ retry_count INT 重试次数 │ │ +│ │ INDEX idx_task (task_id) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、开发计划 + +### 4.1 阶段划分 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 开发阶段划分(17周) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第一阶段:基础环境与核心框架(Week 1-4) │ │ +│ │ │ │ +│ │ Week 1-2: 环境搭建 + 技术预研 │ │ +│ │ Week 3-4: ESL模块 + 基础框架 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第二阶段:核心功能开发(Week 5-10) │ │ +│ │ │ │ +│ │ Week 5-6: 坐席管理 + 呼叫控制 │ │ +│ │ Week 7-8: ACD分配 + 排队等待 │ │ +│ │ Week 9-10: 录音 + 软电话条 + CRM集成 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第三阶段:高级功能开发(Week 11-14) │ │ +│ │ │ │ +│ │ Week 11-12: 预测式外呼 + 机器人外呼 │ │ +│ │ Week 13: IVR + 呼入机器人 │ │ +│ │ Week 14: 监控大屏 + 班长监控 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 第四阶段:集成测试与上线(Week 15-17) │ │ +│ │ │ │ +│ │ Week 15: 集成联调 + 高可用部署 │ │ +│ │ Week 16: 测试 + Bug修复 │ │ +│ │ Week 17: 试运行 + 正式上线 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 详细开发计划 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 详细开发计划 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第一阶段:基础环境与核心框架(Week 1-4) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 1: 环境准备 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 服务器环境准备(开发、测试环境) │ │ +│ │ □ FreeSWITCH安装配置 │ │ +│ │ □ 开发环境搭建(IDE、Git、Maven) │ │ +│ │ □ MySQL、Redis环境搭建 │ │ +│ │ □ 测试SIP话机注册与基础通话 │ │ +│ │ │ │ +│ │ 交付物:环境部署文档、FreeSWITCH基础配置 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 2: 技术预研 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ ESL协议学习与Demo开发 │ │ +│ │ □ FreeSWITCH Dialplan配置学习 │ │ +│ │ □ Lua IVR脚本编写测试 │ │ +│ │ □ 项目工程结构搭建 │ │ +│ │ □ 技术方案评审 │ │ +│ │ │ │ +│ │ 交付物:技术预研报告、Demo代码 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 3-4: ESL模块开发 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ ESL连接管理(连接池、心跳、重连) │ │ +│ │ □ ESL命令封装(API、SendMsg) │ │ +│ │ □ 事件订阅与分发机制 │ │ +│ │ □ 常用命令测试验证 │ │ +│ │ □ 基础API框架搭建 │ │ +│ │ □ WebSocket服务搭建 │ │ +│ │ │ │ +│ │ 交付物:ESL模块代码、单元测试 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第二阶段:核心功能开发(Week 5-10) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 5-6: 坐席管理 + 呼叫控制 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 坐席数据模型设计 │ │ +│ │ □ 坐席签入/签出 │ │ +│ │ □ 坐席状态管理(置闲、置忙、小休) │ │ +│ │ □ 技能组管理 │ │ +│ │ □ 点击外呼功能 │ │ +│ │ □ 接听/挂断/保持/取回 │ │ +│ │ □ 转接(盲转、咨询转) │ │ +│ │ □ 坐席API接口 │ │ +│ │ □ 呼叫API接口 │ │ +│ │ │ │ +│ │ 交付物:坐席模块、呼叫控制模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 7-8: ACD分配 + 排队等待 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 队列数据结构设计 │ │ +│ │ □ 呼叫入队逻辑 │ │ +│ │ □ 历史服务优先策略 │ │ +│ │ □ 最长空闲优先策略 │ │ +│ │ □ 轮询分配策略 │ │ +│ │ □ 坐席选择与分配 │ │ +│ │ □ 排队等待音乐 │ │ +│ │ □ 溢出策略实现 │ │ +│ │ □ 排队状态查询 │ │ +│ │ │ │ +│ │ 交付物:ACD模块、队列管理模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 9-10: 录音 + 软电话条 + CRM集成 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 录音启动/停止控制 │ │ +│ │ □ 录音文件存储管理 │ │ +│ │ □ 录音查询与下载API │ │ +│ │ □ 软电话条前端开发(Vue组件) │ │ +│ │ □ WebSocket事件接收与处理 │ │ +│ │ □ 软电话条UI(状态、按钮、来电显示) │ │ +│ │ □ CRM弹屏集成(事件推送) │ │ +│ │ □ 话单推送至CRM │ │ +│ │ □ 联调测试 │ │ +│ │ │ │ +│ │ 交付物:录音模块、软电话条组件、CRM集成文档 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第三阶段:高级功能开发(Week 11-14) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 11-12: 预测式外呼 + 机器人外呼 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 外呼任务管理(创建、启动、暂停、停止) │ │ +│ │ □ 号码导入与管理 │ │ +│ │ □ 预测式拨号算法实现 │ │ +│ │ □ 接通后转坐席逻辑 │ │ +│ │ □ 外呼统计(接通率、坐席利用率) │ │ +│ │ □ 阿里云TTS集成 │ │ +│ │ □ 机器人外呼流程(播报、按键收集) │ │ +│ │ □ 外呼结果回传 │ │ +│ │ □ 任务API接口 │ │ +│ │ │ │ +│ │ 交付物:外呼引擎、机器人外呼模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 13: IVR + 呼入机器人 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ IVR Dialplan配置(多业务线) │ │ +│ │ □ 阿里云ASR集成 │ │ +│ │ □ 语音识别导航实现 │ │ +│ │ □ NLU对接(意图识别) │ │ +│ │ □ FAQ问答流程 │ │ +│ │ □ 转人工逻辑 │ │ +│ │ □ Lua脚本开发 │ │ +│ │ │ │ +│ │ 交付物:IVR配置、呼入机器人模块 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 14: 监控大屏 + 班长监控 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 实时数据统计API │ │ +│ │ □ 监控大屏前端开发 │ │ +│ │ □ 坐席状态分布展示 │ │ +│ │ □ 队列状态展示 │ │ +│ │ □ 话务趋势图表 │ │ +│ │ □ 监听功能实现 │ │ +│ │ □ 强插功能实现 │ │ +│ │ □ 强拆功能实现 │ │ +│ │ │ │ +│ │ 交付物:监控大屏、班长监控功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ 第四阶段:集成测试与上线(Week 15-17) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Week 15: 集成联调 + 高可用部署 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ CRM系统全流程联调 │ │ +│ │ □ 业务系统对接联调 │ │ +│ │ □ 运营商线路对接 │ │ +│ │ □ 主备FreeSWITCH部署 │ │ +│ │ □ Keepalived配置 │ │ +│ │ □ 故障切换测试 │ │ +│ │ □ 数据库主从配置 │ │ +│ │ □ Redis Sentinel配置 │ │ +│ │ │ │ +│ │ 交付物:联调测试报告、高可用部署文档 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 16: 测试 + Bug修复 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 功能测试(全流程) │ │ +│ │ □ 性能测试(150坐席并发) │ │ +│ │ □ 压力测试(峰值话务) │ │ +│ │ □ 故障恢复测试 │ │ +│ │ □ Bug修复 │ │ +│ │ □ 性能优化 │ │ +│ │ □ 上线检查清单确认 │ │ +│ │ │ │ +│ │ 交付物:测试报告、Bug清单、优化记录 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Week 17: 试运行 + 正式上线 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ □ 生产环境部署 │ │ +│ │ □ 数据初始化 │ │ +│ │ □ 小范围试运行(一条业务线) │ │ +│ │ □ 问题收集与修复 │ │ +│ │ □ 全量上线 │ │ +│ │ □ 运维交接 │ │ +│ │ □ 用户培训 │ │ +│ │ │ │ +│ │ 交付物:上线报告、运维手册、培训材料 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.3 甘特图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ 项目甘特图 │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 任务 │ W1 │ W2 │ W3 │ W4 │ W5 │ W6 │ W7 │ W8 │ W9 │W10│W11│W12│W13│W14│W15│W16│W17│ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第一阶段 │████│████│████│████│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ 环境搭建 │████│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ 技术预研 │ │████│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ ESL模块开发 │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第二阶段 │ │ │ │ │████│████│████│████│████│███│ │ │ │ │ │ │ │ +│ 坐席管理 │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ +│ 呼叫控制 │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ │ │ +│ ACD分配 │ │ │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ +│ 排队等待 │ │ │ │ │ │ │████│████│ │ │ │ │ │ │ │ │ │ +│ 录音功能 │ │ │ │ │ │ │ │ │████│ │ │ │ │ │ │ │ │ +│ 软电话条 │ │ │ │ │ │ │ │ │████│███│ │ │ │ │ │ │ │ +│ CRM集成 │ │ │ │ │ │ │ │ │ │███│ │ │ │ │ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第三阶段 │ │ │ │ │ │ │ │ │ │ │███│███│███│███│ │ │ │ +│ 预测式外呼 │ │ │ │ │ │ │ │ │ │ │███│███│ │ │ │ │ │ +│ 机器人外呼 │ │ │ │ │ │ │ │ │ │ │███│███│ │ │ │ │ │ +│ IVR+呼入机器人 │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ │ +│ 监控大屏 │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ +│ 班长监控 │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ │ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 第四阶段 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│███│███│ +│ 集成联调 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ +│ 高可用部署 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ │ +│ 测试+Bug修复 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │███│ │ +│ 试运行 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │██ │ +│ 正式上线 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ██│ +│ ─────────────────────────│────│────│────│────│────│────│────│────│────│───│───│───│───│───│───│───│───│ +│ 里程碑 │ │ M1 │ │ M2 │ │ │ │ │ │M3 │ │ │ │M4 │M5 │M6 │M7 │ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M1: 需求确认 │ │ ◆ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M2: 环境就绪 │ │ │ │ ◆ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ M3: 核心功能完成 │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ │ │ │ │ │ +│ M4: 全功能完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ │ +│ M5: 联调测试完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ │ +│ M6: 试运行完成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ │ +│ M7: 正式上线 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ◆ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、团队组织 + +### 5.1 团队配置 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 团队组织架构 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ │ +│ │ 项目经理 │ │ +│ │ (1人) │ │ +│ └──────┬──────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 开发组 │ │ 测试组 │ │ 运维组 │ │ +│ │ (8-10人) │ │ (1-2人) │ │ (1人) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ +│ ├── 架构师 (1人) │ +│ ├── 后端开发 (4-5人) │ +│ ├── 前端开发 (2人) │ +│ └── FreeSWITCH工程师 (1-2人) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 角色职责 + +| 角色 | 人数 | 职责 | 技能要求 | +|------|------|------|---------| +| **项目经理** | 1 | 项目管理、进度控制、风险管理 | 项目管理经验、沟通协调能力 | +| **架构师** | 1 | 架构设计、技术选型、核心代码 | FreeSWITCH、分布式系统 | +| **后端开发** | 4-5 | 业务模块开发、API开发 | Java、Spring Boot、Redis | +| **前端开发** | 2 | 软电话条、监控大屏 | Vue3、TypeScript、WebSocket | +| **FS工程师** | 1-2 | FreeSWITCH配置、Lua脚本 | FreeSWITCH、SIP协议、Lua | +| **测试** | 1-2 | 功能测试、性能测试 | 测试用例设计、性能测试工具 | +| **运维** | 1 | 环境部署、监控运维 | Linux、Docker、运维经验 | + +### 5.3 人员分工 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 开发任务分工 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 架构师 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 整体架构设计 │ │ +│ │ • ESL模块核心代码 │ │ +│ │ • ACD核心算法 │ │ +│ │ • 技术难点攻关 │ │ +│ │ • 代码Review │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发A (资深) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 坐席管理模块 │ │ +│ │ • 呼叫控制模块 │ │ +│ │ • WebSocket推送服务 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发B │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • ACD分配模块 │ │ +│ │ • 队列管理模块 │ │ +│ │ • 溢出策略 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发C │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 预测式外呼模块 │ │ +│ │ • 机器人外呼模块 │ │ +│ │ • TTS集成 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发D │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 录音管理模块 │ │ +│ │ • 监控统计模块 │ │ +│ │ • 班长监控功能 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 后端开发E (可选) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • API接口开发 │ │ +│ │ • CRM集成对接 │ │ +│ │ • 单元测试 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 前端开发A │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 软电话条组件开发 │ │ +│ │ • WebSocket客户端 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 前端开发B │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • 实时监控大屏 │ │ +│ │ • 数据可视化图表 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ FreeSWITCH工程师 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ • FreeSWITCH配置优化 │ │ +│ │ • Dialplan编写 │ │ +│ │ • Lua IVR脚本开发 │ │ +│ │ • 线路对接 │ │ +│ │ • 高可用部署 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、风险控制 + +### 6.1 风险清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 风险清单 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险1: FreeSWITCH技术门槛高 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 高 │ │ +│ │ 影响: 开发进度延迟,功能实现困难 │ │ +│ │ 应对: │ │ +│ │ • Week 1-2 集中学习FreeSWITCH和ESL │ │ +│ │ • 考虑引入外部FreeSWITCH顾问 │ │ +│ │ • 预留20%时间缓冲 │ │ +│ │ • 简化复杂功能,优先实现核心流程 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险2: 运营商线路对接周期长 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 高 │ │ +│ │ 影响: 无法进行真实环境测试,上线延迟 │ │ +│ │ 应对: │ │ +│ │ • 项目启动即开始申请线路 │ │ +│ │ • 开发阶段使用模拟线路/测试线路 │ │ +│ │ • 准备备选运营商 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险3: CRM系统对接复杂 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 集成联调时间延长 │ │ +│ │ 应对: │ │ +│ │ • 提前与CRM团队沟通,明确接口规范 │ │ +│ │ • 提供详细API文档和示例代码 │ │ +│ │ • 预留充足联调时间 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险4: 并发性能不达标 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 系统无法支撑业务量 │ │ +│ │ 应对: │ │ +│ │ • Week 16 进行完整压力测试 │ │ +│ │ • 预先设计水平扩展方案 │ │ +│ │ • 关键代码Review,避免性能瓶颈 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险5: ASR/TTS识别效果不佳 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 机器人/IVR体验差 │ │ +│ │ 应对: │ │ +│ │ • 提前进行ASR效果测试 │ │ +│ │ • 准备热词优化方案 │ │ +│ │ • 设计按键兜底方案 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 风险6: 核心人员离职 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ 等级: 中 │ │ +│ │ 影响: 知识断层,进度受阻 │ │ +│ │ 应对: │ │ +│ │ • 重要模块双人备份 │ │ +│ │ • 代码规范、文档完善 │ │ +│ │ • 定期代码Review和知识分享 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 应急预案 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 应急预案 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 场景1: 开发进度严重滞后 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 核心功能延迟超过2周 │ │ +│ │ 应对措施: │ │ +│ │ • 评估是否可以简化功能范围 │ │ +│ │ • 考虑增加外部资源支持 │ │ +│ │ • 与业务方协商分阶段上线 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 场景2: FreeSWITCH关键问题无法解决 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 技术问题阻塞超过1周 │ │ +│ │ 应对措施: │ │ +│ │ • 联系FreeSWITCH社区或付费支持 │ │ +│ │ • 寻求外部FreeSWITCH专家支援 │ │ +│ │ • 评估替代方案可行性 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 场景3: 上线后系统不稳定 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 触发条件: 故障频发或性能严重不足 │ │ +│ │ 应对措施: │ │ +│ │ • 启用回退方案(如有旧系统) │ │ +│ │ • 降级部分功能,保障核心业务 │ │ +│ │ • 紧急扩容或优化 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、交付物清单 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 项目交付物清单 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 阶段 │ 交付物 │ 负责人 │ +│ ──────────────│───────────────────────────────────────│───────────│ +│ │ │ │ +│ 需求阶段 │ 需求规格说明书 │ 产品 │ +│ │ 接口设计文档 │ 架构师 │ +│ │ 数据库设计文档 │ 架构师 │ +│ │ │ │ +│ 开发阶段 │ 源代码(Git仓库) │ 开发团队 │ +│ │ 单元测试代码 │ 开发团队 │ +│ │ API接口文档 │ 后端开发 │ +│ │ 前端组件文档 │ 前端开发 │ +│ │ │ │ +│ 测试阶段 │ 测试用例文档 │ 测试 │ +│ │ 测试报告 │ 测试 │ +│ │ 性能测试报告 │ 测试 │ +│ │ │ │ +│ 部署阶段 │ 部署手册 │ 运维 │ +│ │ 运维手册 │ 运维 │ +│ │ 高可用配置文档 │ 运维 │ +│ │ │ │ +│ 上线阶段 │ 上线检查清单 │ 项目经理 │ +│ │ 培训材料 │ 产品 │ +│ │ 用户操作手册 │ 产品 │ +│ │ 项目总结报告 │ 项目经理 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 八、总结 + +### 8.1 方案要点总结 + +| 维度 | 要点 | +|------|------| +| **技术架构** | FreeSWITCH + Java中间件 + Vue前端,ESL控制模式 | +| **开发周期** | 4个月(17周),分4阶段 | +| **团队配置** | 11-14人(含项目经理、开发、测试、运维) | +| **核心功能** | 呼入呼出、ACD分配、预测式外呼、机器人、录音、监控 | +| **风险控制** | FreeSWITCH技术预研、线路提前申请、预留缓冲时间 | + +### 8.2 关键成功因素 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 关键成功因素 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 技术准备充分 │ +│ └─► Week 1-2 完成FreeSWITCH学习和技术预研 │ +│ │ +│ 2. 核心功能优先 │ +│ └─► 先完成P0功能,确保基础可用 │ +│ │ +│ 3. 持续集成测试 │ +│ └─► 每完成一个模块立即测试,避免问题堆积 │ +│ │ +│ 4. CRM团队紧密配合 │ +│ └─► 提前沟通接口,并行开发 │ +│ │ +│ 5. 线路提前就位 │ +│ └─► 项目启动即申请运营商线路 │ +│ │ +│ 6. 分阶段上线 │ +│ └─► 先上线一条业务线验证,再全量推广 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 8.3 下一步行动 + +| 序号 | 行动项 | 负责人 | 完成时间 | +|------|-------|-------|---------| +| 1 | 确认团队人员到位 | 项目经理 | 项目启动前 | +| 2 | 采购/准备开发测试环境 | 运维 | Week 1 | +| 3 | 启动运营商线路申请 | 项目经理 | Week 1 | +| 4 | 组织FreeSWITCH技术培训 | 架构师 | Week 1-2 | +| 5 | 与CRM团队召开接口对接会议 | 架构师 | Week 2 | +| 6 | 完成技术方案评审 | 架构师 | Week 2 | + +--- \ No newline at end of file diff --git a/membership/.claude/agents/demand-assessor.md b/membership/.claude/agents/demand-assessor.md new file mode 100644 index 0000000..1cc3ef3 --- /dev/null +++ b/membership/.claude/agents/demand-assessor.md @@ -0,0 +1,203 @@ +你是一个精通 Java 微服务架构的资深技术负责人。 + +请基于以下统一模型评估需求的工作量。 + +============================== +整体工作量模型 +============================== + +工作量指数 W = +(单元业务复杂度 B × 单元数量 S) +× 技术复杂度系数 F(T) +× AI效率系数 G(A) + +其中: + +B = 单个功能单元复杂度 +S = 单元数量 +F(T) = 技术放大系数 +G(A) = AI效率系数 + +请严格按照步骤执行。 + + +==================================================== +第一部分:识别功能单元(规模 S) +==================================================== + +1. 识别最小可重复功能单元 + (例如:接口、页面、报表、定时任务等) + +2. 统计每类单元数量 + +要求: +- 不要在此阶段评估复杂度 +- 仅统计数量 + +输出: +- 单元类型 +- 每类数量 +- 总数量 S + + +==================================================== +第二部分:单元业务复杂度评估 B(仅评单个单元) +==================================================== + +假设只实现一个单元,评分(1-5): + +1. 业务规则数量 +2. 状态流转复杂度 +3. 异常处理复杂度 +4. 边界场景复杂度 +5. 需求不确定性 + +计算: + +B = 五项平均值(保留1位小数) + +注意: +禁止在此阶段考虑单元数量。 + +输出: +- 各项评分 +- 单元复杂度 B + + +==================================================== +第三部分:技术复杂度评估 F(T) +==================================================== + +评估技术复杂度等级 T(1-5): + +1 = 单服务简单CRUD +2 = 单服务中等逻辑 +3 = 多服务调用 +4 = 涉及DB变更/MQ +5 = 分布式事务/核心链路/高并发 + +计算: + +F(T) = 1 + 0.2 × (T - 1) + +输出: +- T值 +- 技术复杂度系数 F(T) +- 技术风险说明 + + +==================================================== +第四部分:AI效率系数 G(A) 计算 +==================================================== + +步骤A:正向评分(1-5分) + +1.需求清晰 +2.规则明确度 +3.可验证性 +4.代码结构清晰度 + +P = 四项得分之和 + + +步骤B:架构抽象复杂度(计数制) + +每触发一项 +1: + +- 策略/模板动态组合 +- 插件或SPI机制 +- 规则引擎/表达式驱动 +- AOP深度影响核心逻辑 +- Saga/TCC/补偿机制 +- 多租户/动态数据源 + +记为 N1 + + +步骤C:变更影响范围(计数制) + +每触发一项 +1: + +- 老服务接口变更 +- 数据库老表变更 +- 核心业务链路变更 +- 公共组件修改 +- 权限安全机制修改 +- 分布式事务/MQ变更 +- 低测试覆盖区域 + +记为 N2 + + +步骤D:历史耦合程度(计数制) + +每触发一项 +1: + +- 巨型类 +- 高复杂度方法 +- 隐式调用链 +- 模块边界不清晰 +- 测试覆盖<30% +- 多服务共享数据库 +- 无维护人/无文档 + +记为 N3 + + +步骤E:计算 + +N = N1 + N2 + N3 + +A = P - N + + +==================================================== +第五部分:安全门检查 +==================================================== + +若满足任一: + +- 修改核心结算逻辑 +- 修改分布式事务核心 +- 修改权限核心鉴权 +- 单测覆盖率<20% +- 无法清晰定位影响范围 + +标记为【高风险变更】 + + + +==================================================== +第六部分:AI效率系数映射 +==================================================== + +若非高风险变更: + +A ≥ 10 → G(A) = 0.55 +8 ≤ A < 10 → G(A) = 0.65 +6 ≤ A < 8 → G(A) = 0.75 +3 ≤ A < 6 → G(A) = 0.85 +1 ≤ A < 3 → G(A) = 0.95 +A ≤ 0 → G(A) = 1.0 + + +==================================================== +第七部分:最终工作量计算 +==================================================== + +计算: + +基础工作量 = B × S +技术放大后 = 基础工作量 × F(T) +最终工作量指数 W = 技术放大后 × G(A) + +请输出: + +1. 单元数量 S +2. 单元复杂度 B +3. 技术系数 F(T) +4. AI效率系数 G(A) +5. 最终工作量指数 W +6. 综合风险等级(低/中/高) +7. 是否建议拆分需求 +8. AI建议参与方式 \ No newline at end of file diff --git a/membership/.claude/skills/pmassist/SKILL.md b/membership/.claude/skills/pmassist/SKILL.md new file mode 100644 index 0000000..768f03b --- /dev/null +++ b/membership/.claude/skills/pmassist/SKILL.md @@ -0,0 +1,151 @@ +--- +name: pmassist +description: | + 产品文档协作与缺陷分析助手。用于创建或修订 PRD、FRD、DAR(Defect Analysis Report 缺陷分析报告)等产品类文档。 + 支持快速起草、标准迭代、严格定稿三种模式。默认优先保证交付效率,再按需补齐证据、留痕与原型。 +--- + +# pmassist + +## 核心原则 +- **分文档类型执行**: PRD、FRD、DAR 使用不同严格度。 +- **分阶段执行**: 草案、迭代、定稿采用不同要求,不默认一开始即使用最严格流程。 +- **WWH + PDCA 默认保留,但允许轻量化执行**。 +- **问答优先闭环关键问题,而非所有问题阻塞**。 +- **证据优先覆盖关键结论,而非所有句子逐条索证**。 +- **先可交付,再补完备性**。 + +--- + +## 0) 文档类型分流(先做) +根据用户初始描述进行分支;不确定就追问: + +- **PRD**: 新需求、流程优化、产品规划、业务方案、用户体验 +- **FRD**: 具体功能实现、接口/数据/流程细节、技术落地规格 +- **DAR**: 线上缺陷、事故复盘、根因分析、纠正预防 + +> 选择后加载对应模板: +- PRD → `references/prd.md` +- FRD → `references/frd.md` +- DAR → `references/dar.md` + +--- + +## 0.1) 执行模式选择(新增) +在确认文档类型后,必须再确认执行模式: + +### A. Quick 模式 +适用于: +- PRD 初稿 +- 需求探索 +- 信息不完整但希望先出版本 +- 老需求的轻量改版 + +特点: +- 允许先生成 v0 草案 +- 允许部分章节以 `[ASSUMPTION]` 标注 +- 不要求每轮完整 PDCA 留痕 +- 不要求 P0 全部解决后才继续草案输出 +- 图表、证据映射表可延后到定稿前补齐 + +### B. Standard 模式 +适用于: +- 常规 PRD/FRD +- 多方评审前的文档整理 +- 需要一定可追溯性,但不需要最强审计痕迹 + +特点: +- 执行 WWH + PDCA +- 关键问题需要闭环 +- 关键章节需要证据 +- 每轮有摘要与变更记录 +- 可以带未决项输出“待确认版” + +### C. Strict 模式 +适用于: +- FRD 正式交付 +- DAR 缺陷分析 +- 高风险项目 +- 需要完整审计留痕的文档 + +特点: +- 必须完整执行 WWH + PDCA +- 必须严格问答闭环 +- P0 未解答不得定稿 +- 必须完成证据→章节映射 +- 必须完成图表、表格、差异分析、资产深挖 + +### 默认策略 +- **PRD 默认 Quick** +- **FRD 默认 Standard** +- **DAR 默认 Strict** + +若用户未指定,按以上默认策略执行。 + +--- + +## 1) 确认工作目录与项目简称 +- 默认路径:`./{项目简称}-{YYYYMMDD-HHMM}` +- 项目简称来自「需求极简概称」或「文件标题」 +- 必须询问用户确认;未确认不得创建目录 + +--- + +## 1.5) 会话恢复(Resume Session) + +### 触发条件 +用户提供已存在的工作目录路径,或明确表达以下意图时立即执行会话恢复: +- “继续之前的工作” +- “修改 XXX 的 PRD/FRD/DAR” +- “重新编辑 {workdir} 的文档” +- “在 {workdir} 基础上调整” +- 用户直接提供形如 `./项目名-20260209-1500` 的路径 + +### 验证会话有效性 +基础必备文件: +- `session.yaml` +- `desc.md` + +增强校验文件: +- `summary.md` +- `outputs/{doc_type}.md` + +若基础文件缺失 → 提示损坏,建议创建新会话 +若增强文件缺失 → 允许恢复,但标记为“部分恢复” + +### 状态回顾(自动生成报告) +读取以下文件: +- `session.yaml` → 获取文档类型、当前 Round、状态、模式 +- `summary.md` → 回顾已完成内容(若存在) +- `questions/round_*.yaml` → 统计遗留问题(若存在) +- `outputs/{doc_type}.md` → 检查章节完成度(若存在) +- `session.yaml` 的 `prototype` 块 → 原型状态(如果启用) + +生成会话状态报告并展示给用户。 + +### 询问工作模式 +展示报告后,询问用户选择工作方式: + +- **Continue**:继续当前迭代 +- **Revise**:开启新轮次修订 +- **Patch**:仅改局部内容 +- **Prototype**:更新原型 +- **Finalize**:执行最终定稿 +- **Fast Draft**:基于已有材料快速重生成草案(新增) + +--- + +## 2) 初始化工作区(确认后执行) + +### 标准目录结构 +```text +{workdir}/ + desc.md + session.yaml + summary.md + decision_log.md + materials/ + materials_index.md + rounds/ + questions/ + outputs/ \ No newline at end of file diff --git a/membership/.claude/skills/tgassist/SKILL.md b/membership/.claude/skills/tgassist/SKILL.md new file mode 100644 index 0000000..e3568a2 --- /dev/null +++ b/membership/.claude/skills/tgassist/SKILL.md @@ -0,0 +1,270 @@ +--- +name: tgassist +description: | + 项目开发“基座操作系统”技能。通过统一 Spec Workspace、角色协作、阶段门禁与证据追溯, + 让 AI on the loop 成为可执行流程,推动项目从需求到验收全程可控、可验证、可复盘。 +--- + +# tgassist + +## 核心定位(必须) +- tgassist 是 **项目级协作治理系统**,不是 PRD/FRD/DAR 文档生成器。 +- **pmassist 是前序步骤且独立存在**:tgassist 只接收已形成的需求输入(可由 pmassist 或人类提供)。 +- tgassist 借鉴 pmassist 的“精益助理精神”和“资产深挖技巧”,但**不包含 pmassist 功能**。 + +## 核心规则(强制) +- **AI on the loop**:关键节点必须人类确认(初始化、阶段门禁、可选门禁启用/变更、验收/归档)。 +- **大周天固定主线**:提供需求 → 明确验收标准 → 制定计划 →(可选)架构设计 → 模块任务拆分 → 功能开发 →(可选)代码评审 → 测试 → 验收评审 → 归档。 +- **小周天 PDCA**:所有角色以 PDCA 闭环执行。 +- **问题闭环**:每轮必须生成问题清单(P0/P1/P2);P0 未关闭不得进入下一轮完整输出。 +- **证据优先**:关键结论必须标注证据或 `[ASSUMPTION]`;需维护证据索引与章节映射。 +- **资产深挖**:若存在 CodeMap/DomainMap/Runtime,必须下钻至页面/字段/调用链证据层级。 +- **门禁治理**:可选门禁由系统推荐、用户确认;中途变更必须走变更单并记录风险接受。 +- **流程裁剪**:允许按项目规模/风险等级裁剪角色流程,但必须留痕。 +- **RACI 裁剪**:角色权限矩阵可按项目规模裁剪,裁剪原因必须落盘。 +- **Git 纪律可选**:关键产出是否提交 Git 由用户确认;若启用需记录摘要/角色/阶段/变更原因。 +- **不做外部工具联动**:不对接 Jira/飞书/Notion/GitHub(可在未来扩展)。 + +## 0) 入口与角色选择 +1. 选择工作方向:初始化项目 / 需求与验收 / 开发推进 / 测试文档 / 评审验收 / 变更管理 / 复盘归档。 +2. 选择角色 Assist:PM / PJM / Arch / Dev / QA / Council(固定 6 角色)。 +3. 系统基于 workspace 缺口与风险等级给出推荐角色,用户确认后进入流程。 + +工作方向菜单: + +| # | 方向 | 角色 | 适用场景 | +|---|------|------|----------| +| 1 | 初始化项目 | PJM | 新建 workspace,填充 project.yaml/session.yaml | +| 2 | 需求与验收 | PM | 需求拆解、验收标准制定 | +| 3 | 开发推进 | Arch / Dev / PJM | 架构设计、任务跟进、代码产出、单测记录 | +| 4 | 测试文档 | QA | 用例编写、缺陷记录、回归 | +| 5 | 评审验收 | Council | 质量门禁、安全/合规审核 | +| 6 | 变更管理 | PJM / Council | 变更单录入、门禁配置变更 | +| 7 | 复盘归档 | Council / PJM | 归档、release notes、Skill 沉淀 | + +## 1) 工作区初始化(必须确认) +**默认目录**:`./workspace/specs/{project}-{YYYYMMDD-HHMM}` +用户可指定路径;确认前不得创建目录。 + +目录结构(完全重新定义): +``` +{workdir}/ + 00_meta/ + project.yaml + session.yaml + summary.md + decision_log.md + status.md + roles.md + gates.md + evidence_index.md + rounds/ + questions/ + 01_input/ + requirements.md + references/ + 02_acceptance/ + acceptance.md + checklist.md + 03_plan/ + milestones.md + risks.md + dependencies.md + 04_design/ + architecture.md + interfaces.md + data_model.md + 05_delivery/ + dev_log.md + change_log.md + 06_test_docs/ + test_cases.md + defects.md + regression.md + 07_council/ + review.md + decision.md + 99_archive/ + release_notes.md +``` + +RACI 建议载体(可裁剪): +- `00_meta/roles.md`(角色职责矩阵) + +初始化模板: +- 使用 `assets/workspace_template/` 作为基线目录结构与文件模板。 +- 必要时用项目名称、风险等级、可选门禁配置填充 `00_meta/project.yaml` 与 `00_meta/session.yaml`。 +- 可选门禁清单模板位于:`assets/workspace_template/00_meta/gate_checklists/`。 + +初始化脚本: +- `scripts/init_workspace.sh`:复制模板并填充占位符,生成新的 workspace。 + - 参见 `references/project_yaml_schema.md` 了解字段规则与枚举值。 + - 参见 `references/session_yaml_schema.md` 了解 session 字段规则。 + +门禁清单生成脚本: +- `scripts/generate_gate_checklists.sh`:根据 `00_meta/project.yaml` 中启用的可选门禁,生成 `gate_checklists_active/`。 + - 门禁推荐规则参见 `references/gate_recommendation_matrix.md`。 + +## 2) 资产理解与证据索引(强制) +- 资产路径:`assets/codemap/`、`assets/domainmap/`、`assets/runtime/` +- 必须深挖证据层级: + - 前端路由/视图/分支:`codemap/frontend/**/routes.yaml`、`views.yaml`、`dialog_branches.yaml` + - 后端字段:`codemap/serve/dataobjects/java/*.yaml` + - 后端调用链:`codemap/serve/callchains/java/domains/*.yaml` + - 领域证据:`domainmap/*.yaml` +- 证据格式: + - 本地资产:`[CODEMAP:...]`、`[DOMAINMAP:...]`、`[RUNTIME:...]` + - 外部资料:`[SRC-xxx]` + - 无证据:`[ASSUMPTION]` + +## 3) 大周天阶段引擎(固定主线) +阶段推进规则: +- 每阶段进入前检查 DoR(输入完整性) +- 每阶段完成后检查 DoD(产出完整性) +- 通过门禁后才允许推进到下一阶段 + +可选门禁(系统推荐 + 用户确认): +- 架构设计门禁 +- 代码评审门禁 +- 安全审核门禁 +- 合规/隐私审核门禁(Council Assist) + +## 4) 小周天(角色 PDCA)流程模板 +所有角色遵循:**Plan → Do → Check → Act** + +### PM Assist +- Plan:需求输入与证据汇总 → 明确 WWH / 范围 / 验收标准 +- Do:需求拆解与证据映射 → 形成问题清单 +- Check:验收标准一致性、范围边界、证据缺口 +- Act:等待人类确认 → 交付给 PJM + +### PJM Assist +- Plan:读取需求/验收 → 制定计划与里程碑 +- Do:影响边界分析(模块/接口/数据/依赖/测试五类)→ 风险/资源/依赖治理 +- Check:计划与验收匹配、影响范围完整性 +- Act:任务发布与监控 → 变更单与复盘 + +### Arch Assist +## 你的身份 +你是系统架构师,负责整体技术设计。 + +## 你的目标 +- 设计清晰、可扩展的系统架构 +- 降低长期复杂度 +- 确保技术选型与约束合理 + +## 你可以做的事 +- 技术选型 +- 系统拆分 +- 定义模块边界和接口规范 +- 输出架构设计文档与数据模型 + +## 你不能做的事 +- 不编写业务代码 +- 不修改需求范围 +- 不绕过门禁单方面冻结方案 + +## 工作 +- Plan:读取需求/约束 → 资产深挖(CodeMap / DomainMap / Runtime)→ 列出架构设计问题清单 +- Do:方案设计与技术取舍 → 关键接口定义 → 数据模型设计 → 输出 `04_design/architecture.md`、`interfaces.md`、`data_model.md` +- Check:一致性/可行性/风险验证 → 确认与需求/验收标准对齐 → 证据缺口标注 +- Act:等待人类确认 → 按评审意见修订 → 方案冻结或进入变更控制 + +### Dev Assist(后端示例) +## 你的身份 +你是后端工程师,只负责实现后端业务逻辑。 + +## 你的目标 +- 按架构和需求实现稳定、可测试的代码 +- 保证单测覆盖率达标 +- 边开发边对照验收标准,不留"后补"债务 + +## 你可以做的事 +- 编写业务代码 +- 实现 API +- 编写必要的单元测试 + +## 你不能做的事 +- 不更改架构设计 +- 不新增未经批准的功能 +- 不跳过单元测试或以"后补"代替 + +## 工作 +- Plan:制定任务拆分计划 → **每个可测试单元必须列出单测计划**(方法名 + 测试场景列表) +- Do:功能开发 → **强制编写单元测试**(不得跳过,不得以"后补"代替)→ 边开发边对照验收标准 +- Check:**单测全部通过**(通过率 100% 才允许进入 Check)→ 记录单测结果到 `dev_log.md` → 总结到06_test_docs → 可选代码评审 +- Act:等待人类确认 → 按修改意见修订计划 → 进入下一轮 + +## 留痕载体 +- `05_delivery/dev_log.md` + +**单元测试强制规则**: +- 每个 Service 方法必须有对应单测(覆盖正常路径 + 至少 1 个异常/边界路径) +- 单测必须在功能开发完成后、Check 阶段前执行完毕 +- 单测结果必须以结构化表格记录到 `05_delivery/dev_log.md` 的 `## 单元测试记录` 区块 +- 单测未通过(或未执行)视为 DoD 未达成,**禁止推进到下一阶段** +- 单测覆盖率低于 **80%**(核心业务方法)须在 dev_log.md 中标注原因并获得人类确认 + +### QA Assist +## 你的身份 +你是测试工程师,专门负责找问题。 + +## 你的目标 +- 覆盖所有验收标准 +- 发现边界与异常情况 +- 尽可能暴露缺陷和风险 + +## 你可以做的事 +- 设计测试用例与覆盖矩阵 +- 提出反例和异常场景 +- 发现逻辑漏洞并记录缺陷 + +## 你不能做的事 +- 不修复代码 +- 不修改需求 +- 不跳过回归复测 + +## 工作 +- Plan:制定测试策略与范围 → 输出测试用例计划(覆盖正常路径 + 边界 + 异常) +- Do:用例编写与覆盖矩阵输出 → 执行测试 → 缺陷记录与归类(`06_test_docs/defects.md`) +- Check:回归与复测记录 → 确认缺陷关闭状态 → 覆盖矩阵完整性验证 +- Act:测试总结 → 等待人类确认/评审 → 缺陷未关闭不得推进验收 + +## 留痕载体 +- `06_test_docs/test_cases.md`、`defects.md`、`regression.md` + +### Council Assist +- Plan:汇总证据包 → 准备门禁检查清单 +- Do:质量门禁 →(可选)安全审核 →(可选)合规/隐私审核 +- Check:问题清单与整改要求 +- Act:评审决议 → 验收结论 → 归档 + +## 5) 合规/隐私最小清单(Council Assist 推荐) +- 合法性/公平性/透明性(告知与合法依据) +- 目的限定与用途限制 +- 数据最小化与数据质量 +- 存储期限与删除策略 +- 安全保障与访问控制 +- 个体权利响应(访问/更正/删除) +- 责任与可证明性(审计/制度/记录) +- DPIA/隐私影响评估(高风险必做) + +## 6) 变更管理(强制) +任何变更必须记录: +- 变更原因、影响范围、风险等级、回滚方案 +- 责任人、确认人、时间 +- 是否影响门禁配置(如需变更须二次确认) + +## 7) 留痕与交付物 +必须落盘: +- `summary.md`(每轮摘要) +- `decision_log.md`(关键决策) +- `rounds/round_N.md`(PDCA 过程) +- `questions/round_N.yaml`(问题清单) +- `evidence_index.md`(证据索引) +- `roles.md`(RACI,按规模裁剪) + +## 8) 禁止事项 +- 未确认即创建目录/更改门禁 +- 未关闭 P0 问题即推进阶段 +- 无证据断言关键结论 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md new file mode 100644 index 0000000..3af1638 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md @@ -0,0 +1,2 @@ +# Decision Log +- {{date}}: 初始化项目与基础规则确认。 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md new file mode 100644 index 0000000..75e7ff9 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md @@ -0,0 +1,5 @@ +# Evidence Index + +| ID | Title | Type | Source | Date | Path | Notes | +|---|---|---|---|---|---|---| +| SRC-001 | | | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md new file mode 100644 index 0000000..a624a4e --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md @@ -0,0 +1,12 @@ +# Architecture Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Scope | Architecture scope matches requirements | | | | +| Constraints | Constraints and assumptions documented | | | | +| Interfaces | Key interfaces defined | | | | +| Data model | Core data model documented | | | | +| Tradeoffs | Tradeoffs and alternatives evaluated | | | | +| Risks | Architecture risks identified and mitigations planned | | | | +| Non-functional | Performance, availability, security targets defined | | | | +| Evolution | Migration/compatibility plan documented | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md new file mode 100644 index 0000000..e963c11 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md @@ -0,0 +1,12 @@ +# Code Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Requirements | Implementation matches acceptance criteria | | | | +| Tests | Unit/functional tests updated and passing | | | | +| Error handling | Errors handled and user-facing behavior defined | | | | +| Performance | Performance impact assessed | | | | +| Security | Security considerations reviewed | | | | +| Maintainability | Code readability and structure acceptable | | | | +| Compatibility | Backward compatibility assessed | | | | +| Logging/Monitoring | Observability changes documented | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md new file mode 100644 index 0000000..8b09222 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md @@ -0,0 +1,13 @@ +# Privacy & Compliance Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Lawfulness/Transparency | Legal basis and user notices are documented | | | | +| Purpose limitation | Data use limited to defined purposes | | | | +| Data minimization | Only necessary data collected | | | | +| Data quality | Data accuracy and update mechanisms defined | | | | +| Storage limitation | Retention period defined and enforced | | | | +| Security safeguards | Security controls for personal data | | | | +| Individual rights | Access/rectify/delete requests supported | | | | +| Accountability | Audit trail and responsibility defined | | | | +| DPIA | DPIA completed for high-risk processing | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md new file mode 100644 index 0000000..e989b8b --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md @@ -0,0 +1,12 @@ +# Security Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Threat model | Threat model exists for new/changed components | | | | +| AuthN/AuthZ | Access control and permission checks reviewed | | | | +| Secrets | Secrets managed securely (no hard-coded secrets) | | | | +| Input validation | User/externally sourced inputs validated | | | | +| Dependencies | Third-party dependencies reviewed/approved | | | | +| Logging | Security-relevant events logged | | | | +| Incident response | Rollback/mitigation plan documented | | | | +| Data protection | Sensitive data protected in transit/at rest | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md new file mode 100644 index 0000000..69a7568 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md @@ -0,0 +1,43 @@ +# Gates (DoR / DoD) + +> 每阶段进入前检查 DoR,完成后检查 DoD;可选门禁由系统推荐、用户确认。 + +## 需求输入 +- DoR: 需求来源明确;背景/目标初步描述 +- DoD: 需求文本落盘;证据索引初版 + +## 验收标准 +- DoR: 需求范围与目标明确 +- DoD: 验收标准可测试;范围边界明确 + +## 计划制定 +- DoR: 验收标准确认 +- DoD: 里程碑/资源/风险/依赖落盘 + +## 架构设计(可选) +- DoR: 复杂度/风险达到门槛 +- DoD: 架构方案/接口/数据模型落盘并评审 + +## 模块任务拆分 +- DoR: 计划确认 +- DoD: 任务列表与责任人明确 + +## 功能开发 +- DoR: 任务清单确认 +- DoD: 实现记录与单测/自测结果 + +## 代码评审(可选) +- DoR: 评审门禁启用 +- DoD: 评审结论与整改记录 + +## 测试 +- DoR: 可测试版本与用例准备 +- DoD: 测试报告/缺陷清单/回归记录 + +## 验收评审 +- DoR: 证据包齐全 +- DoD: 评审决议与整改清单 + +## 归档 +- DoR: 所有门禁通过 +- DoD: 归档文档与复盘记录 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml new file mode 100644 index 0000000..bec651c --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml @@ -0,0 +1,27 @@ +schema: + name: tgassist.project + version: "0.1" + +project: + name: "{{project_name}}" + alias: "{{project_alias}}" + description: "{{project_desc}}" + owner: "{{project_owner}}" + created_at: "{{date}}" + +governance: + scale: "small|medium|large" + risk_level: "low|medium|high" + optional_gates: + architecture_design: "enabled|disabled" + code_review: "enabled|disabled" + security_review: "enabled|disabled" + privacy_compliance: "enabled|disabled" + git_policy: + enabled: "true|false" + commit_format: "[role][phase] summary - reason" + +evidence_sources: + codemap: "assets/codemap/" + domainmap: "assets/domainmap/" + runtime: "assets/runtime/" diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml new file mode 100644 index 0000000..3cd890b --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml @@ -0,0 +1,7 @@ +round: {{round}} +questions: + - id: Q{{round}}-1 + priority: P0 + question: "..." + options: ["...", "...", "其他"] + status: pending diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md new file mode 100644 index 0000000..e2af403 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md @@ -0,0 +1,16 @@ +# Roles (RACI) + +> 按项目规模裁剪并记录原因。 + +| 阶段/角色 | PM | PJM | Arch | Dev | QA | Council | +|---|---|---|---|---|---|---| +| 需求输入 | R | C | I | I | I | I | +| 验收标准 | A | C | C | I | I | I | +| 计划制定 | C | A/R | C | I | I | I | +| 架构设计(可选) | C | C | A/R | I | I | I | +| 任务拆分 | C | A/R | C | R | I | I | +| 功能开发 | I | C | C | A/R | I | I | +| 代码评审(可选) | I | C | C | A/R | I | I | +| 测试 | I | C | I | C | A/R | I | +| 验收评审 | C | C | C | C | C | A/R | +| 归档 | I | A/R | I | I | I | C | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md new file mode 100644 index 0000000..6e8d4a8 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md @@ -0,0 +1,21 @@ +# Round {{round}} + +## Plan +- WWH 填充度: +- 本轮目标: +- 需要读取的资产与资料: +- 需要提出的问题: + +## Do +- 资产读取: +- 分析与产出: +- 提问: + +## Check +- 目标覆盖: +- 证据充分性: +- 逻辑一致性: + +## Act +- 更新 summary/decision_log/session +- 规划下一轮 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml new file mode 100644 index 0000000..5d5f310 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml @@ -0,0 +1,43 @@ +schema: + name: tgassist.session + version: "0.1" + +status: + current_phase: "demand" + current_round: 1 + state: "in_progress" + last_updated: "{{date}}" + +phases: + - name: "demand" + status: "in_progress" + - name: "acceptance" + status: "pending" + - name: "plan" + status: "pending" + - name: "architecture" + status: "optional" + - name: "decompose" + status: "pending" + - name: "develop" + status: "pending" + - name: "code_review" + status: "optional" + - name: "test" + status: "pending" + - name: "acceptance_review" + status: "pending" + - name: "archive" + status: "pending" + +questions: + p0_open: 0 + p1_open: 0 + p2_open: 0 + +metrics: + evidence_count: 0 + mermaid_count: 0 + table_count: 0 + +assumptions: [] diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md new file mode 100644 index 0000000..10b840f --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md @@ -0,0 +1,10 @@ +# Status + +- 当前阶段:{{current_phase}} +- 当前轮次:{{current_round}} +- 阻塞问题:{{p0_count}} +- 关键决策:{{last_decision}} +- 最近更新:{{date}} + +## 下一步 +- {{next_action}} diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md new file mode 100644 index 0000000..0738e5e --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md @@ -0,0 +1,2 @@ +# Summary +- {{date}}: 初始化项目,进入 Round 1。 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md b/membership/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md new file mode 100644 index 0000000..ab27475 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md @@ -0,0 +1,7 @@ +# Requirements + +## 背景与目标 + +## 需求概述 + +## 证据/参考 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md b/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md new file mode 100644 index 0000000..27ec650 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md @@ -0,0 +1,5 @@ +# Acceptance + +## 验收标准 + +## 范围边界 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md b/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md new file mode 100644 index 0000000..e0d8659 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md @@ -0,0 +1,5 @@ +# Acceptance Checklist + +| Item | Description | Status | Evidence | +|---|---|---|---| +| | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md new file mode 100644 index 0000000..b89fcba --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md @@ -0,0 +1,5 @@ +# Dependencies + +| Dependency | Type | Impact | Owner | Status | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md new file mode 100644 index 0000000..12ccaeb --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md @@ -0,0 +1,5 @@ +# Milestones + +| Milestone | Date | Owner | Status | +|---|---|---|---| +| | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md new file mode 100644 index 0000000..335c608 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md @@ -0,0 +1,5 @@ +# Risks + +| Risk | Level | Mitigation | Owner | Status | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md new file mode 100644 index 0000000..946ae92 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md @@ -0,0 +1,7 @@ +# Architecture + +## Overview + +## Tradeoffs + +## Evidence diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md new file mode 100644 index 0000000..ed3c60c --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md @@ -0,0 +1,5 @@ +# Data Model + +| Entity | Fields | Constraints | Notes | +|---|---|---|---| +| | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md new file mode 100644 index 0000000..457b965 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md @@ -0,0 +1,5 @@ +# Interfaces + +| Interface | Owner | Input | Output | Notes | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md b/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md new file mode 100644 index 0000000..aabf5cc --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md @@ -0,0 +1,5 @@ +# Change Log + +| Change | Reason | Impact | Decision | Date | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md b/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md new file mode 100644 index 0000000..c431eb9 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md @@ -0,0 +1,90 @@ +# 开发日志 (05_delivery/dev_log.md) + +> **项目**: {project_name} +> **更新规则**: 每个任务完成或有重要产出时更新;单元测试执行后**必须**填写"单元测试记录"区块 + +--- + +## 任务状态总览 + +| 任务 | 负责人 | Day | 状态 | 完成时间 | 备注 | +|------|--------|-----|------|----------|------| +| T-xxx | — | — | ⬜ 待开始 | — | — | + +> 状态说明:✅ 完成 / 🔄 进行中 / ⬜ 待开始 / ❌ 阻塞 + +--- + +## 开发日志详情 + +### {YYYY-MM-DD} | Round N | Dev Assist — {任务编号} {任务名称} + +**PDCA 阶段**: Do — 代码产出 + +**本轮产出**: + +| 文件 | 模块 | 说明 | +|------|------|------| +| — | — | — | + +**关键设计决策**: +- (记录影响后续维护的设计选择) + +**DoD 验证清单**: +- [ ] ... + +**遗留问题**: +- (无则写"无") + +--- + +## 单元测试记录 + +> ⚠️ **强制要求**:每个任务的单测必须在进入 Check 阶段前完成并记录。 +> 单测未通过或未记录 = DoD 未达成 = 禁止推进下一阶段。 + +### {任务编号} {任务名称} — 单测计划与结果 + +**执行时间**: {YYYY-MM-DD HH:MM} +**执行人**: {name} +**测试框架**: JUnit 5 / Mockito(或实际使用框架) + +#### 单测结果明细 + +| 测试类 | 测试方法 | 场景描述 | 结果 | 备注 | +|--------|----------|----------|------|------| +| `XxxServiceTest` | `testSave_success` | 正常创建,返回主键 | ✅ PASS | — | +| `XxxServiceTest` | `testSave_missingOrderNo` | 订单号为空,抛 BusinessException | ✅ PASS | — | +| `XxxServiceTest` | `testSave_invalidProvider` | 服务商不存在,抛 BusinessException | ✅ PASS | — | +| `XxxServiceTest` | `testList_emptyResult` | 无数据时返回空 Page | ✅ PASS | — | + +> 结果说明:✅ PASS / ❌ FAIL / ⚠️ SKIP(须注明原因) + +#### 覆盖率摘要 + +| 类 | 方法数 | 已覆盖 | 覆盖率 | 是否达标(≥80%) | +|----|--------|--------|--------|-----------------| +| `XxxService` | — | — | —% | — | + +> 覆盖率低于 80% 须填写原因,并获得人类确认后方可推进: +> - 原因: +> - 确认人: +> - 确认时间: + +#### 失败/跳过明细(若有) + +| 测试方法 | 失败原因 | 修复状态 | 修复时间 | +|----------|----------|----------|----------| +| — | — | — | — | + +--- + +## 变更记录 + +> 暂无变更 + +--- + +## 阻塞记录 + +> 暂无阻塞 diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md new file mode 100644 index 0000000..fa85a5d --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md @@ -0,0 +1,5 @@ +# Defects + +| ID | Summary | Severity | Status | Evidence | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md new file mode 100644 index 0000000..cc10947 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md @@ -0,0 +1,5 @@ +# Regression + +| Version | Cases | Pass | Fail | Notes | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md new file mode 100644 index 0000000..cd020d6 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md @@ -0,0 +1,5 @@ +# Test Cases + +| Case | Scope | Steps | Expected | Status | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md b/membership/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md new file mode 100644 index 0000000..0941681 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md @@ -0,0 +1,5 @@ +# Decision + +| Item | Decision | Owner | Due | Status | +|---|---|---|---|---| +| | | | | | diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/07_council/review.md b/membership/.claude/skills/tgassist/assets/workspace_template/07_council/review.md new file mode 100644 index 0000000..146d689 --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/07_council/review.md @@ -0,0 +1,7 @@ +# Review + +## Summary + +## Issues + +## Decision diff --git a/membership/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md b/membership/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md new file mode 100644 index 0000000..f3a764b --- /dev/null +++ b/membership/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md @@ -0,0 +1,7 @@ +# Release Notes + +## Highlights + +## Changes + +## Risks diff --git a/membership/.claude/skills/tgassist/references/gate_recommendation_matrix.md b/membership/.claude/skills/tgassist/references/gate_recommendation_matrix.md new file mode 100644 index 0000000..9ec8edb --- /dev/null +++ b/membership/.claude/skills/tgassist/references/gate_recommendation_matrix.md @@ -0,0 +1,16 @@ +# Gate Recommendation Matrix (tgassist) + +> System recommends gates based on project scale and risk level. User confirmation is required. + +## Matrix + +| Risk \ Scale | Small | Medium | Large | +|---|---|---|---| +| Low | (none) | Code Review | Code Review | +| Medium | Code Review + Security Review | Architecture + Code Review + Security Review | Architecture + Code Review + Security Review | +| High | Architecture + Code Review + Security Review + Privacy/Compliance | Architecture + Code Review + Security Review + Privacy/Compliance | Architecture + Code Review + Security Review + Privacy/Compliance | + +## Notes +- Architecture gate recommended when system complexity is medium or above. +- Privacy/Compliance gate recommended for high-risk or personal data handling projects. +- User can override recommendations, but must record decision and risk acceptance. diff --git a/membership/.claude/skills/tgassist/references/project_yaml_schema.md b/membership/.claude/skills/tgassist/references/project_yaml_schema.md new file mode 100644 index 0000000..7eb7b31 --- /dev/null +++ b/membership/.claude/skills/tgassist/references/project_yaml_schema.md @@ -0,0 +1,50 @@ +# project.yaml Schema (tgassist) + +## Fields + +- `schema.name`: + - Value: `tgassist.project` + +- `schema.version`: + - Value: `0.1` + +- `project.name`: + - Human-readable project name + +- `project.alias`: + - Short slug used for directory naming + +- `project.description`: + - One-line description + +- `project.owner`: + - Primary owner (role/person) + +- `project.created_at`: + - ISO date (YYYY-MM-DD) + +- `governance.scale`: + - Enum: `small | medium | large` + +- `governance.risk_level`: + - Enum: `low | medium | high` + +- `governance.optional_gates`: + - `architecture_design`: `enabled | disabled` + - `code_review`: `enabled | disabled` + - `security_review`: `enabled | disabled` + - `privacy_compliance`: `enabled | disabled` + +- `governance.git_policy`: + - `enabled`: `true | false` + - `commit_format`: string, default `[role][phase] summary - reason` + +- `evidence_sources`: + - `codemap`: path string + - `domainmap`: path string + - `runtime`: path string + +## Notes +- Optional gates default to `disabled` until user confirms. +- Risk level drives recommended optional gates. +- If `git_policy.enabled=true`, commits must include summary, role, phase, and change reason. diff --git a/membership/.claude/skills/tgassist/references/session_yaml_schema.md b/membership/.claude/skills/tgassist/references/session_yaml_schema.md new file mode 100644 index 0000000..2a12f27 --- /dev/null +++ b/membership/.claude/skills/tgassist/references/session_yaml_schema.md @@ -0,0 +1,44 @@ +# session.yaml Schema (tgassist) + +## Fields + +- `schema.name`: + - Value: `tgassist.session` + +- `schema.version`: + - Value: `0.1` + +- `status.current_phase`: + - Enum: `demand | acceptance | plan | architecture | decompose | develop | code_review | test | acceptance_review | archive` + +- `status.current_round`: + - Integer (>= 1) + +- `status.state`: + - Enum: `in_progress | awaiting_answers | finalized` + +- `status.last_updated`: + - ISO date (YYYY-MM-DD) + +- `phases`: + - List of phase objects + - Each phase has: + - `name` (same enum as `current_phase`) + - `status`: `pending | in_progress | completed | optional` + +- `questions`: + - `p0_open`: integer + - `p1_open`: integer + - `p2_open`: integer + +- `metrics`: + - `evidence_count`: integer + - `mermaid_count`: integer + - `table_count`: integer + +- `assumptions`: + - List of strings + +## Notes +- Phase progression requires DoR/DoD checks and gate approvals. +- `state` becomes `awaiting_answers` when P0 questions remain. diff --git a/membership/.claude/skills/tgassist/scripts/generate_gate_checklists.sh b/membership/.claude/skills/tgassist/scripts/generate_gate_checklists.sh new file mode 100644 index 0000000..38962e7 --- /dev/null +++ b/membership/.claude/skills/tgassist/scripts/generate_gate_checklists.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + generate_gate_checklists.sh --project PATH [options] + +Required: + --project PATH Workspace root (contains 00_meta/project.yaml) + +Optional: + --output PATH Output directory for active gate checklists + (default: /00_meta/gate_checklists_active) + --template PATH Template dir (default: skills/tg/assets/workspace_template/00_meta/gate_checklists) + -h, --help Show help +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +project="" +output="" +template_dir="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --project) project="$2"; shift 2;; + --output) output="$2"; shift 2;; + --template) template_dir="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) die "unknown arg: $1";; + esac +done + +[[ -z "$project" ]] && die "--project is required" + +project_yaml="$project/00_meta/project.yaml" +[[ -f "$project_yaml" ]] || die "missing $project_yaml" + +if [[ -z "$output" ]]; then + output="$project/00_meta/gate_checklists_active" +fi + +if [[ -z "$template_dir" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + template_dir="${script_dir}/../assets/workspace_template/00_meta/gate_checklists" +fi + +mkdir -p "$output" + +enabled_gates=$(python3 - "$project_yaml" <<'PY' +import re, sys +path = sys.argv[1] +patterns = { + "architecture_design": "architecture_review.md", + "code_review": "code_review.md", + "security_review": "security_review.md", + "privacy_compliance": "privacy_review.md", +} +found = {k: False for k in patterns} +for line in open(path, 'r', encoding='utf-8'): + m = re.match(r"\s*(architecture_design|code_review|security_review|privacy_compliance)\s*:\s*\"?(\w+)\"?", line) + if m: + found[m.group(1)] = (m.group(2).strip().lower() == "enabled") + +files = [] +for k, v in found.items(): + if v: + files.append(patterns[k]) + +print(" ".join(files)) +PY +) + +if [[ -z "$enabled_gates" ]]; then + echo "No optional gates enabled." + exit 0 +fi + +for f in $enabled_gates; do + src="$template_dir/$f" + [[ -f "$src" ]] || die "template missing: $src" + cp "$src" "$output/" + echo "Copied: $f" +done + +echo "Active gate checklists generated at: $output" diff --git a/membership/.claude/skills/tgassist/scripts/init_workspace.sh b/membership/.claude/skills/tgassist/scripts/init_workspace.sh new file mode 100644 index 0000000..6a111bc --- /dev/null +++ b/membership/.claude/skills/tgassist/scripts/init_workspace.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + init_workspace.sh --name NAME [options] + +Required: + --name NAME Project name + +Optional: + --alias ALIAS Project alias (default: slugified name) + --desc DESC Project description + --owner OWNER Project owner + --scale small|medium|large Project scale (default: medium) + --risk low|medium|high Risk level (default: medium) + --gate-architecture enabled|disabled Optional gate (default: disabled) + --gate-code-review enabled|disabled Optional gate (default: disabled) + --gate-security enabled|disabled Optional gate (default: disabled) + --gate-privacy enabled|disabled Optional gate (default: disabled) + --git-enabled true|false Git policy enabled (default: false) + --git-commit-format FORMAT Commit format (default: "[role][phase] summary - reason") + --output PATH Output directory (default: ./workspace/specs/{alias}-YYYYMMDD-HHMM) + -h, --help Show help +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +project_name="" +project_alias="" +project_desc="" +project_owner="" +scale="medium" +risk="medium" +_gate_arch="disabled" +_gate_code_review="disabled" +_gate_security="disabled" +_gate_privacy="disabled" +git_enabled="false" +git_commit_format="[role][phase] summary - reason" +output="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) project_name="$2"; shift 2;; + --alias) project_alias="$2"; shift 2;; + --desc) project_desc="$2"; shift 2;; + --owner) project_owner="$2"; shift 2;; + --scale) scale="$2"; shift 2;; + --risk) risk="$2"; shift 2;; + --gate-architecture) _gate_arch="$2"; shift 2;; + --gate-code-review) _gate_code_review="$2"; shift 2;; + --gate-security) _gate_security="$2"; shift 2;; + --gate-privacy) _gate_privacy="$2"; shift 2;; + --git-enabled) git_enabled="$2"; shift 2;; + --git-commit-format) git_commit_format="$2"; shift 2;; + --output) output="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) die "unknown arg: $1";; + esac +done + +[[ -z "$project_name" ]] && die "--name is required" + +if [[ -z "$project_alias" ]]; then + project_alias=$(echo "$project_name" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-') +fi + +if [[ -z "$output" ]]; then + output="./workspace/specs/${project_alias}-$(date +%Y%m%d-%H%M)" +fi + +if [[ -e "$output" ]]; then + die "output already exists: $output" +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +template_dir="${script_dir}/../assets/workspace_template" + +mkdir -p "$output" +cp -R "$template_dir"/. "$output"/ + +date_str="$(date +%Y-%m-%d)" + +export TG_PROJECT_NAME="$project_name" +export TG_PROJECT_ALIAS="$project_alias" +export TG_PROJECT_DESC="$project_desc" +export TG_PROJECT_OWNER="$project_owner" +export TG_DATE="$date_str" +export TG_SCALE="$scale" +export TG_RISK="$risk" +export TG_GATE_ARCH="$_gate_arch" +export TG_GATE_CODE_REVIEW="$_gate_code_review" +export TG_GATE_SECURITY="$_gate_security" +export TG_GATE_PRIVACY="$_gate_privacy" +export TG_GIT_ENABLED="$git_enabled" +export TG_GIT_COMMIT_FORMAT="$git_commit_format" +export TG_CURRENT_PHASE="demand" +export TG_CURRENT_ROUND="1" +export TG_P0_COUNT="0" +export TG_LAST_DECISION="-" +export TG_NEXT_ACTION="collect requirements" + +replace_file() { + python3 - "$1" <<'PY' +import os +from pathlib import Path + +path = Path(os.environ["TARGET_FILE"]) +data = path.read_text() +repl = { + "project_name": os.environ.get("TG_PROJECT_NAME", ""), + "project_alias": os.environ.get("TG_PROJECT_ALIAS", ""), + "project_desc": os.environ.get("TG_PROJECT_DESC", ""), + "project_owner": os.environ.get("TG_PROJECT_OWNER", ""), + "date": os.environ.get("TG_DATE", ""), + "current_phase": os.environ.get("TG_CURRENT_PHASE", ""), + "current_round": os.environ.get("TG_CURRENT_ROUND", ""), + "p0_count": os.environ.get("TG_P0_COUNT", ""), + "last_decision": os.environ.get("TG_LAST_DECISION", ""), + "next_action": os.environ.get("TG_NEXT_ACTION", ""), +} +for k, v in repl.items(): + data = data.replace("{{" + k + "}}", v) + +# project.yaml optional fields +if path.name == "project.yaml": + data = data.replace("small|medium|large", os.environ.get("TG_SCALE", "medium")) + data = data.replace("low|medium|high", os.environ.get("TG_RISK", "medium")) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_ARCH") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_CODE_REVIEW") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_SECURITY") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_PRIVACY") == "enabled" else "disabled", 1) + data = data.replace("true|false", "true" if os.environ.get("TG_GIT_ENABLED") == "true" else "false", 1) + data = data.replace("[role][phase] summary - reason", os.environ.get("TG_GIT_COMMIT_FORMAT", "[role][phase] summary - reason")) + +path.write_text(data) +PY +} + +for f in \ + "$output/00_meta/project.yaml" \ + "$output/00_meta/session.yaml" \ + "$output/00_meta/summary.md" \ + "$output/00_meta/decision_log.md" \ + "$output/00_meta/status.md"; do + export TARGET_FILE="$f" + replace_file "$f" +done + +echo "Workspace initialized at: $output" diff --git a/membership/package.json b/membership/package.json new file mode 100644 index 0000000..3c1ce48 --- /dev/null +++ b/membership/package.json @@ -0,0 +1,18 @@ +{ + "name": "claude-team-workflow", + "private": true, + "scripts": { + "claude:test": "node scripts/claude-safe-run.js .claude/agents/tester.md", + "claude:architect": "node scripts/claude-safe-run.js .claude/agents/architect.md", + "claude:backend": "node scripts/claude-safe-run.js .claude/agents/backend.md", + "claude:frontend": "node scripts/claude-safe-run.js .claude/agents/frontend.md", + "claude:review": "node scripts/claude-safe-run.js .claude/agents/codeReview.md", + "claude:supervisor": "node scripts/claude-safe-run.js .claude/agents/supervisor.md", + + "claude:product": "node scripts/claude-safe-run.js .claude/agents/product.md", + "claude:security": "node scripts/claude-safe-run.js .claude/agents/security.md", + + "claude:preflight": "node scripts/claude-safe-run.js .claude/agents/preflight.md", + "claude:postflight": "node scripts/claude-safe-run.js .claude/agents/postflight.md" + } +} \ No newline at end of file diff --git a/membership/scripts/claude-safe-run.js b/membership/scripts/claude-safe-run.js new file mode 100644 index 0000000..b54bc9c --- /dev/null +++ b/membership/scripts/claude-safe-run.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Claude Safe Run - Team Edition + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ---------- Utils ---------- +function run(cmd, options = {}) { + return execSync(cmd, { stdio: "pipe", encoding: "utf-8", ...options }); +} + +function exists(p) { + return fs.existsSync(p); +} + +function fatal(msg) { + console.error(`❌ ${msg}`); + process.exit(1); +} + +// ---------- Args ---------- +const agentPrompt = process.argv[2]; +if (!agentPrompt) { + fatal("Usage: node scripts/claude-safe-run.js "); +} + +if (!exists(agentPrompt)) { + fatal(`Agent prompt not found: ${agentPrompt}`); +} + +// ---------- Required Files ---------- +const PREFLIGHT_AGENT = ".claude/agents/preflight.md"; +const POSTFLIGHT_AGENT = ".claude/agents/postflight.md"; +const RUNS_DIR = ".claude/runs"; + +[PREFLIGHT_AGENT, POSTFLIGHT_AGENT, RUNS_DIR].forEach((p) => { + if (!exists(p)) fatal(`Required file or directory missing: ${p}`); +}); + +// ---------- Git Context ---------- +let git = {}; +try { + git.user = run("git config user.name").trim(); + git.branch = run("git rev-parse --abbrev-ref HEAD").trim(); + git.commit = run("git rev-parse HEAD").trim(); +} catch { + fatal("Git context unavailable. Are you in a git repository?"); +} + +// ---------- Metadata ---------- +const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); +const agentName = path.basename(agentPrompt, ".md"); +const runId = `${timestamp}-${agentName}-${git.user.replace(/\s+/g, "_")}`; + +console.log(`🧠 Claude Safe Run`); +console.log(`- Agent: ${agentName}`); +console.log(`- Author: ${git.user}`); +console.log(`- Branch: ${git.branch}`); +console.log(`- Commit: ${git.commit}`); +console.log(`- Run ID: ${runId}`); + +// ---------- Preflight ---------- +console.log("\n🔍 Running Preflight Agent..."); +const preflightOutput = run( + `claude run --prompt ${PREFLIGHT_AGENT} --input ${agentPrompt}` +); + +console.log(preflightOutput); + +if (!preflightOutput.includes("PRECHECK: PASS")) { + fatal("Preflight check failed or unclear. Execution aborted."); +} + +// ---------- Run Main Agent ---------- +console.log("\n🚀 Running Main Agent..."); +execSync(`claude run --prompt ${agentPrompt}`, { stdio: "inherit" }); + +// ---------- Postflight ---------- +console.log("\n🧪 Running Postflight Agent..."); +const postflightOutput = run( + `claude run --prompt ${POSTFLIGHT_AGENT} --input ${runId}` +); + +console.log(postflightOutput); + +if (!postflightOutput.includes("POSTCHECK: PASS")) { + fatal("Postflight validation failed. Run logs invalid or missing."); +} + +console.log("\n✅ Claude run completed successfully."); +console.log(`📄 Run ID: ${runId}`); \ No newline at end of file diff --git a/membership/specs/materials/企业微信截图_17706340271335.png b/membership/specs/materials/企业微信截图_17706340271335.png new file mode 100644 index 0000000..010a471 Binary files /dev/null and b/membership/specs/materials/企业微信截图_17706340271335.png differ diff --git a/membership/specs/materials/服务商配置.png b/membership/specs/materials/服务商配置.png new file mode 100644 index 0000000..32fe4e2 Binary files /dev/null and b/membership/specs/materials/服务商配置.png differ diff --git a/membership/specs/materials/调度日志比价.png b/membership/specs/materials/调度日志比价.png new file mode 100644 index 0000000..51eea2f Binary files /dev/null and b/membership/specs/materials/调度日志比价.png differ diff --git a/membership/产品需求文档.txt b/membership/产品需求文档.txt new file mode 100644 index 0000000..a44e978 --- /dev/null +++ b/membership/产品需求文档.txt @@ -0,0 +1,2493 @@ +中道会员中台系统 产品需求文档(PRD) + +--- + +## 一、文档信息 + +### 1.1 文档基本信息 + +| 项目 | 内容 | +|------|------| +| 项目名称 | 集团会员中台系统 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2024年 | +| 预计上线 | 2个月 | +| 产品负责人 | - | +| 技术负责人 | - | + +### 1.2 版本修订记录 + +| 版本 | 日期 | 修订人 | 修订内容 | +|------|------|--------|----------| +| V1.0 | 2024年 | - | 初稿创建 | + +### 1.3 名词解释 + +| 名词 | 解释 | +|------|------| +| 客户池 | 来自各业务系统的原始客户数据,尚未转化为会员的潜在用户集合 | +| 会员 | 通过领券、注册等方式完成转化,进入会员体系的用户 | +| 服务券 | A业务向B业务采购的优惠券,成本由采购方(A)承担 | +| 引流券 | B业务免费提供给A业务发放的优惠券,成本由提供方(B)承担,目的是为B业务引流 | +| 综合积分 | 用于计算会员等级的积分,包含消费金额、消费次数等加权计算 | +| 可用积分 | 用户可用于抵扣消费或兑换的积分余额 | +| 券池 | 存放已创建但未绑定用户的空白券,用于批量发放或导出 | +| 大礼包/优惠卡 | 将多张优惠券组合成一个礼包,支持定价销售或赠送 | +| 跨业务转化 | 用户从一个业务线转化到另一个业务线消费 | + +### 1.4 参考文档 + +| 文档名称 | 说明 | +|----------|------| +| 接口设计文档 | 见附录4 | +| 原型设计稿 | 见附录5 | +| 数据库设计文档 | 见附录6 | + +--- + +## 二、项目概述 + +### 2.1 项目背景 + +公司目前拥有三条产品线:道路救援、代驾服务、到家服务。每条产品线独立运营,各自拥有独立的业务系统、会员管理体系和优惠券管理体系。 + +当前存在以下问题: + +1. **用户数据割裂**:三个业务系统账号完全独立,连手机号都未打通 +2. **无法相互引流**:用户在A业务消费后,无法便捷地导向B业务 +3. **重复建设成本高**:三套会员系统、优惠券系统各自维护 +4. **运营效率低**:无法进行集团层面的统一会员营销活动 +5. **数据价值未发挥**:无法获得用户在多业务的完整画像 + +### 2.2 现状问题 + +| 问题类别 | 具体问题 | +|----------|----------| +| 账号体系 | 三个业务完全独立,用户需重复注册 | +| 会员体系 | 各业务仅有基础会员数据,无统一等级、积分 | +| 优惠券 | 各业务独立管理,无法跨业务使用和流转 | +| 数据分析 | 无法进行跨业务用户分析和转化追踪 | +| 组织协同 | 三个BU独立运营,各有KPI,缺乏协同机制 | + +### 2.3 项目目标 + +| 优先级 | 目标 | 衡量指标 | +|--------|------|----------| +| P0 | 实现跨业务用户引流 | 跨业务转化率提升至15% | +| P1 | 打通用户数据,建立统一画像 | 数据完整度达90% | +| P2 | 支持集团层面统一运营活动 | 支持跨业务营销活动 | +| P3 | 提升用户跨业务复购率 | 复购率提升20% | +| P4 | 降低重复建设成本 | 系统维护成本降低30% | + +### 2.4 项目范围 + +**在范围内:** + +- ✅ 建立统一客户池,汇集三业务客户数据 +- ✅ 建立统一会员中台(账号、等级、积分、优惠券、标签、消息) +- ✅ 支持优惠券跨业务流转(服务券采购、引流券发放) +- ✅ 建立跨业务结算对账机制 +- ✅ 开发会员小程序(用户端) +- ✅ 开发统一管理后台 +- ✅ 与三个业务系统API对接 + +**不在范围内:** + +- ❌ 老用户数据迁移(保持现状,只管新用户) +- ❌ 改造现有三个业务系统的核心流程 +- ❌ 替代现有业务系统的订单、服务等模块 + +### 2.5 项目约束 + +| 约束类型 | 约束内容 | +|----------|----------| +| 时间约束 | 2个月内完成上线 | +| 组织约束 | 三个BU独立运营,各有KPI,利益分配需明确 | +| 技术约束 | 需与三个现有业务系统对接,采用API实时同步 | +| 数据约束 | 只管理新用户,老用户暂不迁移 | + +### 2.6 成功指标 + +| 指标 | 目标值 | 衡量周期 | +|------|--------|----------| +| 新会员注册数 | 月均5万+ | 上线后3个月 | +| 跨业务用户占比 | 20%+ | 上线后6个月 | +| 跨业务转化率 | 15%+ | 上线后6个月 | +| 优惠券核销率 | 60%+ | 上线后3个月 | +| 引流券ROI | 3.0+ | 上线后3个月 | + +--- + +## 三、系统架构 + +### 3.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员中台系统 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 客户池管理 │ │ 统一账号体系 │ │ 会员等级体系 │ │ 积分体系 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 优惠券中心 │ │ 用户标签体系 │ │ 消息推送 │ │ 服务类目管理 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ 结算对账管理 │ │ 报表统计中心 │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + ↑↓ ↑↓ ↑↓ + API实时对接 API实时对接 API实时对接 + ↑↓ ↑↓ ↑↓ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ 道路救援系统 │ │ 代驾服务系统 │ │ 到家服务系统 │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 3.2 产品形态 + +| 端 | 形态 | 说明 | +|----|------|------| +| 用户端 | 独立会员小程序 | 会员中心、领券、积分、礼包商城等 | +| 管理端 | 统一中台后台 | 集团统一管理,支持BU分权限 | + +### 3.3 系统对接方式 + +| 对接方式 | 说明 | +|----------|------| +| 接口协议 | RESTful API | +| 数据格式 | JSON | +| 同步方式 | 实时同步 | +| 安全机制 | 签名验证、HTTPS | +| 异常处理 | 支持重试、补偿机制 | + +### 3.4 技术架构说明 + +| 层级 | 说明 | +|------|------| +| 接入层 | API网关,统一鉴权、限流 | +| 服务层 | 微服务架构,各功能模块独立服务 | +| 数据层 | MySQL主从、Redis缓存 | +| 消息队列 | 用于异步处理、削峰 | + +--- + +## 四、功能模块详细设计 + +### 4.0 客户池管理 + +#### 4.0.1 功能说明 + +客户池是会员的前置数据池,用于汇集各业务系统的客户数据。这些客户尚未成为会员,通过精准营销触达后,引导其领券/注册,完成向会员的转化。 + +#### 4.0.2 客户与会员的关系 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户池 │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ 道路救援 │ │ 代驾服务 │ │ 到家服务 │ │ +│ │ 客户数据 │ │ 客户数据 │ │ 客户数据 │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ └──────────────┼──────────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 客户数据汇集 │ │ +│ │ 去重 & 合并 │ │ +│ └────────┬────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 潜在客户池 │ ←── 未转化会员 │ +│ └────────┬────────┘ │ +└───────────────────────┼─────────────────────────────────────────┘ + ↓ 营销触达(短信/推送/外呼) + ┌─────────────────┐ + │ 领券 / 注册 │ + └────────┬────────┘ + ↓ +┌───────────────────────┼─────────────────────────────────────────┐ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 会员池 │ ←── 已转化会员 │ +│ └─────────────────┘ │ +│ 会员中台 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 4.0.3 客户数据来源 + +| 来源渠道 | 数据说明 | 同步方式 | +|----------|----------|----------| +| 道路救援系统 | 历史服务客户 | API实时/批量 | +| 代驾系统 | 历史服务客户 | API实时/批量 | +| 到家服务系统 | 历史服务客户 | API实时/批量 | +| 线下渠道 | 地推收集客户 | 批量导入 | +| 合作渠道 | 第三方合作数据 | 批量导入 | +| 活动收集 | 活动报名客户 | API/导入 | + +#### 4.0.4 客户数据属性 + +**基础信息:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 客户ID | 是 | 系统自动生成 | +| 手机号 | 是 | 唯一标识,用于去重 | +| 姓名 | 否 | 客户姓名 | +| 性别 | 否 | 男/女/未知 | +| 城市 | 否 | 所在城市 | +| 来源渠道 | 是 | 数据来源业务/渠道 | +| 来源时间 | 是 | 数据进入时间 | +| 客户状态 | 是 | 潜在/已触达/已转化/无效 | + +**业务信息:** + +| 字段 | 说明 | +|------|------| +| 关联业务 | 曾使用过哪些业务 | +| 历史消费次数 | 各业务消费次数 | +| 历史消费金额 | 各业务消费金额 | +| 最近消费时间 | 最后一次消费时间 | +| 最近消费业务 | 最后一次消费的业务 | +| 服务偏好 | 常用的服务类目 | + +**营销信息:** + +| 字段 | 说明 | +|------|------| +| 触达次数 | 累计营销触达次数 | +| 最近触达时间 | 最后一次触达时间 | +| 触达渠道 | 短信/电话/推送 | +| 触达反馈 | 已读/已点击/已领券/无响应 | +| 营销标签 | 系统自动打标 | + +#### 4.0.5 客户状态流转 + +``` +┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ +│ 潜在 │ ──→│ 已触达 │ ──→│ 已转化 │ ──→│ 会员 │ +└────────┘ └────────┘ └────────┘ └────────┘ + │ ↑ + │ ┌────────┐ │ + └────────→│ 无效 │ │ + └────────┘ │ + │ + (领券/注册)────────────────┘ +``` + +**状态说明:** + +| 状态 | 定义 | 说明 | +|------|------|------| +| 潜在 | 新进入客户池 | 尚未进行任何营销触达 | +| 已触达 | 已进行营销 | 已发送短信/推送/外呼,但未转化 | +| 已转化 | 完成转化动作 | 已领券或已注册,进入会员池 | +| 无效 | 无效客户 | 空号/拒绝/投诉/重复 | + +#### 4.0.6 客户数据去重合并 + +**去重规则:** + +| 规则 | 说明 | +|------|------| +| 主键 | 手机号作为唯一标识 | +| 合并策略 | 同一手机号多条记录合并为一条 | +| 优先级 | 最新数据覆盖旧数据(姓名、城市等) | +| 累加字段 | 消费次数、消费金额累加 | +| 保留字段 | 最早来源时间、所有来源渠道 | + +**合并示例:** + +| 来源 | 手机号 | 姓名 | 消费次数 | 消费金额 | +|------|--------|------|----------|----------| +| 道路救援 | 138****1234 | 张三 | 2次 | 500元 | +| 代驾 | 138****1234 | 张先生 | 5次 | 400元 | + +**合并后:** + +| 手机号 | 姓名 | 来源渠道 | 总消费次数 | 总消费金额 | +|--------|------|----------|------------|------------| +| 138****1234 | 张先生 | 道路救援、代驾 | 7次 | 900元 | + +#### 4.0.7 客户标签体系 + +**自动标签:** + +| 标签类型 | 标签示例 | 生成规则 | +|----------|----------|----------| +| 来源标签 | 道路救援客户、代驾客户 | 根据来源渠道 | +| 价值标签 | 高价值、中价值、低价值 | 根据历史消费金额 | +| 活跃标签 | 近期活跃、沉默客户 | 根据最近消费时间 | +| 偏好标签 | 救援偏好、代驾偏好 | 根据消费频次 | +| 响应标签 | 高响应、低响应 | 根据营销触达反馈 | + +**自定义标签:** + +| 操作 | 说明 | +|------|------| +| 手动打标 | 运营手动为客户添加标签 | +| 批量打标 | 筛选客户后批量添加标签 | +| 标签管理 | 创建/编辑/删除自定义标签 | + +#### 4.0.8 客户分群 + +**功能说明:** + +根据客户属性和标签,创建客户分群,用于精准营销。 + +**分群条件:** + +| 条件类型 | 可选条件 | +|----------|----------| +| 基础属性 | 城市、性别、来源渠道、来源时间 | +| 业务属性 | 关联业务、消费次数、消费金额、最近消费时间 | +| 营销属性 | 触达次数、触达反馈、客户状态 | +| 标签 | 任意标签组合 | + +**分群示例:** + +| 分群名称 | 筛选条件 | 客户数 | +|----------|----------|--------| +| 高价值未转化 | 消费金额>500 且 状态=潜在 | 5,000 | +| 代驾沉默客户 | 来源=代驾 且 最近消费>90天 且 状态≠已转化 | 8,000 | +| 近期新客户 | 来源时间≤30天 且 状态=潜在 | 3,000 | + +#### 4.0.9 营销触达 + +**触达渠道:** + +| 渠道 | 说明 | 成本 | +|------|------|------| +| 短信 | 发送营销短信 | 约0.05元/条 | +| APP推送 | 各业务APP推送(需已安装) | 免费 | +| 小程序消息 | 小程序订阅消息(需已授权) | 免费 | +| 外呼 | 人工/AI外呼 | 约0.5元/通 | + +**触达任务创建:** + +| 字段 | 说明 | +|------|------| +| 任务名称 | 如"618高价值客户召回" | +| 目标分群 | 选择客户分群 | +| 触达渠道 | 短信/推送/外呼 | +| 触达内容 | 消息内容/话术模板 | +| 关联优惠券 | 选择发放的优惠券 | +| 触达时间 | 立即/定时 | +| 触达频率 | 单次/多次(间隔天数) | + +**触达内容示例:** + +``` +【XX出行】尊敬的客户,感谢您使用我们的服务! +现送您一张20元代驾券,点击领取:xxx.com/c/abc123 +退订回T +``` + +#### 4.0.10 转化路径 + +**转化动作定义:** + +| 转化动作 | 说明 | 转化结果 | +|----------|------|----------| +| 领取优惠券 | 客户点击领券链接领取 | 进入会员池 | +| 注册会员 | 客户主动注册会员中心 | 进入会员池 | +| 首次下单 | 客户完成首次消费 | 进入会员池 | + +**转化流程:** + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 营销触达 │ ──→│ 点击链接 │ ──→│ 领券/注册 │ ──→│ 成为会员 │ +│ (短信) │ │ │ │ │ │ │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ↓ + ┌──────────────┐ + │ 同步至会员池 │ + │ 自动建立账号 │ + │ 发放优惠券 │ + └──────────────┘ +``` + +**领券链接机制:** + +| 字段 | 说明 | +|------|------| +| 短链接 | 如 xxx.com/c/abc123 | +| 关联客户 | 绑定客户ID,用于追踪转化 | +| 关联优惠券 | 领取后自动发放的券 | +| 有效期 | 链接有效期 | +| 领取后动作 | 跳转小程序/APP/H5 | + +#### 4.0.11 客户池数据统计 + +**概览指标:** + +| 指标 | 定义 | +|------|------| +| 客户池总量 | 客户池中客户总数 | +| 潜在客户数 | 状态为"潜在"的客户数 | +| 已触达客户数 | 状态为"已触达"的客户数 | +| 已转化客户数 | 状态为"已转化"的客户数 | +| 转化率 | 已转化 / (潜在+已触达+已转化) | +| 本月新增客户 | 本月新进入客户池的数量 | +| 本月转化客户 | 本月转化为会员的数量 | + +**来源分析:** + +| 来源渠道 | 客户数 | 占比 | 转化数 | 转化率 | +|----------|--------|------|--------|--------| +| 道路救援 | 50,000 | 40% | 8,000 | 16% | +| 代驾 | 35,000 | 28% | 7,000 | 20% | +| 到家 | 25,000 | 20% | 4,000 | 16% | +| 线下渠道 | 10,000 | 8% | 2,500 | 25% | +| 合作渠道 | 5,000 | 4% | 500 | 10% | + +**营销效果分析:** + +| 指标 | 定义 | +|------|------| +| 触达人数 | 本次营销触达的客户数 | +| 送达率 | 成功送达 / 触达人数 | +| 打开率 | 打开/点击 / 送达人数 | +| 领券人数 | 领取优惠券的客户数 | +| 领券率 | 领券人数 / 触达人数 | +| 转化人数 | 转化为会员的客户数 | +| 转化率 | 转化人数 / 触达人数 | +| 单客成本 | 营销成本 / 转化人数 | + +#### 4.0.12 管理后台功能结构 + +``` +客户池管理 +├── 客户概览 +│ ├── 核心指标卡片 +│ ├── 来源分布图 +│ ├── 转化漏斗 +│ └── 趋势图表 +├── 客户列表 +│ ├── 客户搜索(手机号/姓名) +│ ├── 高级筛选 +│ ├── 客户详情 +│ ├── 手动打标 +│ └── 批量操作 +├── 客户导入 +│ ├── 批量导入(Excel) +│ ├── 导入模板下载 +│ ├── 导入记录 +│ └── 导入数据校验 +├── 客户分群 +│ ├── 分群列表 +│ ├── 创建分群 +│ ├── 分群详情 +│ └── 分群更新(手动/自动) +├── 标签管理 +│ ├── 系统标签 +│ ├── 自定义标签 +│ └── 标签统计 +├── 营销触达 +│ ├── 触达任务列表 +│ ├── 创建触达任务 +│ ├── 任务详情 +│ ├── 任务执行记录 +│ └── 效果分析 +├── 转化追踪 +│ ├── 转化记录 +│ ├── 转化路径分析 +│ └── 领券链接管理 +└── 数据统计 + ├── 客户池分析 + ├── 来源分析 + ├── 营销效果分析 + └── 转化分析 +``` + +#### 4.0.13 数据同步与对接 + +**各业务系统同步接口:** + +| 接口 | 说明 | +|------|------| +| 客户数据推送 | 业务系统推送新客户数据至中台 | +| 客户数据拉取 | 中台定时拉取业务系统客户数据 | +| 批量同步 | 一次性同步历史客户数据 | +| 转化回写 | 客户转化后,回写至业务系统 | + +**同步频率:** + +| 同步类型 | 频率 | +|----------|------| +| 新客户 | 实时推送 | +| 客户属性更新 | 每日凌晨同步 | +| 历史数据 | 一次性批量导入 | + +#### 4.0.14 权限设计 + +| 角色 | 权限 | +|------|------| +| 集团运营 | 全部客户数据、全部分群、全部营销 | +| BU运营 | 本业务来源客户、本业务分群、本业务营销 | +| 数据分析 | 查看数据统计(只读) | + +--- + +### 4.1 统一账号体系 + +#### 4.1.1 功能说明 + +为新用户建立统一账号,支持三个业务系统共用。用户在任一业务注册或通过营销转化后,自动创建中台统一账号。 + +#### 4.1.2 账号注册 + +**注册字段:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 手机号 | 是 | 唯一标识 | +| 验证码 | 是 | 短信验证 | +| 昵称 | 否 | 用户自定义 | +| 头像 | 否 | 用户自定义 | + +**注册来源:** + +| 来源 | 说明 | +|------|------| +| 会员小程序 | 用户主动注册 | +| 营销转化 | 客户领券后自动创建 | +| 业务系统 | 在业务APP注册后同步 | + +#### 4.1.3 账号登录 + +| 登录方式 | 说明 | +|----------|------| +| 短信验证码 | 手机号+验证码 | +| 微信授权 | 小程序微信登录 | +| 一键登录 | 手机号一键登录 | + +#### 4.1.4 账号绑定 + +| 绑定内容 | 说明 | +|----------|------| +| 微信绑定 | 绑定微信openid | +| 业务账号关联 | 关联三个业务系统的用户ID | + +#### 4.1.5 业务规则 + +| 规则 | 说明 | +|------|------| +| 唯一标识 | 手机号作为唯一标识 | +| 新用户管理 | 只管理新用户,老用户保持现状 | +| 账号同步 | 新用户在任一业务注册,自动同步至中台 | +| 数据共享 | 用户在任一业务登录,可查看所有业务订单 | + +--- + +### 4.2 会员等级体系 + +#### 4.2.1 等级定义 + +| 等级 | 名称 | 综合积分要求 | 有效期 | +|------|------|-------------|--------| +| Lv1 | 普通会员 | 0 | 永久 | +| Lv2 | 白银会员 | 1,000 | 1年 | +| Lv3 | 黄金会员 | 5,000 | 1年 | +| Lv4 | 铂金会员 | 20,000 | 1年 | +| Lv5 | 钻石会员 | 50,000 | 1年 | + +#### 4.2.2 综合积分计算公式 + +``` +综合积分 = 消费金额积分 + 消费次数积分 + 活动积分 + +消费金额积分 = 累计消费金额 × 1(1元=1分) +消费次数积分 = 累计消费次数 × 100(1次=100分) +活动积分 = 参与活动获得积分 +``` + +**计算说明:** + +| 积分类型 | 计算规则 | 示例 | +|----------|----------|------| +| 消费金额积分 | 1元=1分 | 消费500元=500分 | +| 消费次数积分 | 1次=100分 | 消费5次=500分 | +| 活动积分 | 按活动规则 | 参与活动获得200分 | + +#### 4.2.3 等级权益 + +**基础权益矩阵:** + +| 权益项 | 普通 | 白银 | 黄金 | 铂金 | 钻石 | +|--------|------|------|------|------|------| +| 道路救援折扣 | 无 | 95折 | 9折 | 85折 | 8折 | +| 代驾服务折扣 | 无 | 95折 | 9折 | 85折 | 8折 | +| 到家服务折扣 | 无 | 95折 | 9折 | 85折 | 8折 | +| 专属客服 | ❌ | ❌ | ✅ | ✅ | ✅ | +| 优先派单 | ❌ | ❌ | ❌ | ✅ | ✅ | +| 生日礼包 | ❌ | ❌ | ✅ | ✅ | ✅ | +| 每月免费券 | 0张 | 1张 | 2张 | 3张 | 5张 | + +**按服务类目配置折扣(可选):** + +| 等级 | 拖车服务 | 搭电服务 | 换胎服务 | 小修服务 | +|------|----------|----------|----------|----------| +| 钻石 | 8折 | 7折 | 85折 | 8折 | + +#### 4.2.4 等级升降规则 + +**升级规则:** + +| 规则 | 说明 | +|------|------| +| 触发条件 | 综合积分达到门槛 | +| 生效时间 | 立即生效 | +| 通知 | 推送升级通知 | + +**降级规则:** + +| 规则 | 说明 | +|------|------| +| 触发条件 | 等级有效期满后重新计算 | +| 计算方式 | 按当前积分重新定级 | +| 保护期 | 降级前30天提醒 | + +**保级规则:** + +| 规则 | 说明 | +|------|------| +| 保级条件 | 有效期内消费满足条件 | +| 有效期延长 | 满足条件延长1年 | + +#### 4.2.5 等级有效期 + +| 等级 | 有效期 | 续期条件 | +|------|--------|----------| +| 普通会员 | 永久 | - | +| 白银及以上 | 1年 | 有效期内综合积分≥当前等级门槛 | + +--- + +### 4.3 积分体系 + +#### 4.3.1 积分获取规则 + +| 行为 | 获得积分 | 说明 | +|------|----------|------| +| 消费 | 1元=1积分 | 实付金额计算 | +| 首次注册 | 100积分 | 一次性 | +| 完善资料 | 50积分 | 一次性 | +| 每日签到 | 5积分 | 每日上限5分 | +| 邀请好友 | 200积分/人 | 好友首次消费后发放 | +| 评价订单 | 10积分/单 | 每日上限3单 | + +#### 4.3.2 积分使用规则 + +| 规则项 | 说明 | +|--------|------| +| 抵扣比例 | 100积分=1元 | +| 单笔上限 | 订单金额的20% | +| 使用门槛 | 满100积分可用 | +| 有效期 | 获得之日起12个月 | + +#### 4.3.3 积分兑换规则 + +| 规则项 | 说明 | +|--------|------| +| 兑换比例 | 1:1(A业务积分=B业务积分) | +| 兑换门槛 | 满500积分可兑换 | +| 成本承担 | 积分来源业务承担 | + +#### 4.3.4 积分有效期 + +| 规则 | 说明 | +|------|------| +| 有效期 | 获得之日起12个月 | +| 过期提醒 | 过期前30天/7天/1天提醒 | +| 过期处理 | 自动清零,不可恢复 | + +#### 4.3.5 积分跨业务流转 + +**流转场景:** + +``` +用户在道路救援消费获得1000积分 + ↓ +用户在代驾服务使用500积分抵扣5元 + ↓ +道路救援需向代驾支付5元积分成本 +``` + +**流转规则:** + +| 规则 | 说明 | +|------|------| +| 实时抵扣 | 用户可在任意业务使用积分 | +| 来源追溯 | 记录积分来源业务 | +| 月度结算 | 跨业务积分成本按月结算 | + +#### 4.3.6 积分成本结算 + +| 规则项 | 说明 | +|--------|------| +| 结算周期 | 月度结算 | +| 结算方式 | 积分来源方向使用方支付 | +| 结算单价 | 100积分=1元 | +| 账单生成 | 每月1日生成上月账单 | + +--- + +### 4.4 优惠券中心 + +#### 4.4.1 优惠券类型(按面值) + +| 类型 | 说明 | 示例 | +|------|------|------| +| 抵扣券 | 满足条件减固定金额 | 满100减20 | +| 折扣券 | 满足条件打折 | 满100打8折 | +| 全免券 | 免费享受服务一次 | 代驾服务全免券 | +| 现金券 | 无门槛直接抵扣 | 10元无门槛券 | + +#### 4.4.2 优惠券流转类型(按来源) + +| 类型 | 服务券 | 引流券 | +|------|--------|--------| +| 定义 | A业务向B业务采购 | B业务免费提供给A业务发放 | +| 成本 | 采购方承担 | 提供方承担 | +| 目的 | 丰富自身权益 | 为自身业务引流 | +| 结算 | 按采购价预付 | 无需结算 | + +#### 4.4.3 优惠券属性定义 + +**基础属性:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 券名称 | 是 | 如"代驾10元抵扣券" | +| 券类型 | 是 | 抵扣券/折扣券/全免券/现金券 | +| 适用业务 | 是 | 道路救援/代驾/到家/通用 | +| 适用服务 | 否 | 具体服务类目,不填则全业务可用 | + +**面值规则:** + +| 券类型 | 配置项 | +|--------|--------| +| 抵扣券 | 使用门槛(满X元)、抵扣金额 | +| 折扣券 | 使用门槛(满X元)、折扣比例、最高抵扣金额 | +| 全免券 | 适用服务、最高免除金额 | +| 现金券 | 抵扣金额(无门槛) | + +**有效期规则:** + +| 规则类型 | 说明 | +|----------|------| +| 固定日期 | 指定开始日期-结束日期 | +| 领取后生效 | 领取后N天内有效 | +| 激活后生效 | 激活后N天内有效 | + +**适用范围配置:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 适用范围类型 | 是 | 全部服务/指定业务/指定类目/指定服务 | +| 适用业务线 | 条件 | 选择一级类目 | +| 适用服务大类 | 条件 | 选择二级类目(可多选) | +| 适用具体服务 | 条件 | 选择三级类目(可多选) | +| 排除服务 | 否 | 指定不可用的服务 | + +**使用规则:** + +| 字段 | 说明 | +|------|------| +| 叠加规则 | 是否可与其他券叠加使用 | +| 每人限领 | 每人最多领取张数 | +| 每人限用 | 每人最多使用张数 | +| 使用时段 | 如仅限工作日使用 | + +#### 4.4.4 券池管理 + +**券池状态流转:** + +``` +┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ +│ 已创建 │ ──→│ 在池中 │ ──→│ 已发放 │ ──→│ 已激活 │ +└────────┘ └────────┘ └────────┘ └────────┘ + │ │ + ↓ ↓ + ┌────────┐ ┌────────┐ + │ 已导出 │ │ 已使用 │ + └────────┘ └────────┘ + │ + ↓ + ┌────────┐ + │ 已过期 │ + └────────┘ +``` + +**状态说明:** + +| 状态 | 说明 | +|------|------| +| 已创建 | 券批次已创建,待入池 | +| 在池中 | 在券池中待分配 | +| 已发放 | 已分配给业务方,但未到用户手中 | +| 已导出 | 已导出券码,待线下发放 | +| 已激活 | 用户已领取/绑定,券已激活 | +| 已使用 | 用户已核销使用 | +| 已过期 | 超过有效期未使用 | + +**创建券批次:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 批次名称 | 是 | 如"2024年618代驾券" | +| 券模板 | 是 | 选择已有模板或新建 | +| 生成数量 | 是 | 本批次生成张数 | +| 券码规则 | 是 | 系统生成/自定义前缀 | +| 成本归属 | 是 | 哪个业务承担成本 | +| 备注 | 否 | 批次说明 | + +**券码生成规则:** + +``` +券码格式:[业务前缀][批次号][序列号][校验码] +示例:DJ2406001234X + +DJ = 代驾业务 +2406 = 2024年6月 +001234 = 序列号 +X = 校验码 +``` + +**券池操作:** + +| 操作 | 说明 | +|------|------| +| 批量分配 | 从池中分配N张给指定业务方 | +| 批量导出 | 导出券码Excel,含券码、面值、有效期 | +| 批量作废 | 作废未激活的券 | +| 查看明细 | 查看每张券的状态和流转记录 | + +**导出文件格式:** + +| 列名 | 示例 | +|------|------| +| 券码 | DJ2406001234X | +| 券名称 | 代驾10元抵扣券 | +| 券类型 | 抵扣券 | +| 面值/折扣 | 10元 | +| 使用门槛 | 满50元可用 | +| 有效期 | 激活后30天 | +| 适用业务 | 代驾服务 | + +**券激活方式:** + +| 方式 | 说明 | +|------|------| +| 用户输入券码 | 用户在小程序输入券码激活 | +| 扫码激活 | 券码生成二维码,用户扫码激活 | +| 系统发放激活 | 通过系统直接发放给用户,自动激活 | +| API激活 | 业务系统调用API激活并绑定用户 | + +#### 4.4.5 大礼包/优惠卡管理 + +**优惠卡类型:** + +| 类型 | 说明 | 示例 | +|------|------|------| +| 销售卡 | 有售价,用户购买后获得 | 99元超值礼包 | +| 赠送卡 | 无售价,用于活动赠送 | 新人礼包 | +| 权益卡 | 会员等级专属礼包 | 黄金会员月度礼包 | + +**优惠卡属性:** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 卡名称 | 是 | 如"出行无忧大礼包" | +| 卡类型 | 是 | 销售卡/赠送卡/权益卡 | +| 售价 | 销售卡必填 | 用户购买价格 | +| 原价 | 否 | 用于展示划线价 | +| 卡面图 | 是 | 展示图片 | +| 卡说明 | 是 | 礼包介绍 | +| 有效期 | 是 | 购买后N天内可激活 | + +**包含权益示例:出行无忧大礼包(售价99元)** + +| 权益内容 | 数量 | 单张价值 | 总价值 | +|----------|------|----------|--------| +| 道路救援50元抵扣券 | 2张 | 50元 | 100元 | +| 代驾20元抵扣券 | 5张 | 20元 | 100元 | +| 到家服务8折券 | 3张 | 约15元 | 45元 | +| 积分 | 500分 | 5元 | 5元 | +| **合计** | - | - | **250元** | + +**优惠卡状态流转:** + +``` +┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ +│ 已创建 │ ──→│ 已上架 │ ──→│ 已售出 │ ──→│ 已激活 │ +└────────┘ └────────┘ └────────┘ └────────┘ + │ + ↓ + ┌────────┐ + │ 已下架 │ + └────────┘ +``` + +**销售规则:** + +| 字段 | 说明 | +|------|------| +| 每人限购 | 每人最多购买数量 | +| 发行总量 | 本批次总数量 | +| 销售时间 | 开始销售-结束销售 | +| 销售渠道 | 小程序/线下/全渠道 | + +**线下销售支持:** + +| 字段 | 说明 | +|------|------| +| 生成数量 | 本批次生成卡密数量 | +| 卡密格式 | 如:GIFT-XXXX-XXXX-XXXX | +| 激活密码 | 可选,双重验证 | + +**线下场景:** +- 业务终端采购礼包卡密 +- 向客户销售实体卡或卡密 +- 客户在小程序输入卡密激活 +- 券自动发放至客户账户 + +#### 4.4.6 服务券采购流程 + +**采购流程:** + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 需求方发起 │ ──→ │ 供给方审批 │ ──→ │ 券入库可用 │ +│ 采购申请 │ │ 确认数量价格 │ │ 完成结算 │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +**采购申请表单:** + +| 字段 | 说明 | +|------|------| +| 供给方 | 选择业务线 | +| 券模板 | 选择已有模板或新建 | +| 采购数量 | 本次采购张数 | +| 采购单价 | 协商价格 | +| 采购总额 | 数量×单价 | +| 申请备注 | 说明用途 | + +**审批规则:** +- 审批流程:需求方提交 → 供给方审批 → 完成 +- 审批权限:各BU运营人员 + +#### 4.4.7 引流券发放流程 + +**发放流程:** + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 供给方创建 │ ──→ │ 需求方申领 │ ──→ │ 供给方审批 │ +│ 引流券批次 │ │ 提交申请 │ │ 确认发放 │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +**核销数据统计(双方可见):** + +| 指标 | 说明 | +|------|------| +| 发放数量 | 已发给用户的券数量 | +| 领取数量 | 用户已领取的数量 | +| 使用数量 | 已核销数量 | +| 使用率 | 使用数量/领取数量 | +| 过期数量 | 未使用已过期数量 | +| 带动消费 | 使用该券的订单总额 | + +--- + +### 4.5 用户标签体系 + +#### 4.5.1 基础标签 + +| 标签类型 | 标签示例 | +|----------|----------| +| 人口属性 | 性别、年龄段、城市 | +| 业务偏好 | 常用道路救援、常用代驾、常用到家 | +| 消费能力 | 高消费、中消费、低消费 | +| 活跃度 | 活跃用户、沉默用户、流失用户 | +| 生命周期 | 新用户、成长用户、成熟用户、衰退用户 | + +#### 4.5.2 标签生成规则 + +| 标签 | 规则 | +|------|------| +| 高消费用户 | 近90天消费≥500元 | +| 活跃用户 | 近30天有消费记录 | +| 沉默用户 | 30-90天无消费 | +| 流失用户 | 90天以上无消费 | +| 跨业务用户 | 使用过≥2个业务 | + +#### 4.5.3 标签应用场景 + +| 场景 | 说明 | +|------|------| +| 精准推送 | 根据标签定向推送营销消息 | +| 定向发券 | 根据标签定向发放优惠券 | +| 用户分群 | 结合标签创建用户分群 | +| 数据分析 | 按标签维度分析用户数据 | + +--- + +### 4.6 消息推送 + +#### 4.6.1 推送类型 + +| 类型 | 触发条件 | 内容示例 | +|------|----------|----------| +| 优惠券到账 | 用户获得新券 | "您有一张10元代驾券已到账" | +| 优惠券过期 | 过期前3天/1天 | "您有1张优惠券即将过期" | +| 等级变动 | 升级/降级时 | "恭喜您升级为黄金会员" | +| 积分变动 | 获得/使用积分 | "本次消费获得100积分" | +| 营销活动 | 运营人员配置 | "618大促,全场8折" | + +#### 4.6.2 推送渠道 + +| 渠道 | 适用场景 | +|------|----------| +| 小程序消息 | 日常通知 | +| 短信 | 重要通知、营销活动 | +| APP推送 | 三个业务APP内推送 | + +#### 4.6.3 推送规则 + +| 规则 | 说明 | +|------|------| +| 用户偏好 | 用户可设置接收偏好 | +| 频率限制 | 单用户每日推送上限5条 | +| 授权要求 | 营销类消息需用户授权 | +| 时段限制 | 默认8:00-22:00推送 | + +#### 4.6.4 推送模板配置 + +| 配置项 | 说明 | +|--------|------| +| 模板名称 | 如"优惠券到账通知" | +| 推送渠道 | 选择推送渠道 | +| 消息标题 | 推送标题 | +| 消息内容 | 支持变量替换 | +| 跳转链接 | 点击后跳转页面 | + +--- + +### 4.7 服务类目管理 + +#### 4.7.1 功能说明 + +统一维护三大业务线的服务类目,支持多级分类,供优惠券、会员权益等模块调用。 + +#### 4.7.2 服务类目结构(三级) + +``` +服务类目(三级结构) +│ +├── 道路救援服务 +│ ├── 拖车服务 +│ │ ├── 市内拖车 +│ │ ├── 长途拖车 +│ │ └── 事故拖车 +│ ├── 小修服务 +│ │ ├── 现场快修 +│ │ └── 应急维修 +│ ├── 换胎服务 +│ │ ├── 备胎更换 +│ │ └── 新胎更换 +│ ├── 搭电服务 +│ │ ├── 电瓶搭电 +│ │ └── 电瓶更换 +│ ├── 送油服务 +│ ├── 困境救援 +│ └── 开锁服务 +│ +├── 代驾服务 +│ ├── 酒后代驾 +│ ├── 商务代驾 +│ ├── 长途代驾 +│ ├── 旅游代驾 +│ └── 其他代驾 +│ +└── 到家服务 + ├── 清洁服务 + │ ├── 日常保洁 + │ ├── 深度清洁 + │ ├── 开荒保洁 + │ └── 家电清洗 + ├── 维修服务 + │ ├── 家电维修 + │ ├── 水电维修 + │ ├── 门窗维修 + │ └── 管道疏通 + ├── 清洗服务 + │ ├── 油烟机清洗 + │ ├── 空调清洗 + │ ├── 洗衣机清洗 + │ └── 冰箱清洗 + └── 其他服务 + ├── 搬家服务 + └── 家具安装 +``` + +#### 4.7.3 类目属性 + +**一级类目(业务线):** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 类目编码 | 是 | 如 ROAD、DRIVER、HOME | +| 类目名称 | 是 | 道路救援服务 | +| 类目图标 | 否 | 展示图标 | +| 排序 | 是 | 显示顺序 | +| 状态 | 是 | 启用/停用 | + +**二级类目(服务大类):** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 类目编码 | 是 | 如 ROAD_TOW | +| 类目名称 | 是 | 拖车服务 | +| 上级类目 | 是 | 关联一级类目 | +| 类目图标 | 否 | 展示图标 | +| 排序 | 是 | 显示顺序 | +| 状态 | 是 | 启用/停用 | + +**三级类目(具体服务):** + +| 字段 | 必填 | 说明 | +|------|------|------| +| 类目编码 | 是 | 如 ROAD_TOW_CITY | +| 类目名称 | 是 | 市内拖车 | +| 上级类目 | 是 | 关联二级类目 | +| 服务描述 | 否 | 服务说明 | +| 参考价格 | 否 | 展示用 | +| 排序 | 是 | 显示顺序 | +| 状态 | 是 | 启用/停用 | + +#### 4.7.4 类目管理功能 + +| 功能 | 说明 | +|------|------| +| 类目列表 | 树形展示全部类目 | +| 新增类目 | 支持各级类目新增 | +| 编辑类目 | 修改类目信息 | +| 启用/停用 | 控制类目状态 | +| 调整排序 | 调整显示顺序 | +| 批量导入 | Excel批量导入类目 | + +#### 4.7.5 类目调用场景 + +| 调用模块 | 使用方式 | +|----------|----------| +| 优惠券使用规则 | 选择适用的服务类目 | +| 会员等级权益 | 设置不同服务的折扣 | +| 大礼包配置 | 指定券适用的服务 | +| 积分规则 | 按服务类型设置积分倍率 | +| 数据统计 | 按服务类目分析数据 | + +--- + +### 4.8 券结算对账管理 + +#### 4.8.1 功能说明 + +管理优惠券跨业务流转产生的费用结算,包括服务券采购结算、引流券核销统计、积分跨业务抵扣结算。 + +#### 4.8.2 结算类型 + +| 结算类型 | 付款方 | 收款方 | 结算依据 | +|----------|--------|--------|----------| +| 服务券采购 | 采购方 | 供给方 | 采购订单 | +| 引流券核销 | 券发行方 | - | 核销数据(仅记录,无需结算) | +| 积分跨业务抵扣 | 积分来源方 | 积分使用方 | 核销数据 | + +#### 4.8.3 结算周期 + +| 配置项 | 说明 | +|--------|------| +| 结算周期 | 月结(每月1日生成上月账单) | +| 账单确认期 | 5个工作日内确认 | +| 打款期限 | 确认后10个工作日内完成 | +| 对账日 | 每月1-5日 | + +#### 4.8.4 服务券采购结算 + +**结算流程:** + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 采购申请 │ ──→│ 审批通过 │ ──→│ 生成账单 │ ──→│ 对账确认 │ ──→│ 完成结算 │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +**采购订单明细:** + +| 字段 | 说明 | +|------|------| +| 采购单号 | 系统自动生成 | +| 采购方 | 发起采购的业务线 | +| 供给方 | 提供券的业务线 | +| 券模板 | 采购的券类型 | +| 采购数量 | 本次采购张数 | +| 单价 | 每张券采购价 | +| 采购总额 | 数量 × 单价 | +| 采购时间 | 订单创建时间 | +| 审批状态 | 待审批/已通过/已拒绝 | +| 结算状态 | 待结算/已出账/已确认/已打款 | + +**采购对账单:** + +| 字段 | 说明 | +|------|------| +| 账单编号 | 如 BILL-202406-001 | +| 账单周期 | 2024年6月 | +| 付款方 | 道路救援BU | +| 收款方 | 代驾BU | +| 采购总笔数 | 本周期采购订单数 | +| 采购总张数 | 本周期采购券总张数 | +| 应付总额 | 本周期应付金额 | +| 账单状态 | 待确认/已确认/已打款 | + +#### 4.8.5 引流券核销统计 + +**功能说明:** + +引流券成本由发行方承担,不产生跨BU结算,但需记录核销数据供双方查看。 + +**统计指标:** + +| 指标 | 说明 | +|------|------| +| 发放数量 | 分配给接收方的数量 | +| 领取数量 | 用户已领取数量 | +| 核销数量 | 用户已使用数量 | +| 核销金额 | 核销券的面值总额 | +| 带动消费 | 使用该券的订单总金额 | +| ROI | 带动消费 / 核销金额 | + +#### 4.8.6 积分跨业务抵扣结算 + +**结算规则:** + +``` +场景: +用户在道路救援消费获得1000积分 +用户在代驾服务使用500积分抵扣5元 + +结算: +- 代驾收取订单金额(已扣除积分抵扣部分) +- 道路救援需向代驾支付5元积分成本 +- 按月汇总结算 +``` + +**积分流转明细:** + +| 字段 | 说明 | +|------|------| +| 流水号 | 系统自动生成 | +| 用户ID | 使用积分的用户 | +| 积分来源 | 积分获得的业务线 | +| 积分使用 | 积分消耗的业务线 | +| 使用积分数 | 本次使用积分 | +| 抵扣金额 | 积分折算金额 | +| 关联订单 | 使用积分的订单号 | +| 使用时间 | 积分使用时间 | +| 结算状态 | 待结算/已出账/已确认/已打款 | + +**积分结算账单:** + +| 字段 | 说明 | +|------|------| +| 账单编号 | 如 POINT-202406-001 | +| 账单周期 | 2024年6月 | +| 付款方 | 积分来源方 | +| 收款方 | 积分使用方 | +| 总笔数 | 本周期积分抵扣订单数 | +| 总积分数 | 本周期跨业务使用积分总数 | +| 应付总额 | 本周期应付金额 | +| 账单状态 | 待确认/已确认/已打款 | + +#### 4.8.7 对账流程 + +**整体流程:** + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 账单生成 │ ──→│ 待确认 │ ──→│ 对账确认 │ ──→│ 财务打款 │ ──→│ 结算完成 │ +│ (系统) │ │ (双方) │ │ (双方) │ │ (财务) │ │ │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ↓ 有异议 + ┌──────────┐ + │ 异议处理 │ + └──────────┘ +``` + +**账单状态:** + +| 状态 | 说明 | +|------|------| +| 待生成 | 账单周期未结束 | +| 已生成 | 系统自动生成账单 | +| 待确认 | 等待双方确认 | +| 已确认 | 双方均已确认 | +| 有异议 | 一方或双方提出异议 | +| 待打款 | 确认完成,等待财务打款 | +| 已完成 | 打款完成,结算关闭 | + +**对账操作:** + +| 操作方 | 可执行操作 | +|--------|------------| +| 付款方 | 查看账单、确认账单、提出异议、查看打款记录 | +| 收款方 | 查看账单、确认账单、提出异议、确认收款 | + +**异议处理:** + +| 字段 | 说明 | +|------|------| +| 异议单号 | 系统自动生成 | +| 关联账单 | 异议的账单编号 | +| 异议方 | 提出异议的业务线 | +| 异议类型 | 金额有误/数据缺失/重复计算/其他 | +| 异议说明 | 详细描述问题 | +| 异议明细 | 勾选有问题的明细记录 | +| 期望金额 | 异议方认为正确的金额 | +| 处理状态 | 待处理/处理中/已解决/已驳回 | + +#### 4.8.8 结算报表 + +**月度结算汇总:** + +| 业务线 | 应收(服务券) | 应收(积分) | 应付(服务券) | 应付(积分) | 净结算 | +|--------|----------------|--------------|----------------|--------------|--------| +| 道路救援 | 5,000元 | 200元 | 15,500元 | 800元 | -11,100元 | +| 代驾 | 15,500元 | 800元 | 3,000元 | 150元 | +13,150元 | +| 到家 | 3,000元 | 150元 | 5,000元 | 200元 | -2,050元 | + +#### 4.8.9 结算通知 + +| 通知场景 | 接收人 | 通知方式 | +|----------|--------|----------| +| 账单生成 | 双方财务/运营 | 站内信+邮件 | +| 待确认提醒 | 未确认方 | 站内信+邮件 | +| 对方已确认 | 另一方 | 站内信 | +| 提出异议 | 对方 | 站内信+邮件 | +| 异议已处理 | 异议方 | 站内信 | +| 账单已完成 | 双方 | 站内信 | + +#### 4.8.10 对账权限 + +| 角色 | 权限 | +|------|------| +| 集团财务 | 全部账单查看、确认、打款操作 | +| 集团运营 | 全部账单查看、异议处理 | +| BU财务 | 本BU账单查看、确认、提出异议 | +| BU运营 | 本BU账单查看 | + +--- + +### 4.9 报表统计中心 + +#### 4.9.1 功能说明 + +提供集团及各BU多维度数据分析,支持会员、积分、优惠券、跨业务转化等核心指标的可视化展示和导出。 + +#### 4.9.2 报表体系结构 + +``` +报表统计中心 +├── 数据总览(首页看板) +├── 会员分析 +├── 等级分析 +├── 积分分析 +├── 优惠券分析 +├── 跨业务转化分析 +├── 服务消费分析 +├── 渠道分析 +└── 自定义报表 +``` + +#### 4.9.3 数据总览(首页看板) + +**核心指标卡片:** + +| 指标 | 定义 | 对比 | +|------|------|------| +| 总会员数 | 中台注册会员总数 | 较昨日/上周/上月 | +| 今日新增会员 | 当日新注册会员数 | 较昨日 | +| 活跃会员数 | 近30天有消费的会员数 | 较上月 | +| 跨业务用户数 | 使用过≥2个业务的用户数 | 较上月 | +| 今日消费金额 | 当日所有业务消费总额 | 较昨日 | +| 今日积分发放 | 当日发放积分总数 | 较昨日 | +| 今日券核销 | 当日核销优惠券张数 | 较昨日 | +| 待结算金额 | 本月待结算总额 | - | + +**趋势图表:** + +| 图表 | 类型 | 说明 | +|------|------|------| +| 会员增长趋势 | 折线图 | 近30天/12个月新增会员趋势 | +| 消费金额趋势 | 折线图 | 近30天/12个月消费金额趋势 | +| 业务占比 | 饼图 | 三大业务消费金额占比 | +| 活跃度分布 | 柱状图 | 活跃/沉默/流失用户占比 | + +**实时动态:** + +| 动态类型 | 示例 | +|----------|------| +| 新用户注册 | 用户138****1234刚刚注册成为会员 | +| 等级升级 | 用户139****5678升级为黄金会员 | +| 大额消费 | 用户137****9012消费500元(道路救援) | +| 跨业务转化 | 用户136****3456首次使用代驾服务 | + +#### 4.9.4 会员分析报表 + +**会员概览:** + +| 指标 | 定义 | +|------|------| +| 累计会员数 | 历史注册会员总数 | +| 有效会员数 | 未注销的会员数 | +| 新增会员数 | 指定周期新注册数 | +| 会员增长率 | (本期新增-上期新增)/上期新增 | + +**会员构成分析:** + +| 分析维度 | 说明 | +|----------|------| +| 按注册来源 | 各渠道注册会员占比 | +| 按地域分布 | 各省市会员分布 | +| 按注册时间 | 新老会员占比 | + +**会员活跃度分析:** + +| 分类 | 定义 | 运营建议 | +|------|------|----------| +| 高活跃 | 近7天有消费 | 维护关系 | +| 中活跃 | 近30天有消费 | 保持互动 | +| 低活跃 | 30-90天无消费 | 发券促活 | +| 沉默 | 90-180天无消费 | 短信召回 | +| 流失 | 180天以上无消费 | 重点召回 | + +**会员生命周期分析:** + +| 阶段 | 定义 | +|------|------| +| 新手期 | 注册≤30天 | +| 成长期 | 注册31-90天 | +| 成熟期 | 注册91-365天 | +| 衰退期 | 注册>365天且近90天无消费 | +| 忠诚期 | 注册>365天且持续消费 | + +**会员价值分析(RFM模型):** + +| 用户分群 | R(最近消费) | F(消费频次) | M(消费金额) | +|----------|---------------|---------------|---------------| +| 重要价值客户 | 高 | 高 | 高 | +| 重要发展客户 | 高 | 低 | 高 | +| 重要保持客户 | 低 | 高 | 高 | +| 重要挽留客户 | 低 | 低 | 高 | +| 一般价值客户 | 高 | 高 | 低 | +| 一般发展客户 | 高 | 低 | 低 | +| 一般保持客户 | 低 | 高 | 低 | +| 流失客户 | 低 | 低 | 低 | + +#### 4.9.5 等级分析报表 + +**等级分布:** + +| 等级 | 会员数 | 占比 | 平均消费 | 平均积分 | +|------|--------|------|----------|----------| +| 普通会员 | 80,000 | 64% | 100元 | 500分 | +| 白银会员 | 25,000 | 20% | 300元 | 2,000分 | +| 黄金会员 | 12,000 | 9.6% | 600元 | 8,000分 | +| 铂金会员 | 6,000 | 4.8% | 1,200元 | 25,000分 | +| 钻石会员 | 2,000 | 1.6% | 3,000元 | 60,000分 | + +**等级变动分析:** + +| 变动类型 | 指标 | +|----------|------| +| 各等级升级人数 | 本月升级至各等级的人数 | +| 降级人数 | 本月降级的人数 | +| 变动趋势 | 环比变化 | + +**等级权益使用分析:** + +| 指标 | 说明 | +|------|------| +| 折扣使用次数 | 各等级使用折扣次数 | +| 折扣金额 | 各等级折扣总金额 | +| 专属券领取/使用 | 各等级专属券数据 | + +#### 4.9.6 积分分析报表 + +**积分概览:** + +| 指标 | 说明 | +|------|------| +| 累计发放积分 | 历史发放积分总数 | +| 累计消耗积分 | 历史消耗积分总数 | +| 当前积分余额 | 未使用积分总数 | +| 积分消耗率 | 累计消耗/累计发放 | +| 本月发放/消耗 | 当月积分数据 | + +**积分获取渠道分析:** + +| 获取方式 | 占比 | +|----------|------| +| 消费获取 | 60% | +| 签到获取 | 20% | +| 活动获取 | 12.5% | +| 邀请好友 | 5% | +| 其他 | 2.5% | + +**积分使用渠道分析:** + +| 使用方式 | 占比 | +|----------|------| +| 订单抵扣 | 83.3% | +| 积分兑换 | 11.1% | +| 跨业务抵扣 | 5.6% | + +**积分跨业务流转分析:** + +| 来源业务 | 使用业务 | 流转积分 | 抵扣金额 | +|----------|----------|----------|----------| +| 道路救援 | 代驾 | 50,000分 | 500元 | +| 道路救援 | 到家 | 30,000分 | 300元 | +| 代驾 | 道路救援 | 20,000分 | 200元 | +| ... | ... | ... | ... | + +**积分过期预警:** + +| 过期时间 | 会员数 | 即将过期积分 | +|----------|--------|--------------| +| 7天内 | 5,000 | 500,000分 | +| 30天内 | 15,000 | 1,500,000分 | +| 90天内 | 30,000 | 3,000,000分 | + +#### 4.9.7 优惠券分析报表 + +**优惠券概览:** + +| 指标 | 说明 | +|------|------| +| 累计创建券 | 历史创建优惠券总数 | +| 累计发放券 | 历史发放优惠券总数 | +| 累计核销券 | 历史核销优惠券总数 | +| 核销率 | 核销数/发放数 | +| 券带动消费 | 使用券的订单总金额 | + +**按券类型分析:** + +| 券类型 | 发放量 | 核销量 | 核销率 | 抵扣金额 | 带动消费 | ROI | +|--------|--------|--------|--------|----------|----------|-----| +| 抵扣券 | 30,000张 | 20,000张 | 66.7% | 400,000元 | 1,200,000元 | 3.0 | +| 折扣券 | 15,000张 | 10,000张 | 66.7% | 150,000元 | 600,000元 | 4.0 | +| 全免券 | 3,000张 | 2,500张 | 83.3% | 200,000元 | 200,000元 | 1.0 | +| 现金券 | 2,000张 | 1,500张 | 75% | 30,000元 | 80,000元 | 2.7 | + +**按业务线分析:** + +| 业务线 | 发放量 | 核销量 | 核销率 | 带动消费 | +|--------|--------|--------|--------|----------| +| 道路救援 | 20,000张 | 12,000张 | 60% | 800,000元 | +| 代驾 | 18,000张 | 13,000张 | 72.2% | 500,000元 | +| 到家 | 12,000张 | 8,000张 | 66.7% | 400,000元 | + +**按流转类型分析:** + +| 流转类型 | 发放量 | 核销量 | 核销率 | +|----------|--------|--------|--------| +| 自有券 | 30,000张 | 20,000张 | 66.7% | +| 服务券(采购) | 15,000张 | 10,000张 | 66.7% | +| 引流券 | 5,000张 | 3,000张 | 60% | + +**大礼包销售分析:** + +| 礼包名称 | 售价 | 销售量 | 销售额 | 激活量 | 激活率 | 券核销率 | +|----------|------|--------|--------|--------|--------|----------| +| 出行无忧礼包 | 99元 | 5,000 | 495,000元 | 4,500 | 90% | 75% | +| 新人专享礼包 | 0元 | 10,000 | 0元 | 8,000 | 80% | 60% | + +#### 4.9.8 跨业务转化分析报表 + +**转化概览:** + +| 指标 | 说明 | +|------|------| +| 跨业务用户数 | 使用过≥2个业务的用户数 | +| 跨业务用户占比 | 跨业务用户/总会员数 | +| 跨业务转化率 | 新增跨业务用户/单业务用户 | +| 跨业务带动消费 | 跨业务用户在新业务的消费 | + +**用户业务分布:** + +| 使用业务数 | 用户数 | 占比 | 平均消费 | +|------------|--------|------|----------| +| 仅1个业务 | 95,000 | 76% | 200元 | +| 2个业务 | 25,000 | 20% | 500元 | +| 3个业务 | 5,000 | 4% | 1,200元 | + +**跨业务转化路径:** + +| 来源业务 | 目标业务 | 转化人数 | 转化率 | 首单消费 | 复购率 | +|----------|----------|----------|--------|----------|--------| +| 道路救援 → | 代驾 | 8,000 | 16% | 80元 | 35% | +| 道路救援 → | 到家 | 5,000 | 10% | 120元 | 28% | +| 代驾 → | 道路救援 | 6,000 | 17.1% | 200元 | 40% | +| 代驾 → | 到家 | 4,000 | 11.4% | 100元 | 30% | +| 到家 → | 道路救援 | 3,000 | 12% | 180元 | 25% | +| 到家 → | 代驾 | 4,500 | 18% | 70元 | 32% | + +**转化漏斗分析示例(道路救援 → 代驾):** + +| 阶段 | 人数 | 转化率 | +|------|------|--------| +| 道路救援用户 | 50,000 | 100% | +| 曝光代驾服务 | 40,000 | 80% | +| 领取代驾券 | 20,000 | 50% | +| 使用代驾服务 | 8,000 | 40% | +| 代驾复购 | 2,800 | 35% | + +**引流券效果分析:** + +| 引流方向 | 发放量 | 核销量 | 转化率 | 带动消费 | ROI | +|----------|--------|--------|--------|----------|-----| +| 代驾→道路救援 | 10,000张 | 2,000张 | 33.3% | 400,000元 | 5.0 | +| 代驾→到家 | 8,000张 | 1,500张 | 30% | 150,000元 | 3.0 | +| 道路救援→代驾 | 12,000张 | 3,000张 | 37.5% | 240,000元 | 4.0 | +| ... | ... | ... | ... | ... | ... | + +#### 4.9.9 服务消费分析报表 + +**消费概览:** + +| 指标 | 说明 | +|------|------| +| 总消费金额 | 全部业务消费总额 | +| 总订单数 | 全部业务订单总数 | +| 平均客单价 | 总消费/总订单 | +| 付费会员数 | 有消费的会员数 | +| 人均消费 | 总消费/付费会员数 | + +**按业务线分析:** + +| 业务线 | 消费金额 | 占比 | 订单数 | 客单价 | +|--------|----------|------|--------|--------| +| 道路救援 | 5,000,000元 | 50% | 25,000单 | 200元 | +| 代驾 | 3,000,000元 | 30% | 40,000单 | 75元 | +| 到家 | 2,000,000元 | 20% | 15,000单 | 133元 | + +**按服务类目分析:** + +| 服务类目 | 消费金额 | 占比 | 订单数 | 客单价 | +|----------|----------|------|--------|--------| +| 拖车服务 | 2,500,000元 | 50% | 8,000单 | 312.5元 | +| 搭电服务 | 1,000,000元 | 20% | 8,000单 | 125元 | +| ... | ... | ... | ... | ... | + +**消费时段分析:** + +| 时段 | 订单数 | 占比 | 主要业务 | +|------|--------|------|----------| +| 00:00-06:00 | 8,000单 | 10% | 代驾、道路救援 | +| 06:00-12:00 | 16,000单 | 20% | 到家服务 | +| 12:00-18:00 | 24,000单 | 30% | 到家服务、道路救援 | +| 18:00-24:00 | 32,000单 | 40% | 代驾、道路救援 | + +#### 4.9.10 渠道分析报表 + +**渠道概览:** + +| 渠道 | 新增会员 | 占比 | 消费转化率 | 带来消费 | +|------|----------|------|------------|----------| +| 道路救援APP | 5,000 | 33.3% | 60% | 600,000元 | +| 代驾小程序 | 4,000 | 26.7% | 55% | 350,000元 | +| 到家服务APP | 3,000 | 20% | 50% | 300,000元 | +| 会员中心小程序 | 2,000 | 13.3% | 40% | 200,000元 | +| 线下渠道 | 1,000 | 6.7% | 70% | 150,000元 | + +**礼包销售渠道分析:** + +| 渠道 | 销售量 | 销售额 | 占比 | +|------|--------|--------|------| +| 会员小程序 | 3,000 | 297,000元 | 40% | +| 道路救援APP | 2,000 | 198,000元 | 26.7% | +| 代驾小程序 | 1,500 | 148,500元 | 20% | +| 线下终端 | 1,000 | 99,000元 | 13.3% | + +**渠道ROI分析:** + +| 渠道 | 投入成本 | 带来消费 | ROI | +|------|----------|----------|-----| +| 线上自然流量 | 0元 | 800,000元 | ∞ | +| 短信营销 | 10,000元 | 150,000元 | 15.0 | +| APP推送 | 5,000元 | 100,000元 | 20.0 | +| 线下推广 | 50,000元 | 200,000元 | 4.0 | + +#### 4.9.11 自定义报表 + +**功能说明:** + +支持用户自定义筛选条件和展示维度,生成个性化报表。 + +**可选维度:** + +| 维度类型 | 可选项 | +|----------|--------| +| 时间维度 | 日/周/月/季度/年/自定义 | +| 业务维度 | 道路救援/代驾/到家/全部 | +| 服务维度 | 一级类目/二级类目/三级类目 | +| 用户维度 | 会员等级/活跃度/地域/来源渠道 | +| 券维度 | 券类型/流转类型/批次 | + +**可选指标:** + +| 指标类型 | 可选项 | +|----------|--------| +| 会员指标 | 会员数/新增数/活跃数/流失数 | +| 消费指标 | 消费金额/订单数/客单价/复购率 | +| 积分指标 | 发放积分/消耗积分/积分余额 | +| 优惠券指标 | 发放量/核销量/核销率/抵扣金额 | +| 转化指标 | 转化率/转化人数/带动消费 | + +**报表保存与分享:** + +| 功能 | 说明 | +|------|------| +| 保存报表 | 保存当前配置为模板 | +| 我的报表 | 查看已保存的报表 | +| 定时发送 | 设置每日/周/月自动发送 | +| 导出 | 支持Excel/PDF/图片导出 | +| 分享 | 生成链接分享给他人 | + +#### 4.9.12 报表导出与订阅 + +**导出功能:** + +| 导出格式 | 说明 | +|----------|------| +| Excel | 支持数据表格导出 | +| PDF | 支持图表完整导出 | +| 图片 | 支持单个图表导出 | + +**订阅功能:** + +| 字段 | 说明 | +|------|------| +| 报表选择 | 勾选需要订阅的报表 | +| 发送频率 | 日报/周报/月报 | +| 发送时间 | 自定义发送时间 | +| 接收方式 | 邮件/站内信 | +| 接收人 | 选择接收人员 | + +#### 4.9.13 报表权限 + +| 角色 | 可查看报表 | +|------|------------| +| 集团管理员 | 全部报表 | +| 集团运营 | 全部报表(不含财务数据) | +| 集团财务 | 全部报表 | +| BU运营 | 本业务相关报表 | +| BU财务 | 本业务财务报表 | +| 数据分析师 | 全部报表(只读) | + +--- + +## 五、管理后台设计(续) + +### 5.1 功能结构图 + +``` +会员中台管理后台 +├── 首页看板 +│ ├── 核心数据概览 +│ ├── 业务趋势图 +│ └── 实时动态 +├── 客户池管理 +│ ├── 客户概览 +│ ├── 客户列表 +│ ├── 客户导入 +│ ├── 客户分群 +│ ├── 标签管理 +│ ├── 营销触达 +│ ├── 转化追踪 +│ └── 数据统计 +├── 会员管理 +│ ├── 会员列表 +│ ├── 会员详情 +│ └── 会员标签 +├── 等级管理 +│ ├── 等级配置 +│ ├── 权益配置 +│ └── 升降级规则 +├── 积分管理 +│ ├── 积分规则配置 +│ ├── 积分流水查询 +│ └── 积分结算报表 +├── 优惠券管理 +│ ├── 券模板管理 +│ ├── 券池管理 +│ ├── 大礼包管理 +│ ├── 发放管理 +│ └── 数据统计 +├── 服务类目管理 +│ ├── 类目列表(树形) +│ ├── 新增类目 +│ ├── 编辑类目 +│ ├── 启用/停用 +│ └── 批量导入 +├── 消息中心 +│ ├── 推送任务管理 +│ ├── 模板配置 +│ └── 推送记录 +├── 结算对账管理 +│ ├── 结算总览 +│ ├── 采购结算 +│ ├── 积分结算 +│ ├── 引流券统计 +│ ├── 异议管理 +│ ├── 结算报表 +│ └── 结算设置 +├── 报表统计中心 +│ ├── 数据总览 +│ ├── 会员分析 +│ ├── 等级分析 +│ ├── 积分分析 +│ ├── 优惠券分析 +│ ├── 跨业务转化分析 +│ ├── 服务消费分析 +│ ├── 渠道分析 +│ ├── 自定义报表 +│ └── 报表订阅 +└── 系统设置 + ├── 业务线管理 + ├── 账号权限管理 + ├── 角色管理 + ├── 操作日志 + └── 系统参数配置 +``` + +### 5.2 角色权限设计 + +#### 5.2.1 角色定义 + +| 角色 | 说明 | 适用人员 | +|------|------|----------| +| 超级管理员 | 系统最高权限,可管理所有功能和配置 | 系统管理员 | +| 集团运营总监 | 集团层面运营管理,可查看和操作所有业务数据 | 集团运营负责人 | +| 集团运营专员 | 集团层面运营执行,可执行日常运营操作 | 集团运营人员 | +| 集团财务 | 集团财务管理,可查看和操作结算相关功能 | 集团财务人员 | +| BU运营负责人 | 业务线运营管理,可管理本业务线数据 | 各BU运营负责人 | +| BU运营专员 | 业务线运营执行,可执行本业务线日常操作 | 各BU运营人员 | +| BU财务 | 业务线财务管理,可查看本业务线结算数据 | 各BU财务人员 | +| 数据分析师 | 数据查看权限,仅可查看报表数据 | 数据分析人员 | + +#### 5.2.2 权限矩阵 + +| 功能模块 | 超级管理员 | 集团运营总监 | 集团运营专员 | 集团财务 | BU运营负责人 | BU运营专员 | BU财务 | 数据分析师 | +|----------|------------|--------------|--------------|----------|--------------|------------|--------|------------| +| 首页看板 | ✅全部 | ✅全部 | ✅全部 | ✅全部 | ✅本BU | ✅本BU | ✅本BU | ✅只读 | +| 客户池管理 | ✅全部 | ✅全部 | ✅全部 | ❌ | ✅本BU | ✅本BU | ❌ | ✅只读 | +| 会员管理 | ✅全部 | ✅全部 | ✅全部 | ❌ | ✅本BU | ✅本BU | ❌ | ✅只读 | +| 等级管理 | ✅全部 | ✅全部 | ✅查看 | ❌ | ✅查看 | ✅查看 | ❌ | ✅只读 | +| 积分管理 | ✅全部 | ✅全部 | ✅全部 | ✅查看 | ✅本BU | ✅本BU | ✅本BU查看 | ✅只读 | +| 优惠券管理 | ✅全部 | ✅全部 | ✅全部 | ❌ | ✅本BU | ✅本BU | ❌ | ✅只读 | +| 服务类目管理 | ✅全部 | ✅全部 | ✅查看 | ❌ | ✅查看 | ✅查看 | ❌ | ✅只读 | +| 消息中心 | ✅全部 | ✅全部 | ✅全部 | ❌ | ✅本BU | ✅本BU | ❌ | ❌ | +| 结算对账管理 | ✅全部 | ✅查看 | ✅查看 | ✅全部 | ✅本BU | ✅本BU查看 | ✅本BU | ❌ | +| 报表统计中心 | ✅全部 | ✅全部 | ✅全部 | ✅财务相关 | ✅本BU | ✅本BU | ✅本BU财务 | ✅全部只读 | +| 系统设置 | ✅全部 | ✅部分 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | + +#### 5.2.3 数据权限 + +| 角色 | 数据范围 | +|------|----------| +| 集团角色 | 可查看/操作全部三个业务线数据 | +| 道路救援BU | 仅可查看/操作道路救援相关数据 | +| 代驾BU | 仅可查看/操作代驾相关数据 | +| 到家服务BU | 仅可查看/操作到家服务相关数据 | + +### 5.3 各模块后台功能清单 + +#### 5.3.1 首页看板 + +| 功能 | 说明 | +|------|------| +| 核心指标卡片 | 展示会员数、活跃数、消费额等关键指标 | +| 趋势图表 | 会员增长、消费趋势、业务占比等图表 | +| 实时动态 | 滚动展示最新注册、升级、消费等动态 | +| 待办事项 | 待审批、待确认等待处理事项提醒 | +| 快捷入口 | 常用功能快捷入口 | + +#### 5.3.2 会员管理 + +| 功能 | 说明 | +|------|------| +| 会员列表 | 分页展示会员列表,支持搜索和筛选 | +| 会员搜索 | 按手机号、姓名、会员ID搜索 | +| 高级筛选 | 按等级、注册时间、来源、标签等筛选 | +| 会员详情 | 查看会员完整信息、消费记录、积分明细、优惠券等 | +| 手动调整积分 | 手动增减会员积分(需填写原因) | +| 手动调整等级 | 手动调整会员等级(需填写原因) | +| 发放优惠券 | 向指定会员发放优惠券 | +| 添加标签 | 为会员添加/移除标签 | +| 导出会员 | 导出会员列表Excel | + +#### 5.3.3 等级管理 + +| 功能 | 说明 | +|------|------| +| 等级列表 | 展示所有等级配置 | +| 等级配置 | 设置等级名称、积分门槛、有效期 | +| 权益配置 | 设置各等级权益(折扣、专属券、特权等) | +| 升降级规则 | 配置升级条件、降级规则、保级条件 | +| 等级图标 | 上传各等级展示图标 | + +#### 5.3.4 积分管理 + +| 功能 | 说明 | +|------|------| +| 获取规则配置 | 配置各种行为获得积分规则 | +| 使用规则配置 | 配置积分抵扣比例、使用门槛等 | +| 兑换规则配置 | 配置跨业务积分兑换规则 | +| 积分流水查询 | 查询积分发放、使用、过期明细 | +| 积分结算报表 | 查看跨业务积分结算数据 | +| 积分过期预警 | 查看即将过期积分数据 | +| 手动发放积分 | 批量或单个发放积分 | + +#### 5.3.5 优惠券管理 + +| 功能 | 说明 | +|------|------| +| 券模板管理 | 创建/编辑/删除优惠券模板 | +| 券池管理 | 创建券批次、批量导出、批量作废 | +| 大礼包管理 | 创建/编辑礼包、上下架、卡密生成导出 | +| 采购审批 | 审批服务券采购申请 | +| 引流券审批 | 审批引流券申领申请 | +| 发放管理 | 定向发放、批量发放优惠券 | +| 券明细查询 | 查询单张券的状态和流转记录 | +| 数据统计 | 券发放、核销、过期等数据统计 | + +#### 5.3.6 服务类目管理 + +| 功能 | 说明 | +|------|------| +| 类目树展示 | 树形结构展示三级类目 | +| 新增类目 | 新增一级/二级/三级类目 | +| 编辑类目 | 修改类目名称、图标、排序等 | +| 启用/停用 | 控制类目状态 | +| 排序调整 | 拖拽或手动调整类目顺序 | +| 批量导入 | Excel批量导入类目 | +| 导出类目 | 导出完整类目列表 | + +#### 5.3.7 消息中心 + +| 功能 | 说明 | +|------|------| +| 推送任务列表 | 查看所有推送任务及状态 | +| 创建推送任务 | 创建新的消息推送任务 | +| 模板管理 | 管理消息推送模板 | +| 推送记录 | 查看历史推送记录和效果 | +| 用户偏好统计 | 统计用户消息接收偏好 | + +#### 5.3.8 结算对账管理 + +| 功能 | 说明 | +|------|------| +| 结算总览 | 展示待结算金额、待确认账单等概览 | +| 采购结算 | 采购订单列表、账单列表、确认/异议 | +| 积分结算 | 积分流转明细、账单列表、确认/异议 | +| 引流券统计 | 引流券核销数据统计(仅查看) | +| 异议管理 | 异议单列表、处理异议 | +| 结算报表 | 月度/年度结算报表 | +| 结算设置 | 结算周期、账单规则配置 | + +#### 5.3.9 报表统计中心 + +| 功能 | 说明 | +|------|------| +| 数据总览 | 首页看板数据 | +| 会员分析 | 会员构成、活跃度、生命周期、RFM分析 | +| 等级分析 | 等级分布、变动分析、权益使用分析 | +| 积分分析 | 积分发放/使用/余额分析、跨业务流转分析 | +| 优惠券分析 | 券发放/核销分析、礼包销售分析 | +| 跨业务转化分析 | 转化路径、漏斗分析、引流效果分析 | +| 服务消费分析 | 按业务/类目/时段分析 | +| 渠道分析 | 各渠道效果分析 | +| 自定义报表 | 自定义维度和指标生成报表 | +| 报表订阅 | 配置报表自动发送 | + +#### 5.3.10 系统设置 + +| 功能 | 说明 | +|------|------| +| 业务线管理 | 管理三个业务线基本信息 | +| 账号管理 | 管理后台用户账号 | +| 角色管理 | 管理角色和权限配置 | +| 操作日志 | 查看系统操作日志 | +| 系统参数 | 配置系统全局参数 | +| 接口管理 | 管理与业务系统的接口配置 | + +### 5.4 操作日志 + +#### 5.4.1 日志记录范围 + +| 操作类型 | 记录内容 | +|----------|----------| +| 登录登出 | 登录时间、IP、设备信息 | +| 数据修改 | 会员信息修改、等级调整、积分调整 | +| 配置变更 | 等级配置、积分规则、券模板等变更 | +| 审批操作 | 采购审批、引流券审批、异议处理 | +| 结算操作 | 账单确认、异议提交、打款确认 | +| 数据导出 | 会员导出、报表导出、券码导出 | + +#### 5.4.2 日志字段 + +| 字段 | 说明 | +|------|------| +| 操作时间 | 精确到秒 | +| 操作人 | 操作人员账号和姓名 | +| 操作类型 | 新增/修改/删除/查询/导出/审批等 | +| 操作模块 | 所属功能模块 | +| 操作对象 | 被操作的数据ID或名称 | +| 操作前数据 | 修改前的数据(JSON格式) | +| 操作后数据 | 修改后的数据(JSON格式) | +| IP地址 | 操作人IP | +| 操作结果 | 成功/失败 | +| 备注 | 操作备注信息 | + +--- + +## 六、会员小程序设计 + +### 6.1 功能结构图 + +``` +会员小程序 +├── 首页 +│ ├── 会员卡片(等级、积分、优惠券数量) +│ ├── 快捷入口(积分、优惠券、订单) +│ ├── 推荐服务(三大业务入口) +│ ├── 活动Banner +│ └── 热门礼包推荐 +├── 我的等级 +│ ├── 当前等级展示 +│ ├── 等级进度条 +│ ├── 等级权益说明 +│ ├── 升级攻略 +│ └── 等级历史记录 +├── 我的积分 +│ ├── 积分余额 +│ ├── 积分明细(收入/支出) +│ ├── 积分规则说明 +│ ├── 积分兑换入口 +│ └── 即将过期提醒 +├── 我的优惠券 +│ ├── 可用优惠券(按业务分类) +│ ├── 已使用优惠券 +│ ├── 已过期优惠券 +│ └── 输入券码兑换 +├── 领券中心 +│ ├── 推荐优惠券 +│ ├── 各业务专区券 +│ └── 限时领券活动 +├── 礼包商城 +│ ├── 在售礼包列表 +│ ├── 礼包详情 +│ ├── 购买礼包 +│ └── 卡密激活入口 +├── 我的礼包 +│ ├── 待激活礼包 +│ ├── 已激活礼包 +│ └── 礼包内券明细 +├── 订单记录 +│ ├── 全部订单 +│ ├── 道路救援订单 +│ ├── 代驾订单 +│ └── 到家服务订单 +├── 消息中心 +│ ├── 系统通知 +│ ├── 优惠券消息 +│ ├── 积分消息 +│ └── 活动消息 +└── 个人中心 + ├── 个人信息 + ├── 手机号管理 + ├── 消息偏好设置 + ├── 常见问题 + ├── 联系客服 + └── 关于我们 +``` + +### 6.2 页面清单 + +#### 6.2.1 首页 + +| 页面元素 | 说明 | +|----------|------| +| 会员卡片 | 展示等级、积分余额、可用券数量 | +| 等级进度 | 显示距离下一等级的进度 | +| 快捷入口 | 积分、优惠券、订单、客服4个入口 | +| 活动Banner | 轮播展示运营活动 | +| 服务入口 | 道路救援、代驾、到家三大业务入口 | +| 热门礼包 | 推荐热销礼包 | +| 新手引导 | 新用户引导任务 | + +#### 6.2.2 我的等级 + +| 页面元素 | 说明 | +|----------|------| +| 等级卡片 | 当前等级名称、图标 | +| 进度条 | 距离下一等级积分进度 | +| 等级权益 | 当前等级可享权益列表 | +| 等级对比 | 各等级权益对比表 | +| 升级攻略 | 如何快速升级指引 | +| 等级说明 | 等级规则详细说明 | + +#### 6.2.3 我的积分 + +| 页面元素 | 说明 | +|----------|------| +| 积分余额 | 当前可用积分 | +| 即将过期 | 即将过期积分提醒 | +| 积分明细 | 收入/支出明细列表 | +| 筛选功能 | 按时间、类型筛选 | +| 积分规则 | 获取和使用规则说明 | +| 兑换入口 | 跳转积分兑换页面 | + +#### 6.2.4 我的优惠券 + +| 页面元素 | 说明 | +|----------|------| +| Tab切换 | 可用/已使用/已过期 | +| 业务筛选 | 全部/道路救援/代驾/到家 | +| 券列表 | 券名称、面值、有效期、适用范围 | +| 券详情 | 点击查看券详细规则 | +| 立即使用 | 跳转对应业务下单 | +| 兑换入口 | 输入券码兑换 | + +#### 6.2.5 领券中心 + +| 页面元素 | 说明 | +|----------|------| +| 推荐券 | 系统推荐的优惠券 | +| 业务专区 | 按业务分类的券 | +| 限时活动 | 限时领券活动 | +| 领取状态 | 已领取/未领取/已领完 | +| 领取数量 | 每人限领数量提示 | + +#### 6.2.6 礼包商城 + +| 页面元素 | 说明 | +|----------|------| +| 礼包列表 | 在售礼包卡片列表 | +| 礼包卡片 | 图片、名称、原价、现价、已售数量 | +| 礼包详情 | 包含权益、使用说明 | +| 购买按钮 | 立即购买 | +| 卡密激活 | 输入卡密激活礼包 | + +#### 6.2.7 我的礼包 + +| 页面元素 | 说明 | +|----------|------| +| Tab切换 | 待激活/已激活 | +| 礼包卡片 | 礼包名称、状态、有效期 | +| 激活操作 | 点击激活礼包 | +| 券明细 | 查看礼包内已发放的券 | + +#### 6.2.8 订单记录 + +| 页面元素 | 说明 | +|----------|------| +| Tab切换 | 全部/道路救援/代驾/到家 | +| 订单列表 | 订单号、服务类型、金额、状态、时间 | +| 订单详情 | 点击查看订单详情 | +| 筛选功能 | 按时间、状态筛选 | + +#### 6.2.9 消息中心 + +| 页面元素 | 说明 | +|----------|------| +| 消息分类 | 系统通知/优惠券/积分/活动 | +| 消息列表 | 标题、摘要、时间、已读状态 | +| 消息详情 | 点击查看完整消息 | +| 全部已读 | 一键标记全部已读 | + +#### 6.2.10 个人设置 + +| 页面元素 | 说明 | +|----------|------| +| 头像昵称 | 修改头像和昵称 | +| 手机号 | 绑定/更换手机号 | +| 消息设置 | 各类消息接收开关 | +| 常见问题 | FAQ列表 | +| 联系客服 | 在线客服入口 | +| 关于我们 | 公司介绍、版本信息 | + +### 6.3 关键页面原型说明 + +#### 6.3.1 首页原型 + +``` +┌─────────────────────────────────────┐ +│ ┌─────────────────────────────┐ │ +│ │ [活动Banner轮播] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ ┌─────┐ │ │ +│ │ │ 头像 │ 张先生 │ │ +│ │ └─────┘ 黄金会员 │ │ +│ │ │ │ +│ │ 积分 2,580 优惠券 5张 │ │ +│ │ ━━━━━━━━━●━━━ 距铂金还需500分│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 积分 │ │ 优惠券 │ │ 订单 │ │ +│ └───────┘ └───────┘ └───────┘ │ +│ │ +│ ── 推荐服务 ────────────────── │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 道路 │ │ 代驾 │ │ 到家 │ │ +│ │ 救援 │ │ 服务 │ │ 服务 │ │ +│ └───────┘ └───────┘ └───────┘ │ +│ │ +│ ── 热门礼包 ────────────────── │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ 出行无忧礼包 │ │ 新人专享礼包 │ │ +│ │ ¥99 │ │ 免费 │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [首页] [领券] [礼包] [订单] [我的] │ +└─────────────────────────────────────┘ +``` + +#### 6.3.2 我的优惠券原型 + +``` +┌─────────────────────────────────────┐ +│ ← 我的优惠券 │ +├─────────────────────────────────────┤ +│ [可用] [已使用] [已过期] │ +│ │ +│ 全部 | 道路救援 | 代驾 | 到家 │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ ┌─────┐ │ │ +│ │ │ ¥20 │ 代驾20元抵扣券 │ │ +│ │ └─────┘ 满50元可用 │ │ +│ │ 2024.12.31到期 │ │ +│ │ [立即使用]│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ ┌─────┐ │ │ +│ │ │ 8折 │ 到家服务8折券 │ │ +│ │ └─────┘ 无门槛 │ │ +│ │ 2024.12.15到期 │ │ +│ │ [立即使用]│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ ┌─────┐ │ │ +│ │ │ ¥50 │ 道路救援50元券 │ │ +│ │ └─────┘ 满200元可用 │ │ +│ │ 2024.11.30到期 │ │ +│ │ [立即使用]│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────┐ │ +│ │ 输入券码兑换 │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────┘ +``` + +#### 6.3.3 礼包详情原型 + +``` +┌─────────────────────────────────────┐ +│ ← 礼包详情 │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ │ │ +│ │ [礼包图片] │ │ +│ │ │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 出行无忧大礼包 │ +│ ¥99 原价¥250 │ +│ 已售 1,234 │ +│ │ +│ ── 礼包内容 ────────────────── │ +│ ┌─────────────────────────────┐ │ +│ │ • 道路救援50元抵扣券 × 2张 │ │ +│ │ • 代驾20元抵扣券 × 5张 │ │ +│ │ • 到家服务8折券 × 3张 │ │ +│ │ • 赠送500积分 │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ── 使用说明 ────────────────── │ +│ 1. 购买后请在30天内激活 │ +│ 2. 激活后优惠券自动发放到账户 │ +│ 3. 各优惠券有效期及规则见券面 │ +│ │ +│ │ +├─────────────────────────────────────┤ +│ ┌─────────────────┐ │ +│ │ 立即购买 ¥99 │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────┘ +``` + +--- + +## 十一、附录 + +### 附录1:名词解释表 + +| 名词 | 英文 | 解释 | +|------|------|------| +| 客户池 | Customer Pool | 来自各业务系统的原始客户数据,尚未转化为会员的潜在用户集合 | +| 会员 | Member | 通过领券、注册等方式完成转化,进入会员体系的用户 | +| 会员中台 | Member Platform | 统一管理会员数据和服务的中心化系统 | +| 服务券 | Service Coupon | A业务向B业务采购的优惠券,成本由采购方承担 | +| 引流券 | Traffic Coupon | B业务免费提供给A业务发放的优惠券,成本由提供方承担 | +| 综合积分 | Composite Points | 用于计算会员等级的积分,包含消费金额、次数等加权计算 | +| 可用积分 | Available Points | 用户可用于抵扣消费或兑换的积分余额 | +| 券池 | Coupon Pool | 存放已创建但未绑定用户的空白券,用于批量发放或导出 | +| 大礼包 | Gift Package | 将多张优惠券组合成一个礼包,支持定价销售或赠送 | +| 跨业务转化 | Cross-business Conversion | 用户从一个业务线转化到另一个业务线消费 | +| RFM模型 | RFM Model | 基于最近消费时间(Recency)、消费频率(Frequency)、消费金额(Monetary)的用户价值分析模型 | +| ROI | Return on Investment | 投资回报率,用于衡量营销活动效果 | + +### 附录2:优惠券流转示意图 + +**服务券流程:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 服务券采购流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 道路救援BU 代驾BU │ +│ │ │ │ +│ │ 1. 发起采购申请 │ │ +│ │ (500张代驾20元券,单价15元) │ │ +│ ├───────────────────────────────────→│ │ +│ │ │ │ +│ │ 2. 审批通过 │ │ +│ │←───────────────────────────────────┤ │ +│ │ │ │ +│ │ 3. 券入库(道路救援券池) │ │ +│ │ │ │ +│ │ 4. 发放给道路救援用户 │ │ +│ │ │ │ +│ │ 5. 用户使用代驾服务│ │ +│ │ 核销优惠券 │ │ +│ │ │ │ +│ │ 6. 月度结算 │ │ +│ │ (道路救援向代驾支付7,500元) │ │ +│ ├───────────────────────────────────→│ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**引流券流程:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 引流券发放流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 代驾BU 道路救援BU │ +│ │ │ │ +│ │ 1. 创建引流券批次 │ │ +│ │ (1000张代驾10元券,用于引流) │ │ +│ │ │ │ +│ │ 2. 申请领取引流券 │ +│ │←───────────────────────────────────┤ │ +│ │ │ │ +│ │ 3. 审批通过,分配500张 │ │ +│ ├───────────────────────────────────→│ │ +│ │ │ │ +│ │ 4. 发放给道路救援用户 │ +│ │ │ │ +│ │ 5. 用户使用代驾服务 │ │ +│ │ 核销优惠券 │ │ +│ │ │ │ +│ │ 6. 记录引流效果(不结算) │ │ +│ │ │ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 附录3:服务类目完整清单 + +| 一级类目 | 二级类目 | 三级类目 | 类目编码 | +|----------|----------|----------|----------| +| 道路救援服务 | 拖车服务 | 市内拖车 | ROAD_TOW_CITY | +| 道路救援服务 | 拖车服务 | 长途拖车 | ROAD_TOW_LONG | +| 道路救援服务 | 拖车服务 | 事故拖车 | ROAD_TOW_ACCIDENT | +| 道路救援服务 | 小修服务 | 现场快修 | ROAD_REPAIR_QUICK | +| 道路救援服务 | 小修服务 | 应急维修 | ROAD_REPAIR_EMERGENCY | +| 道路救援服务 | 换胎服务 | 备胎更换 | ROAD_TIRE_SPARE | +| 道路救援服务 | 换胎服务 | 新胎更换 | ROAD_TIRE_NEW | +| 道路救援服务 | 搭电服务 | 电瓶搭电 | ROAD_BATTERY_JUMP | +| 道路救援服务 | 搭电服务 | 电瓶更换 | ROAD_BATTERY_REPLACE | +| 道路救援服务 | 送油服务 | - | ROAD_FUEL | +| 道路救援服务 | 困境救援 | - | ROAD_RESCUE | +| 道路救援服务 | 开锁服务 | - | ROAD_UNLOCK | +| 代驾服务 | 酒后代驾 | - | DRIVER_WINE | +| 代驾服务 | 商务代驾 | - | DRIVER_BUSINESS | +| 代驾服务 | 长途代驾 | - | DRIVER_LONG | +| 代驾服务 | 旅游代驾 | - | DRIVER_TRAVEL | +| 代驾服务 | 其他代驾 | - | DRIVER_OTHER | +| 到家服务 | 清洁服务 | 日常保洁 | HOME_CLEAN_DAILY | +| 到家服务 | 清洁服务 | 深度清洁 | HOME_CLEAN_DEEP | +| 到家服务 | 清洁服务 | 开荒保洁 | HOME_CLEAN_NEW | +| 到家服务 | 清洁服务 | 家电清洗 | HOME_CLEAN_APPLIANCE | +| 到家服务 | 维修服务 | 家电维修 | HOME_REPAIR_APPLIANCE | +| 到家服务 | 维修服务 | 水电维修 | HOME_REPAIR_UTILITY | +| 到家服务 | 维修服务 | 门窗维修 | HOME_REPAIR_DOOR | +| 到家服务 | 维修服务 | 管道疏通 | HOME_REPAIR_PIPE | +| 到家服务 | 清洗服务 | 油烟机清洗 | HOME_WASH_HOOD | +| 到家服务 | 清洗服务 | 空调清洗 | HOME_WASH_AC | +| 到家服务 | 清洗服务 | 洗衣机清洗 | HOME_WASH_WASHER | +| 到家服务 | 清洗服务 | 冰箱清洗 | HOME_WASH_FRIDGE | +| 到家服务 | 其他服务 | 搬家服务 | HOME_OTHER_MOVE | +| 到家服务 | 其他服务 | 家具安装 | HOME_OTHER_INSTALL | + +### 附录4:接口文档 + +详见《集团会员中台系统接口设计文档》(单独附件) + +### 附录5:原型设计稿 + +详见《集团会员中台系统原型设计稿》(单独附件) + +### 附录6:数据库设计 + +详见《集团会员中台系统数据库设计文档》(单独附件) + +--- + +## 文档结束 + diff --git a/training-system/.claude/settings.local.json b/training-system/.claude/settings.local.json deleted file mode 100644 index b51a7de..0000000 --- a/training-system/.claude/settings.local.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(netstat:*)", - "Bash(findstr:*)", - "Bash(ping:*)", - "Bash(curl:*)", - "Bash(mvn compile test-compile:*)", - "mcp__chrome-devtools__click", - "mcp__chrome-devtools__take_snapshot", - "mcp__chrome-devtools__navigate_page", - "mcp__chrome-devtools__list_network_requests", - "mcp__chrome-devtools__get_network_request", - "mcp__chrome-devtools__fill", - "mcp__chrome-devtools__evaluate_script", - "mcp__chrome-devtools__fill_form", - "mcp__chrome-devtools__wait_for", - "Bash(copy:*)" - ] - } -} diff --git a/training-system/.gitignore b/training-system/.gitignore deleted file mode 100644 index fbfe99e..0000000 --- a/training-system/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -target/ -.idea/ -.summaries/ -.claude/ -.env diff --git a/training-system/.idea/.gitignore b/training-system/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/training-system/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/training-system/.idea/misc.xml b/training-system/.idea/misc.xml deleted file mode 100644 index b1ad763..0000000 --- a/training-system/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/training-system/.idea/modules.xml b/training-system/.idea/modules.xml deleted file mode 100644 index fe33cca..0000000 --- a/training-system/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/training-system/.idea/training-system.iml b/training-system/.idea/training-system.iml deleted file mode 100644 index d6ebd48..0000000 --- a/training-system/.idea/training-system.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/training-system/.idea/vcs.xml b/training-system/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/training-system/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/training-system/.summaries/summary-training-system-20260108-1634-会话摘要.md b/training-system/.summaries/summary-training-system-20260108-1634-会话摘要.md deleted file mode 100644 index 9be230b..0000000 --- a/training-system/.summaries/summary-training-system-20260108-1634-会话摘要.md +++ /dev/null @@ -1,28 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-08 16:34 | 会话摘要 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|----------|--------|------| -| 1 | 执行 /summary 命令 | 获取系统时间并询问需求ID | 成功 | - -## 错误记录 - -无错误记录。 - -## 成果产出 - -- 生成本会话摘要文件 - -## 总结 - -本次会话为新开始的会话,用户直接执行了 /summary 命令生成会话摘要。 - -## 效率评估 - -**实际用时**: 1分钟 -**应耗人工**: 3分钟 diff --git a/training-system/.summaries/summary-training-system-20260108-1721-后端服务及前端页面开发.md b/training-system/.summaries/summary-training-system-20260108-1721-后端服务及前端页面开发.md deleted file mode 100644 index c1c0bbd..0000000 --- a/training-system/.summaries/summary-training-system-20260108-1721-后端服务及前端页面开发.md +++ /dev/null @@ -1,50 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-08 17:21 | 后端服务及前端页面开发 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|----------|--------|------| -| 1 | 继续上一会话开发任务 | 读取已完成的DTO/VO文件,继续开发阶段四培训计划模块 | 成功 | -| 2 | - | 创建TrainingPlanService接口和PlanProgressService接口 | 成功 | -| 3 | - | 创建PlanProgressServiceImpl实现类 | 成功 | -| 4 | - | 创建TrainingPlanServiceImpl实现类 | 成功 | -| 5 | - | 创建TrainingPlanController控制器 | 成功 | -| 6 | - | 开始阶段五前端页面开发,检查现有页面文件 | 成功 | -| 7 | - | 创建system/org.html组织架构页面 | 成功 | -| 8 | - | 创建system/user.html员工管理页面 | 成功 | -| 9 | - | 创建knowledge/category.html知识分类页面 | 成功 | -| 10 | - | 创建knowledge/list.html知识列表页面 | 成功 | -| 11 | - | 创建exam/question-category.html题库分类页面 | 成功 | - -## 错误记录 - -无错误发生 - -## 成果产出 - -### 后端服务层(阶段四) -- `TrainingPlanService.java` - 培训计划服务接口 -- `PlanProgressService.java` - 学习进度服务接口 -- `TrainingPlanServiceImpl.java` - 培训计划服务实现(含CRUD、发布、结束、学员计划查询) -- `PlanProgressServiceImpl.java` - 学习进度服务实现(含知识完成标记、进度计算) -- `TrainingPlanController.java` - 培训计划控制器(含管理端和学员端接口) - -### 前端页面(阶段五进行中) -- `templates/system/org.html` - 组织架构管理页面(树形结构+详情展示+CRUD弹窗) -- `templates/system/user.html` - 员工管理页面(列表+筛选+新增编辑+密码重置) -- `templates/knowledge/category.html` - 知识分类页面(树形结构+表单编辑) -- `templates/knowledge/list.html` - 知识列表页面(列表+上传+状态管理) -- `templates/exam/question-category.html` - 题库分类页面(树形结构+题目数量统计) - -## 总结 - -本次会话完成了阶段四培训计划模块的全部后端开发,包括培训计划的创建、编辑、删除、发布、结束,以及学员的培训计划查看和学习进度跟踪功能。同时开始了阶段五前端页面开发,已完成组织架构、员工管理、知识分类、知识列表、题库分类共5个核心管理页面。前端页面采用纯HTML+CSS+JS方案,通过AJAX与后端API交互,实现了完整的CRUD操作界面。 - -## 效率评估 - -**实际用时**: 25分钟 -**应耗人工**: 480分钟 diff --git a/training-system/.summaries/summary-training-system-20260108-1947-登录功能测试.md b/training-system/.summaries/summary-training-system-20260108-1947-登录功能测试.md deleted file mode 100644 index 08abe97..0000000 --- a/training-system/.summaries/summary-training-system-20260108-1947-登录功能测试.md +++ /dev/null @@ -1,55 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-08 19:47 | 登录功能测试 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|---------|--------|------| -| 1 | 请求测试登录功能 | 创建JwtUtilsTest、AuthServiceTest、AuthControllerTest | 41个测试用例全部通过 | -| 2 | 询问如何运行项目 | 提供项目运行步骤和配置说明 | 用户成功启动项目 | -| 3 | 登录页面404错误 | 分析原因:HTML在templates目录而非static目录 | 将HTML文件移至static目录,问题解决 | -| 4 | admin/admin登录失败 | 检查SQL初始化脚本,发现密码应为admin123 | 告知用户正确密码 | -| 5 | 请求做登录功能单元测试 | 创建PasswordEncoderTest和LoginIntegrationTest | 发现数据库密码哈希错误并修复 | - -## 错误记录 - -| 错误类型 | 错误描述 | 解决方案 | -|---------|---------|---------| -| 404错误 | 登录页面无法访问 | HTML文件从templates移至static目录 | -| 密码错误 | admin用户登录失败 | SQL脚本中BCrypt哈希错误,生成新的有效哈希并更新 | -| JWT测试失败 | 同用户同秒生成Token相同 | 测试延迟从10ms改为1100ms | - -## 成果产出 - -### 测试文件 -1. `src/test/java/com/sino/training/common/utils/JwtUtilsTest.java` - 19个测试用例 -2. `src/test/java/com/sino/training/module/auth/service/AuthServiceTest.java` - 11个测试用例 -3. `src/test/java/com/sino/training/module/auth/controller/AuthControllerTest.java` - 11个测试用例 -4. `src/test/java/com/sino/training/module/auth/PasswordEncoderTest.java` - 4个测试用例 -5. `src/test/java/com/sino/training/module/auth/LoginIntegrationTest.java` - 11个测试用例 - -### 文档 -- `docs/测试报告-登录模块.md` - 完整测试报告 - -### 修复 -- 修复SQL初始化脚本中admin用户的密码哈希 -- 将前端HTML文件移至static目录 - -## 总结 - -完成了登录模块的全面测试工作,包括: -1. 后端单元测试(JwtUtils、AuthService、AuthController)共41个用例全部通过 -2. 发现并修复登录页面404问题(HTML文件位置错误) -3. 发现并修复数据库密码哈希错误问题 -4. 创建密码编码验证测试和登录集成测试 -5. 生成完整测试报告 - -用户需执行SQL更新数据库密码后,使用admin/admin123登录。 - -## 效率评估 - -**实际用时**: 45分钟 -**应耗人工**: 240分钟 diff --git a/training-system/.summaries/summary-training-system-20260109-1806-员工管理复测及漏测分析.md b/training-system/.summaries/summary-training-system-20260109-1806-员工管理复测及漏测分析.md deleted file mode 100644 index 2f41772..0000000 --- a/training-system/.summaries/summary-training-system-20260109-1806-员工管理复测及漏测分析.md +++ /dev/null @@ -1,48 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-09 18:06 | 员工管理复测及漏测分析 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|---------|--------|------| -| 1 | 要求对员工管理模块进行复测 | 获取Token并验证3个缺陷修复情况 | BUG-USER-001通过、BUG-USER-002失败、BUG-USER-003通过 | -| 2 | 反馈重置密码和编辑状态功能问题 | 测试重置密码功能 | 功能正常,新密码可登录 | -| 3 | 继续测试编辑状态功能 | 测试专用disable接口和编辑接口 | 发现编辑接口无法修改状态 | -| 4 | 要求确认是否前端问题 | 分析前端代码user.html | 确认前端正确传递status字段 | -| 5 | 继续验证后端问题 | 检查UserDTO和UserServiceImpl | 确认后端DTO缺少status字段 | -| 6 | 要求分析漏测原因 | 分析前两次测试记录 | 归纳出6大漏测原因 | -| 7 | 要求将分析归纳到LLR.md | 更新docs/LLR.md文件 | 添加漏测分析记录 | - -## 错误记录 - -| 错误类型 | 描述 | 解决方案 | -|---------|------|---------| -| 后端Bug | UserDTO缺少status字段,updateUser()未处理状态 | 需在DTO添加status字段并在Service中处理 | -| 后端Bug | JWT Token未包含departmentId导致部门隔离失效 | 需修改JwtUtils.generateToken()添加departmentId | - -## 成果产出 - -| 文件 | 说明 | -|------|------| -| docs/test-reports/RegressionReport_1.3_UserManagement.md | 第一版复测报告 | -| docs/test-reports/RegressionReport_1.3_UserManagement_V2.md | 深度测试后的V2复测报告 | -| docs/LLR.md | 更新漏测原因分析记录 | - -## 总结 - -本次会话对员工管理模块进行了三轮测试: -1. **第一轮复测**:验证原有3个缺陷,2个通过1个失败 -2. **第二轮深度测试**:根据用户反馈发现新Bug(编辑接口无法修改状态) -3. **第三轮分析**:确认问题为后端Bug(非前端问题),并归纳漏测原因 - -核心发现: -- BUG-USER-004:编辑接口无法修改状态(后端UserDTO缺少status字段) -- 漏测根因:"只测了能用的接口,没测用户实际用的接口" - -## 效率评估 - -**实际用时**: 45分钟 -**应耗人工**: 180分钟 diff --git a/training-system/.summaries/summary-training-system-20260109-1808-员工管理模块缺陷修复.md b/training-system/.summaries/summary-training-system-20260109-1808-员工管理模块缺陷修复.md deleted file mode 100644 index 6e570a6..0000000 --- a/training-system/.summaries/summary-training-system-20260109-1808-员工管理模块缺陷修复.md +++ /dev/null @@ -1,57 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-09 18:08 | 员工管理模块缺陷修复 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|---------|--------|------| -| 1 | 要求根据测试报告给出修复建议 | 分析测试报告,识别3个缺陷并给出修复方案 | 完成缺陷分析 | -| 2 | 确认查看相关文件 | 查看UserController、UserServiceImpl、AuthInterceptor等文件 | 给出详细修复方案 | -| 3 | 要求按方案修改代码 | 创建RequireRole注解、RoleInterceptor拦截器,修改WebMvcConfig、UserController、UserServiceImpl、ResultCode | P0/P1/P2缺陷修复完成 | -| 4 | 分析员工禁用状态不生效问题 | 分析AuthInterceptor未校验用户状态,给出修复方案 | 完成分析(未修改代码) | -| 5 | 分析BUG-USER-004问题 | 分析UserDTO缺少status字段、updateUser未处理状态更新 | 完成根因分析 | -| 6 | 要求修复BUG-USER-004 | 修改UserDTO添加status字段,修改updateUser添加状态更新逻辑 | 修复完成 | - -## 错误记录 - -| 错误类型 | 描述 | 解决方案 | -|---------|------|---------| -| 无 | - | - | - -## 成果产出 - -### 新建文件 -1. `src/main/java/com/sino/training/common/annotation/RequireRole.java` - 角色权限注解 -2. `src/main/java/com/sino/training/common/interceptor/RoleInterceptor.java` - 角色权限拦截器 - -### 修改文件 -1. `WebMvcConfig.java` - 注册RoleInterceptor -2. `UserController.java` - 添加@RequireRole注解 -3. `UserServiceImpl.java` - 添加部门数据隔离逻辑、手机号唯一性校验、状态更新逻辑 -4. `ResultCode.java` - 新增PHONE_EXISTS错误码 -5. `UserDTO.java` - 新增status字段 - -### 缺陷修复情况 -| 缺陷编号 | 描述 | 状态 | -|---------|------|------| -| BUG-USER-001 | 手机号未做唯一性校验 | ✅ 已修复 | -| BUG-USER-002 | 讲师未实现部门数据隔离 | ⚠️ Service层已添加,需修复JWT | -| BUG-USER-003 | 学员可访问员工管理接口 | ✅ 已修复 | -| BUG-USER-004 | 编辑接口无法修改状态 | ✅ 已修复 | - -## 总结 - -本次会话完成了员工管理模块的缺陷分析和修复工作: -1. 实现了基于注解的角色权限控制机制(@RequireRole + RoleInterceptor) -2. 在Service层添加了部门数据隔离逻辑 -3. 添加了手机号唯一性校验 -4. 修复了编辑接口无法修改状态的问题 -5. 分析了禁用状态不生效的根因(AuthInterceptor未校验用户状态),待后续修复 - -## 效率评估 - -**实际用时**: 45分钟 -**应耗人工**: 180分钟 diff --git a/training-system/.summaries/summary-training-system-20260112-1953-前端BUG修复.md b/training-system/.summaries/summary-training-system-20260112-1953-前端BUG修复.md deleted file mode 100644 index 7500a7e..0000000 --- a/training-system/.summaries/summary-training-system-20260112-1953-前端BUG修复.md +++ /dev/null @@ -1,55 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-12 19:53 | 前端BUG修复 | - | - -## 交互记录 - -| 序号 | 用户操作 | AI响应 | 结果 | -|------|----------|--------|------| -| 1 | 题目编辑时选项和答案不显示 | 分析发现前端JSON.parse多余且setOptions未正确读取opt.value | 修复question-edit.html两处代码 | -| 2 | 组卷方式题目不显示 | 分析发现result.data.list应为result.data.records | 修复paper-edit.html两处代码 | -| 3 | 新增考卷totalScore未传后端 | 分析发现savePaper函数缺少totalScore字段 | 添加totalScore计算并传递 | -| 4 | 创建试卷报"所属部门"不存在 | 分析发现PaperServiceImpl.createPaper未实现部门ID逻辑 | 添加与QuestionServiceImpl相同的部门ID处理逻辑 | -| 5 | 试卷管理列表不显示 | 分析发现pageData.list应为pageData.records | 修复paper.html一处代码 | -| 6 | 编辑试卷不显示信息 | 分析发现q.content应为q.question.content | 修复paper-edit.html两处字段路径 | -| 7 | 试卷发布功能不可用 | 分析发现前端用POST但后端用PUT | 修复paper.html请求方法 | -| 8 | 试卷复制功能不可用 | 分析发现后端未实现copy接口 | 删除前端复制按钮和函数 | -| 9 | 试卷预览不显示内容 | 分析发现多处字段路径错误 | 修复paper-preview.html六处代码 | - -## 错误记录 - -| 错误类型 | 文件 | 问题描述 | 解决方案 | -|----------|------|----------|----------| -| 数据格式不匹配 | question-edit.html | 后端返回options是对象数组,前端多余JSON.parse | 移除JSON.parse,读取opt.value | -| 字段名错误 | paper-edit.html, paper.html | 使用data.list但后端返回data.records | 改为records | -| 缺少字段 | paper-edit.html | savePaper未传totalScore | 添加totalScore计算 | -| 业务逻辑缺失 | PaperServiceImpl.java | createPaper未按角色处理departmentId | 添加UserContext判断逻辑 | -| 嵌套对象访问错误 | paper-edit.html, paper-preview.html | 直接访问q.content但实际是q.question.content | 修正字段路径 | -| HTTP方法不匹配 | paper.html | 前端POST但后端PUT | 改为put方法 | -| 接口未实现 | paper.html | 复制功能后端未实现 | 删除前端复制功能 | - -## 成果产出 - -| 文件 | 修改类型 | 说明 | -|------|----------|------| -| question-edit.html | 修复 | 移除多余JSON.parse,正确读取选项值 | -| paper-edit.html | 修复 | records字段、totalScore、嵌套对象访问 | -| paper.html | 修复 | records字段、PUT方法、删除复制功能 | -| paper-preview.html | 修复 | 6处嵌套对象字段路径修正 | -| PaperServiceImpl.java | 修复 | createPaper添加部门ID逻辑 | - -## 总结 - -本次会话修复了9个前端BUG,主要问题集中在: -1. **数据格式不匹配**:后端返回的分页数据用records字段,前端错误使用list -2. **嵌套对象访问**:试卷题目数据嵌套在question对象中,前端直接访问导致undefined -3. **JSON解析问题**:后端已将JSON字符串解析为对象,前端多余的JSON.parse导致错误 -4. **HTTP方法不一致**:发布接口后端用PUT,前端用POST -5. **后端逻辑缺失**:PaperServiceImpl未按CLAUDE.md要求处理departmentId - -## 效率评估 - -**实际用时**: 25分钟 -**应耗人工**: 120分钟 diff --git a/training-system/.summaries/summary-training-system-20260113-1138-参与人员保存BUG修复.md b/training-system/.summaries/summary-training-system-20260113-1138-参与人员保存BUG修复.md deleted file mode 100644 index 8e2ce6c..0000000 --- a/training-system/.summaries/summary-training-system-20260113-1138-参与人员保存BUG修复.md +++ /dev/null @@ -1,44 +0,0 @@ -# 会话摘要 - -| 项目 | 时间 | 任务 | 需求ID | -|------|------|------|--------| -| training-system | 2026-01-13 11:38 | 参与人员保存BUG修复 | - | - -## 交互记录 - -| 序号 | 操作类型 | 描述 | 结果 | -|------|----------|------|------| -| 1 | 问题复现 | 用户反馈"发布考试选择了参与人员,保存后参与人员丢失" | 确认问题存在 | -| 2 | API验证 | 调用 `/api/exam/3` 检查数据库数据 | 数据已正确保存(targets数组包含2人) | -| 3 | 前端检查 | 编辑页面显示"已选择0人" | 确认是前端显示问题 | -| 4 | 代码分析 | 阅读 edit.html 中 loadExam 函数 | 发现字段名不匹配和异步顺序问题 | -| 5 | 修复1 | 修正字段名 participants → targets, userId → targetId | 完成 | -| 6 | 修复2 | 添加 await 确保异步执行顺序正确 | 完成 | -| 7 | 文件同步 | 复制修改后的文件到 target 目录 | 完成 | -| 8 | 验证修复 | 刷新页面检查参与人员显示 | 正确显示"已选择2人" | - -## 错误记录 - -| 错误类型 | 位置 | 原因 | 解决方案 | -|----------|------|------|----------| -| 字段名不匹配 | edit.html:294-299 | 代码使用 `exam.participants`,API返回 `exam.targets` | 修改为 `exam.targets` 和 `targetId` | -| 异步执行顺序 | edit.html:149-160 | loadUsers()和loadExam()同时执行无await | 添加 await 保证执行顺序 | - -## 成果产出 - -| 文件 | 修改内容 | -|------|----------| -| src/main/resources/static/exam/edit.html | 1. 修复 loadExam 中参与人员字段名(participants→targets, userId→targetId)
2. 修复 DOMContentLoaded 中异步执行顺序(添加await) | - -## 总结 - -用户反馈考试参与人员保存后丢失的问题。经测试定位,数据已正确保存到数据库,问题在于前端编辑页面加载时: -1. 使用了错误的字段名(participants 而非 targets) -2. 异步函数执行顺序不正确导致渲染时机问题 - -通过修正字段名和异步执行顺序,问题已修复并验证通过。 - -## 效率评估 - -**实际用时**: 15分钟 -**应耗人工**: 60分钟 diff --git a/training-system/CLAUDE.md b/training-system/CLAUDE.md deleted file mode 100644 index 55ca144..0000000 --- a/training-system/CLAUDE.md +++ /dev/null @@ -1,43 +0,0 @@ -# Java Maven 项目 AI 开发最高准则(必须遵守) - -## 一、技术栈(不可更改) -- 后端语言:Java -- JDK:17 -- 构建工具:Maven -- 后端框架:Spring Boot 3.1.2 -- 前端框架: HTML + CSS + Bootstrap -- ORM:MyBatis Plus -- 数据库:MySQL 8 -- API 文档:Springdoc OpenAPI -- 序列化:Jackson -- 安全框架:Spring Security(用于BCrypt密码加密) -- 认证方式:JWT Token(使用 java-jwt) - -## 二、强制约束(最高优先级) -- ❌ 不允许引入未声明的新技术栈 -- ❌ 不允许更换框架或大版本 -- ❌ 不允许使用过时 API -- ❌ 不允许在本项目任何位置使用 Python(包括代码、脚本、Notebook 等) - -## 三、编码规范 -- 遵循《阿里 Java 开发规范》 -- 必须使用 Lombok -- Controller(RESTful) 层不写业务逻辑 -- Service + Impl 负责业务 -- Mapper 只做数据访问 - -## 四、通用要求 -- 所有代码必须: - - 可读 - - 可维护 - - 有必要注释 -- 不生成 Demo / 示例 / 伪代码 -- 生成代码必须可直接运行 - -## 五、当需求与以上规则冲突时 -👉 **以本文件为最高准则,拒绝执行冲突需求** - -## 六、业务逻辑要求 -- 实现部门ID逻辑: - - ADMIN:使用前端传入的 departmentId,若为空则报错 - - 非ADMIN:强制使用当前登录用户的 departmentId,忽略前端传值 diff --git a/training-system/README.md b/training-system/README.md deleted file mode 100644 index 67c2868..0000000 --- a/training-system/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# 道路救援企业培训系统 - -> 版本:V1.0.1 | 基于 Spring Boot 3.1.2 - -为道路救援企业打造的一站式内部培训平台,通过知识沉淀、在线考核、培训管理三大核心能力,提升员工专业技能水平和服务标准化程度。 - ---- - -## 技术栈 - -| 类别 | 技术 | 版本 | -|-----|------|------| -| 语言 | Java | 17 | -| 构建 | Maven | 3.6+ | -| 框架 | Spring Boot | 3.1.2 | -| ORM | MyBatis Plus | 3.5.3.1 | -| 数据库 | MySQL | 8.0+ | -| 认证 | JWT (java-jwt) | 4.4.0 | -| API文档 | Springdoc OpenAPI | 2.2.0 | -| 工具库 | Lombok、Hutool | - | - ---- - -## 功能模块 - -| 模块 | 功能说明 | -|-----|---------| -| **人员管理** | 组织架构(中心→部门→小组)、员工管理、角色权限 | -| **知识库** | 文档/视频上传、在线预览、分类管理、状态流转 | -| **考题管理** | 单选/多选/判断题、题目解析、题库分类 | -| **试卷管理** | 手动组卷、自动组卷、试卷预览 | -| **考试管理** | 发布考试、指定对象、在线答题、自动阅卷 | -| **培训计划** | 关联多个知识+多个考试、分配学员、进度跟踪 | - ---- - -## 快速开始 - -### 环境要求 - -- JDK 17+ -- Maven 3.6+ -- MySQL 8.0+ - -### 1. 克隆项目 - -```bash -git clone -cd training-system -``` - -### 2. 初始化数据库 - -```sql -CREATE DATABASE training_system DEFAULT CHARACTER SET utf8mb4; -USE training_system; -SOURCE sql/init.sql; -``` - -### 3. 修改配置 - -编辑 `src/main/resources/application.yml`,配置数据库连接: - -```yaml -spring: - datasource: - url: jdbc:mysql://localhost:3306/training_system - username: root - password: 你的密码 -``` - -### 4. 启动应用 - -```bash -mvn spring-boot:run -``` - -### 5. 访问系统 - -- 系统地址:http://localhost:8080 -- API文档:http://localhost:8080/swagger-ui.html -- 默认账号:`admin` / `123456` - ---- - -## 生产部署 - -### 打包 - -```bash -mvn clean package -DskipTests -``` - -生成文件:`target/training-system-1.0.0.jar` - -### Linux部署 - -```bash -# 上传JAR包和SQL脚本到服务器 -scp target/training-system-1.0.0.jar user@server:/opt/training/ -scp sql/init.sql user@server:/opt/training/ - -# 创建生产配置 /opt/training/application-prod.yml -# 启动应用 -java -jar training-system-1.0.0.jar --spring.profiles.active=prod -``` - -详细部署指南见 [部署文档](docs/Deploy.md) - ---- - -## 项目结构 - -``` -training-system/ -├── src/main/java/com/sino/training/ -│ ├── common/ # 公共模块(配置、异常、工具类) -│ ├── module/ -│ │ ├── system/ # 系统模块(用户、部门、小组) -│ │ ├── knowledge/ # 知识库模块 -│ │ ├── exam/ # 考试模块(题目、试卷、考试) -│ │ └── training/ # 培训计划模块 -│ └── TrainingApplication.java -├── src/main/resources/ -│ ├── static/ # 前端静态资源 -│ ├── application.yml # 配置文件 -│ └── mapper/ # MyBatis XML -├── sql/ -│ └── init.sql # 数据库初始化脚本 -├── docs/ # 项目文档 -└── pom.xml -``` - ---- - -## 编码规范 - -- 遵循《阿里 Java 开发规范》 -- Controller 层不写业务逻辑 -- Service + Impl 负责业务处理 -- Mapper 只做数据访问 -- 必须使用 Lombok 简化代码 - ---- - -## 相关文档 - -- [产品需求文档 (PRD)](docs/PRD.md) -- [低层级需求文档 (LLR)](docs/LLR.md) -- [测试计划](docs/TestPlan.md) - ---- - -## 版本记录 - -| 版本 | 日期 | 更新内容 | -|-----|------|---------| -| V1.0.1 | 2026-01-13 | 培训计划支持多考试任务 | -| V1.0.0 | 2026-01-08 | 初始版本发布 | diff --git a/training-system/agents/architect.md b/training-system/agents/architect.md deleted file mode 100644 index 3052ae8..0000000 --- a/training-system/agents/architect.md +++ /dev/null @@ -1,31 +0,0 @@ -# Role: Architect Agent(系统架构师) - -## 你的身份 -你是系统架构师,负责整体技术设计。 - -## 你的目标 -- 设计清晰、可扩展的系统架构 -- 降低长期复杂度 - -## 你可以做的事 -- 技术选型 -- 系统拆分 -- 定义模块边界和接口规范 - -## 你不能做的事 -- 不实现具体业务逻辑 -- 不写完整功能代码 - -## 输入 -- requirements.md -- Supervisor 指令 - -## 输出 -- architecture.md -- 目录结构建议 -- API 设计说明 - -## 工作规则 -- 设计必须支持未来扩展 -- 避免过度设计 -- 明确模块职责 \ No newline at end of file diff --git a/training-system/agents/backend.md b/training-system/agents/backend.md deleted file mode 100644 index c2a28c5..0000000 --- a/training-system/agents/backend.md +++ /dev/null @@ -1,30 +0,0 @@ -# Role: Backend Agent(后端工程师) - -## 你的身份 -你是后端工程师,只负责实现后端业务逻辑。 - -## 你的目标 -- 按架构和需求实现稳定、可测试的代码 - -## 你可以做的事 -- 编写业务代码 -- 实现 API -- 编写必要的单元测试 - -## 你不能做的事 -- 不更改架构设计 -- 不新增未经批准的功能 - -## 输入 -- architecture.md -- requirements.md -- Supervisor 指令 - -## 输出 -- 后端源码 -- 测试代码 - -## 工作规则 -- 代码必须可读 -- 必须遵循项目规范 -- 所有假设必须说明 \ No newline at end of file diff --git a/training-system/agents/devops.md b/training-system/agents/devops.md deleted file mode 100644 index 08e7915..0000000 --- a/training-system/agents/devops.md +++ /dev/null @@ -1,20 +0,0 @@ -# Role: DevOps Agent(部署与运维) - -## 你的身份 -你负责系统交付与运行环境。 - -## 你的目标 -- 系统可部署、可回滚、可监控 - -## 输入 -- 架构设计 -- 运行要求 - -## 输出 -- 部署方案 -- CI/CD 建议 -- 环境说明 - -## 工作规则 -- 优先稳定性 -- 避免复杂配置 \ No newline at end of file diff --git a/training-system/agents/doc.md b/training-system/agents/doc.md deleted file mode 100644 index 15555ff..0000000 --- a/training-system/agents/doc.md +++ /dev/null @@ -1,28 +0,0 @@ -# Role: Doc Agent(文档工程师) - -## 你的身份 -你是项目文档负责人。 - -## 你的目标 -- 让人和 AI 都能快速理解项目 - -## 你可以做的事 -- 编写 README -- 编写使用说明和开发规范 - -## 你不能做的事 -- 不修改代码 -- 不解释未实现的功能 - -## 输入 -- 最终代码 -- 架构与需求文档 - -## 输出 -- README.md -- 使用与维护说明 - -## 工作规则 -- 文档必须准确 -- 避免废话 -- 假设读者是新加入的开发者或 AI \ No newline at end of file diff --git a/training-system/agents/frontend.md b/training-system/agents/frontend.md deleted file mode 100644 index 37c3dcb..0000000 --- a/training-system/agents/frontend.md +++ /dev/null @@ -1,28 +0,0 @@ -# Role: Frontend Agent(前端工程师) - -## 你的身份 -你是前端工程师,负责 UI 与交互实现。 - -## 你的目标 -- 提供清晰、一致的用户体验 - -## 你可以做的事 -- 编写前端代码 -- 实现页面和交互逻辑 - -## 你不能做的事 -- 不改后端接口定义 -- 不引入未确认的 UI 框架 - -## 输入 -- architecture.md -- requirements.md -- API 文档 - -## 输出 -- 前端源码 -- 简要交互说明 - -## 工作规则 -- 优先可维护性 -- 不做“看起来更酷”的无关改动 \ No newline at end of file diff --git a/training-system/agents/pm.md b/training-system/agents/pm.md deleted file mode 100644 index 207b514..0000000 --- a/training-system/agents/pm.md +++ /dev/null @@ -1,32 +0,0 @@ -# Role: Product Manager Agent(产品经理) - -## 你的身份 -你是负责需求分析和功能拆解的产品经理。 - -## 你的目标 -- 将模糊需求转化为清晰、可执行的需求 -- 定义验收标准 - -## 你可以做的事 -- 追问需求细节 -- 编写用户故事(User Story) -- 定义功能边界和非功能需求 - -## 你不能做的事 -- 不做技术选型 -- 不设计系统架构 -- 不写代码 - -## 输入 -- 用户原始需求 -- Supervisor 的指令 - -## 输出 -- requirements.md -- 功能清单 -- 验收标准(Acceptance Criteria) - -## 工作规则 -- 所有需求必须可测试 -- 明确“不做什么” -- 避免模糊词(如:尽量、可能) \ No newline at end of file diff --git a/training-system/agents/qa.md b/training-system/agents/qa.md deleted file mode 100644 index 2f185f0..0000000 --- a/training-system/agents/qa.md +++ /dev/null @@ -1,29 +0,0 @@ -# Role: QA / Tester Agent(测试工程师) - -## 你的身份 -你是专门负责找问题的人。 - -## 你的目标 -- 尽可能暴露缺陷和风险 - -## 你可以做的事 -- 设计测试用例 -- 提出反例和异常场景 -- 发现逻辑漏洞 - -## 你不能做的事 -- 不修复代码 -- 不修改需求 - -## 输入 -- 功能说明 -- 源码或接口定义 - -## 输出 -- 测试用例 -- Bug 列表 -- 风险说明 - -## 工作规则 -- 假设开发会犯错 -- 重点关注边界条件 \ No newline at end of file diff --git a/training-system/agents/security.md b/training-system/agents/security.md deleted file mode 100644 index 7d1f11a..0000000 --- a/training-system/agents/security.md +++ /dev/null @@ -1,29 +0,0 @@ -# Role: Security Agent(安全审计) - -## 你的身份 -你是安全专家。 - -## 你的目标 -- 发现安全漏洞和设计风险 - -## 你可以做的事 -- 审查代码和接口 -- 指出潜在攻击面 -- 提供修复建议 - -## 你不能做的事 -- 不实现功能 -- 不更改业务逻辑 - -## 输入 -- 架构设计 -- 代码或接口文档 - -## 输出 -- 安全问题清单 -- 风险等级 -- 修复建议 - -## 工作规则 -- 默认系统处于敌对环境 -- 不忽略“看起来不太可能”的问题 \ No newline at end of file diff --git a/training-system/agents/supervisor.md b/training-system/agents/supervisor.md deleted file mode 100644 index 414f6b6..0000000 --- a/training-system/agents/supervisor.md +++ /dev/null @@ -1,34 +0,0 @@ -# Role: Supervisor Agent(项目总负责人) - -## 你的身份 -你是整个项目的总负责人,负责项目最终质量与方向。 -你不写具体业务代码。 - -## 你的目标 -- 确保项目符合需求、可维护、可扩展 -- 确保每个 Agent 各司其职 -- 防止范围蔓延和架构混乱 - -## 你可以做的事 -- 拆分项目阶段 -- 指派 Agent 执行任务 -- 审核、拒绝或要求返工任何 Agent 的输出 -- 汇总各 Agent 结果 - -## 你不能做的事 -- 不直接编写业务代码 -- 不绕过其他 Agent 直接下结论 - -## 输入 -- 用户需求 -- 各 Agent 的输出文档 - -## 输出 -- 决策说明 -- 是否通过 / 是否返工 -- 下一步执行指令 - -## 工作规则 -- 若信息不足,必须要求澄清 -- 优先考虑长期维护而非短期实现 -- 所有结论必须有理由 \ No newline at end of file diff --git a/training-system/docs/LLR.md b/training-system/docs/LLR.md deleted file mode 100644 index 8227c4f..0000000 --- a/training-system/docs/LLR.md +++ /dev/null @@ -1,89 +0,0 @@ -# LLR (Lessons Learned Record) - -## 2026-01-09: 组织架构列表加载失败 - -### 问题现象 -组织架构页面列表一直显示 loading,无法加载数据。 - -### 根本原因 -`common.js` 中 `getCurrentUser()` 函数对 localStorage 数据处理不够健壮,当存储值为字符串 `"undefined"` 时,`JSON.parse("undefined")` 抛出异常,导致页面脚本中断。 - -### 经验教训 - -1. **防御性编程**:从 localStorage 读取数据时,应考虑数据可能被污染或格式异常的情况,需增加有效性校验 -2. **错误定位**:前端加载问题应先查看浏览器控制台错误信息,而非只检查网络请求 -3. **文件去重**:项目中 `static` 和 `templates` 目录下存在相同文件(如 `org.html`),应统一管理避免修改遗漏 - ---- - -## 2026-01-09: 编辑员工状态功能漏测分析 - -### 问题现象 - -员工管理模块中,用户通过前端编辑页面修改员工状态为"禁用",点击保存后提示成功,但刷新页面后状态仍为"启用"。 - -### 根本原因 - -**后端Bug**:`UserDTO` 没有定义 `status` 字段,`UserServiceImpl.updateUser()` 方法也未处理状态更新,导致前端传递的 status 字段被静默忽略。 - -### 漏测原因分析 - -#### 1. 测试路径覆盖不完整 - -系统提供了两种修改状态的方式,但只测试了一种: - -| 路径 | 接口 | 是否测试 | -|------|------|---------| -| 路径1:专用接口 | `PUT /api/system/user/{id}/disable` | ✅ 已测试 | -| 路径2:编辑接口 | `PUT /api/system/user` + status字段 | ❌ 未测试 | - -#### 2. 接口覆盖遗漏 - -编辑接口 `PUT /api/system/user` 完全没有出现在测试用例中: - -``` -第一轮测试覆盖: -✅ POST /api/system/user (创建) -❌ PUT /api/system/user (编辑 - 漏测!) -✅ GET /api/system/user/{id} (查询) -✅ PUT /api/system/user/{id}/enable (启用) -✅ PUT /api/system/user/{id}/disable (禁用) -``` - -#### 3. 未进行端到端测试 - -只做了接口级测试,没有模拟前端真实操作流程。如果从前端页面操作验证,可以直接发现问题。 - -#### 4. 字段级测试缺失 - -对编辑接口没有验证每个字段是否都能正确更新(realName、phone、role、departmentId、**status**)。 - -#### 5. 前后端契约未验证 - -没有验证前端传递的字段后端是否都能正确接收和处理。 - -#### 6. 测试思维局限 - -看到系统提供了专用的 `/enable` 和 `/disable` 接口,就主观认为"状态修改功能已覆盖",忽略了编辑接口也应该支持状态修改。 - -### 经验教训 - -1. **测试要覆盖所有接口**:不能遗漏任何CRUD接口 -2. **测试要覆盖所有路径**:同一功能的不同实现路径,每条都要测试 -3. **测试要覆盖所有字段**:对编辑接口,需建立字段覆盖矩阵 -4. **测试要端到端验证**:重要功能必须从前端页面操作验证 -5. **测试要二次确认**:不信任接口返回值,必须查询确认数据确实变化 -6. **验证前后端契约**:确保前端发送的字段后端都能正确处理 - -### 改进检查清单 - -设计和执行测试时,问自己: - -- [ ] 这个功能有几种实现路径?都测了吗? -- [ ] 这个接口的所有字段都验证了吗? -- [ ] 前端实际是怎么调用的?和我测试的方式一样吗? -- [ ] 我验证了数据确实变化了吗?还是只看了返回值? - -### 一句话总结 - -**"只测了能用的接口,没测用户实际用的接口"** diff --git a/training-system/docs/TestPlan.md b/training-system/docs/TestPlan.md deleted file mode 100644 index 6f4a66b..0000000 --- a/training-system/docs/TestPlan.md +++ /dev/null @@ -1,412 +0,0 @@ -# 道路救援企业培训系统 - 测试计划 - -> 版本:V1.0 -> 创建日期:2026-01-09 -> 基于:PRD V1.0 MVP版本 - ---- - -## 一、测试范围 - -根据PRD V1.0 MVP版本,测试覆盖以下6大模块: - -| 模块 | 测试优先级 | -|------|-----------| -| 模块一:系统基础与人员管理 | P0 | -| 模块二:知识库 | P0 | -| 模块三:考题管理 | P0 | -| 模块四:试卷管理 | P0 | -| 模块五:考试管理 | P0 | -| 模块六:培训计划 | P1 | - ---- - -## 二、测试类型 - -- 2.1 功能测试 -- 2.2 接口测试(API) -- 2.3 权限测试 -- 2.4 边界测试 -- 2.5 数据隔离测试 -- 2.6 UI/交互测试 - ---- - -## 三、详细测试用例 - -### 模块一:系统基础与人员管理 - -#### 1.1 认证登录 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| AUTH-001 | 企业微信授权登录(正常流程) | 跳转企微授权,返回后获取Token,成功进入系统 | 待测 | -| AUTH-002 | 未录入员工登录 | 提示"用户未录入,请联系管理员" | 待测 | -| AUTH-003 | 被禁用用户登录 | 提示"账号已禁用" | 待测 | -| AUTH-004 | Token过期访问 | 返回401,跳转登录页 | 待测 | -| AUTH-005 | 无效Token访问 | 返回401,拒绝访问 | 待测 | - -#### 1.2 组织架构管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| ORG-001 | 创建中心 | 成功创建,返回中心信息 | 待测 | -| ORG-002 | 创建部门(关联中心) | 成功创建,部门数 ≤ 8 | 待测 | -| ORG-003 | 创建小组(关联部门) | 成功创建,小组数 ≤ 10/部门 | 待测 | -| ORG-004 | 部门数量超过8个 | 提示"部门数量已达上限" | 待测 | -| ORG-005 | 小组数量超过10个 | 提示"小组数量已达上限" | 待测 | -| ORG-006 | 删除有员工的部门 | 提示"存在关联员工,无法删除" | 待测 | -| ORG-007 | 编辑组织名称 | 成功修改 | 待测 | - -#### 1.3 员工管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| USER-001 | 管理员创建员工 | 成功创建,指定角色和部门 | 待测 | -| USER-002 | 创建重复手机号员工 | 提示"手机号已存在" | 待测 | -| USER-003 | 分配角色(ADMIN/LECTURER/STUDENT) | 角色分配成功 | 待测 | -| USER-004 | 禁用员工 | 状态变更为DISABLED | 待测 | -| USER-005 | 启用员工 | 状态变更为ENABLED | 待测 | -| USER-006 | 讲师查看本部门员工 | 只能看到本部门 | 待测 | -| USER-007 | 学员无法访问员工管理 | 返回403 | 待测 | - ---- - -### 模块二:知识库 - -#### 2.1 知识分类 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| KC-001 | 创建一级分类 | 成功创建 | 待测 | -| KC-002 | 创建多级分类(如:安全规范 > 高速救援) | 层级关系正确 | 待测 | -| KC-003 | 删除有知识的分类 | 提示"存在关联知识,无法删除" | 待测 | -| KC-004 | 讲师只能管理本部门分类 | 其他部门分类不可见/不可操作 | 待测 | - -#### 2.2 知识文档管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| KM-001 | 上传PDF文档 | 上传成功,可在线预览 | 待测 | -| KM-002 | 上传Word文档 | 上传成功 | 待测 | -| KM-003 | 上传Excel文档 | 上传成功 | 待测 | -| KM-004 | 上传PPT文档 | 上传成功 | 待测 | -| KM-005 | 上传视频文件 | 上传成功,可在线播放 | 待测 | -| KM-006 | 上传不支持的格式 | 提示"不支持该文件格式" | 待测 | -| KM-007 | 超大文件上传 | 提示"文件大小超过限制" | 待测 | - -#### 2.3 知识状态管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| KS-001 | 创建知识(默认草稿) | 状态为DRAFT | 待测 | -| KS-002 | 草稿→发布 | 状态变为PUBLISHED | 待测 | -| KS-003 | 已发布→下架 | 状态变为OFFLINE | 待测 | -| KS-004 | 已下架→重新上架 | 状态变为PUBLISHED | 待测 | -| KS-005 | 学员查看草稿知识 | 不可见 | 待测 | -| KS-006 | 学员查看已发布知识 | 可见,可预览 | 待测 | -| KS-007 | 学员查看已下架知识 | 不可见 | 待测 | -| KS-008 | 下架被培训计划引用的知识 | 显示警告确认 | 待测 | - -#### 2.4 部门隔离 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| KD-001 | 讲师查看本部门知识 | 正常查看 | 待测 | -| KD-002 | 讲师查看其他部门知识 | 不可见 | 待测 | -| KD-003 | 学员查看本部门已发布知识 | 正常查看 | 待测 | -| KD-004 | 学员查看其他部门知识 | 不可见 | 待测 | - ---- - -### 模块三:考题管理 - -#### 3.1 题型测试 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| Q-001 | 创建单选题(4选项) | 成功创建,答案为单个选项 | 待测 | -| Q-002 | 创建多选题 | 成功创建,答案为多个选项 | 待测 | -| Q-003 | 创建判断题 | 成功创建,答案为true/false | 待测 | -| Q-004 | 单选题设置多个答案 | 提示错误 | 待测 | -| Q-005 | 多选题只设置一个答案 | 提示"多选题至少选择两个答案" | 待测 | -| Q-006 | 题目必须填写解析 | 解析为空时提示必填 | 待测 | - -#### 3.2 题目状态管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| QS-001 | 草稿题目发布 | 状态变为PUBLISHED | 待测 | -| QS-002 | 下架被试卷引用的题目 | 显示警告确认 | 待测 | -| QS-003 | 只有已发布题目可被组卷 | 草稿/下架题目不可选 | 待测 | - -#### 3.3 部门隔离 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| QD-001 | 讲师只能管理本部门题库 | 其他部门题目不可见 | 待测 | -| QD-002 | 学员无法访问题目管理 | 返回403 | 待测 | - ---- - -### 模块四:试卷管理 - -#### 4.1 手动组卷 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| P-001 | 手动选择题目组卷 | 成功创建试卷 | 待测 | -| P-002 | 设置每题分值 | 分值设置正确 | 待测 | -| P-003 | 总分自动计算 | 各题分值之和 = 总分 | 待测 | -| P-004 | 设置考试时长 | 时长设置正确 | 待测 | -| P-005 | 设置及格分 | 及格分 ≤ 总分 | 待测 | -| P-006 | 及格分超过总分 | 提示"及格分不能超过总分" | 待测 | - -#### 4.2 自动组卷 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| PA-001 | 设置规则自动抽题 | 按规则随机抽取 | 待测 | -| PA-002 | 题库数量不足 | 提示"题目数量不足" | 待测 | -| PA-003 | 自动组卷题目随机 | 多次组卷结果不同 | 待测 | - -#### 4.3 试卷预览 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| PP-001 | 预览试卷 | 显示完整试卷内容 | 待测 | - -#### 4.4 试卷状态管理 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| PS-001 | 下架被考试引用的试卷 | 显示警告确认 | 待测 | - ---- - -### 模块五:考试管理 - -#### 5.1 发布考试 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| E-001 | 创建考试,关联试卷 | 成功创建 | 待测 | -| E-002 | 设置时间窗口(开始~结束) | 时间设置正确 | 待测 | -| E-003 | 设置及格线 | 及格线设置正确 | 待测 | -| E-004 | 设置最大考试次数 | 次数限制生效 | 待测 | -| E-005 | 指定部门参加考试 | 该部门所有人可见考试 | 待测 | -| E-006 | 指定小组参加考试 | 该小组成员可见考试 | 待测 | -| E-007 | 指定个人参加考试 | 仅该用户可见考试 | 待测 | - -#### 5.2 在线答题 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| EA-001 | 在时间窗口内进入考试 | 成功进入 | 待测 | -| EA-002 | 不在时间窗口进入考试 | 提示"不在考试时间范围内" | 待测 | -| EA-003 | 超过最大次数进入考试 | 提示"考试次数已用完" | 待测 | -| EA-004 | 答题过程自动计时 | 倒计时正确 | 待测 | -| EA-005 | 超时自动交卷 | 系统自动提交 | 待测 | -| EA-006 | 主动交卷 | 成功提交 | 待测 | -| EA-007 | 答案定时自动保存(30秒) | 答案保存成功 | 待测 | -| EA-008 | 断网后恢复继续答题 | 可继续答题 | 待测 | - -#### 5.3 成绩计算 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| ES-001 | 单选题判分 | 正确得满分,错误0分 | 待测 | -| ES-002 | 多选题判分 | 全对得分,部分对/错误0分 | 待测 | -| ES-003 | 判断题判分 | 正确得满分,错误0分 | 待测 | -| ES-004 | 分数 ≥ 及格线 | 显示"通过" | 待测 | -| ES-005 | 分数 < 及格线 | 显示"未通过" | 待测 | -| ES-006 | 多次考试取最高分 | 最高分记录正确 | 待测 | - -#### 5.4 成绩查看 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| EV-001 | 交卷后立即显示成绩 | 成绩即时显示 | 待测 | -| EV-002 | 显示答案解析 | 每题显示正确答案和解析 | 待测 | -| EV-003 | 讲师查看本部门学员成绩 | 正常查看 | 待测 | - ---- - -### 模块六:培训计划 - -#### 6.1 创建培训计划 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| TP-001 | 创建培训计划(名称、周期、目标) | 成功创建 | 待测 | -| TP-002 | 关联多个知识文档 | 关联成功 | 待测 | -| TP-003 | 关联考试(可选) | 关联成功 | 待测 | -| TP-004 | 分配部门/小组/个人 | 分配成功 | 待测 | - -#### 6.2 计划状态 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| TPS-001 | 开始日期前状态 | 未开始 | 待测 | -| TPS-002 | 在周期内状态 | 进行中 | 待测 | -| TPS-003 | 结束日期后状态 | 已结束 | 待测 | - -#### 6.3 进度跟踪 - -| 用例编号 | 测试项 | 预期结果 | 状态 | -|---------|--------|---------|------| -| TPP-001 | 学员完成知识学习 | 进度更新 | 待测 | -| TPP-002 | 学员完成关联考试 | 进度更新 | 待测 | -| TPP-003 | 学员查看自己的学习进度 | 进度显示正确 | 待测 | -| TPP-004 | 讲师查看学员培训进度 | 可查看本部门 | 待测 | - ---- - -## 四、权限矩阵测试 - -| 用例编号 | 角色 | 操作 | 预期结果 | 状态 | -|---------|------|------|---------|------| -| PERM-001 | 管理员 | 管理所有组织架构 | 允许 | 待测 | -| PERM-002 | 管理员 | 查看全平台数据 | 允许 | 待测 | -| PERM-003 | 讲师 | 管理本部门知识库 | 允许 | 待测 | -| PERM-004 | 讲师 | 管理其他部门知识库 | 拒绝(403) | 待测 | -| PERM-005 | 讲师 | 查看本部门学员成绩 | 允许 | 待测 | -| PERM-006 | 学员 | 查看本部门已发布知识 | 允许(只读) | 待测 | -| PERM-007 | 学员 | 编辑知识 | 拒绝(403) | 待测 | -| PERM-008 | 学员 | 访问题目管理 | 拒绝(403) | 待测 | -| PERM-009 | 学员 | 访问试卷管理 | 拒绝(403) | 待测 | -| PERM-010 | 学员 | 参加分配的考试 | 允许 | 待测 | - ---- - -## 五、边界测试 - -| 用例编号 | 测试项 | 边界条件 | 预期结果 | 状态 | -|---------|--------|---------|---------|------| -| BD-001 | 部门数量 | = 8 | 允许创建 | 待测 | -| BD-002 | 部门数量 | = 9 | 拒绝创建 | 待测 | -| BD-003 | 小组数量 | = 10/部门 | 允许创建 | 待测 | -| BD-004 | 小组数量 | = 11/部门 | 拒绝创建 | 待测 | -| BD-005 | 考试时长 | 0分钟 | 提示错误 | 待测 | -| BD-006 | 及格分 | 0分 | 允许 | 待测 | -| BD-007 | 及格分 | 负数 | 提示错误 | 待测 | -| BD-008 | 最大考试次数 | 0次 | 提示错误 | 待测 | -| BD-009 | 题目选项 | 空选项 | 提示错误 | 待测 | - ---- - -## 六、接口测试(API) - -### 6.1 认证接口 - -| 接口 | 方法 | 说明 | 状态 | -|------|------|------|------| -| /api/auth/wx-login | POST | 企业微信登录 | 待测 | -| /api/auth/refresh | POST | Token刷新 | 待测 | - -### 6.2 组织管理接口 - -| 接口 | 方法 | 说明 | 状态 | -|------|------|------|------| -| /api/system/centers | GET/POST/PUT/DELETE | 中心管理 | 待测 | -| /api/system/departments | GET/POST/PUT/DELETE | 部门管理 | 待测 | -| /api/system/groups | GET/POST/PUT/DELETE | 小组管理 | 待测 | -| /api/system/users | GET/POST/PUT/DELETE | 用户管理 | 待测 | - -### 6.3 知识库接口 - -| 接口 | 方法 | 说明 | 状态 | -|------|------|------|------| -| /api/knowledge/categories | GET/POST/PUT/DELETE | 分类管理 | 待测 | -| /api/knowledge/items | GET/POST/PUT/DELETE | 知识管理 | 待测 | -| /api/knowledge/upload | POST | 文件上传 | 待测 | -| /api/knowledge/{id}/publish | PUT | 发布知识 | 待测 | -| /api/knowledge/{id}/offline | PUT | 下架知识 | 待测 | - -### 6.4 考试接口 - -| 接口 | 方法 | 说明 | 状态 | -|------|------|------|------| -| /api/exam/questions | GET/POST/PUT/DELETE | 题目管理 | 待测 | -| /api/exam/papers | GET/POST/PUT/DELETE | 试卷管理 | 待测 | -| /api/exam/exams | GET/POST/PUT/DELETE | 考试管理 | 待测 | -| /api/exam/{id}/start | POST | 开始考试 | 待测 | -| /api/exam/{id}/submit | POST | 提交试卷 | 待测 | -| /api/exam/{id}/auto-save | PUT | 自动保存答案 | 待测 | - -### 6.5 培训接口 - -| 接口 | 方法 | 说明 | 状态 | -|------|------|------|------| -| /api/training/plans | GET/POST/PUT/DELETE | 培训计划管理 | 待测 | -| /api/training/plans/{id}/progress | GET | 进度查询 | 待测 | - ---- - -## 七、测试环境要求 - -| 项目 | 要求 | -|------|------| -| JDK | 17 | -| 数据库 | MySQL 8.0 | -| 浏览器 | Chrome最新版、企业微信内置浏览器 | -| 测试数据 | 准备3个角色测试账号(管理员/讲师/学员) | - ---- - -## 八、测试优先级 - -| 优先级 | 模块/功能 | -|--------|----------| -| P0 | 用户认证登录 | -| P0 | 权限控制 | -| P0 | 在线考试(核心流程) | -| P0 | 数据隔离 | -| P1 | 知识库管理 | -| P1 | 题目/试卷管理 | -| P1 | 培训计划 | -| P2 | 文件上传/预览 | -| P2 | UI交互细节 | - ---- - -## 九、测试用例统计 - -| 模块 | 用例数 | -|------|--------| -| 认证登录 | 5 | -| 组织架构 | 7 | -| 员工管理 | 7 | -| 知识库 | 19 | -| 考题管理 | 11 | -| 试卷管理 | 11 | -| 考试管理 | 20 | -| 培训计划 | 11 | -| 权限测试 | 10 | -| 边界测试 | 9 | -| **总计** | **110** | - ---- - -## 十、测试执行记录 - -### 执行摘要 - -| 项目 | 数值 | -|------|------| -| 计划用例数 | 110 | -| 已执行 | 0 | -| 通过 | 0 | -| 失败 | 0 | -| 阻塞 | 0 | -| 通过率 | - | - -### 缺陷记录 - -| 缺陷编号 | 用例编号 | 严重程度 | 描述 | 状态 | -|---------|---------|---------|------|------| -| - | - | - | - | - | - ---- - -**文档状态:已创建,等待测试执行** diff --git a/training-system/docs/TestPlan_ExamModule.md b/training-system/docs/TestPlan_ExamModule.md deleted file mode 100644 index d0f4632..0000000 --- a/training-system/docs/TestPlan_ExamModule.md +++ /dev/null @@ -1,133 +0,0 @@ -# 考试管理模块回归测试方案 - -> 版本:V1.0 -> 更新日期:2026-01-13 -> 状态:待执行 - ---- - -## 一、测试范围 - -根据 PRD 模块五"考试管理"的功能定义: - -| 功能点 | 说明 | -|--------|------| -| 发布考试 | 选择试卷,设置考试名称、时间窗口(开始~结束时间) | -| 指定对象 | 支持指定部门 / 小组 / 个人参加考试 | -| 考试规则 | 设置及格线、限制考试次数、考试时长 | -| 在线答题 | 学员在线作答,自动计时,超时自动交卷 | -| 成绩查看 | 交卷后立即显示成绩和答案解析 | - ---- - -## 二、测试环境要求 - -| 项目 | 要求 | -|------|------| -| 测试账号 | ADMIN 账号 1 个、LECTURER 账号 2 个(不同部门)、STUDENT 账号 3 个 | -| 前置数据 | 至少 1 个已发布试卷、至少 2 个部门、部门下有学员 | -| 浏览器 | Chrome 最新版 | - ---- - -## 三、测试用例 - -### 3.1 发布考试功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-001 | 正常发布考试 | 讲师登录,有已发布试卷 | 1. 进入考试管理
2. 点击"发布考试"
3. 填写考试名称、选择试卷
4. 设置开始/结束时间
5. 选择参与人员
6. 点击发布 | 发布成功,列表显示新考试 | -| EX-002 | 必填项校验 | 讲师登录 | 1. 进入发布考试页
2. 不填写考试名称,直接提交 | 提示"请输入考试名称" | -| EX-003 | 试卷下拉列表 | 讲师登录 | 1. 进入发布考试页
2. 查看试卷下拉列表 | 只显示当前用户部门的已发布试卷 | -| EX-004 | 参与人员列表 | 讲师登录 | 1. 进入发布考试页
2. 查看参与人员列表 | 只显示当前用户部门的学员 | -| EX-005 | 时间校验 | 讲师登录 | 1. 设置结束时间早于开始时间
2. 提交 | 提示"结束时间必须晚于开始时间" | -| EX-006 | 部门数据隔离 | 讲师A登录(部门A) | 1. 发布一场考试
2. 切换讲师B登录(部门B)
3. 查看考试列表 | 讲师B看不到讲师A发布的考试 | - -### 3.2 考试列表功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-101 | 列表正常显示 | 有考试数据 | 1. 进入考试管理页 | 列表正常显示,包含考试名称、试卷、时间、状态等 | -| EX-102 | 状态筛选 | 有不同状态考试 | 1. 选择"进行中"状态筛选 | 只显示进行中的考试 | -| EX-103 | 关键字搜索 | 有考试数据 | 1. 输入考试名称关键字搜索 | 显示匹配的考试 | -| EX-104 | 分页功能 | 考试数量 > 10 | 1. 查看分页
2. 点击下一页 | 分页正常,数据正确切换 | -| EX-105 | 考试状态自动更新 | 有未开始的考试 | 1. 设置一个即将开始的考试
2. 等待时间到达 | 状态自动变为"进行中" | - -### 3.3 考试详情/编辑功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-201 | 查看考试详情 | 有考试数据 | 1. 点击"查看详情"按钮 | 弹窗显示考试详细信息 | -| EX-202 | 编辑未开始考试 | 有未开始状态考试 | 1. 点击编辑
2. 修改考试名称
3. 保存 | 修改成功 | -| EX-203 | 删除未开始考试 | 有未开始状态考试 | 1. 点击删除
2. 确认删除 | 删除成功,列表刷新 | -| EX-204 | 删除有记录考试 | 考试已有学员作答 | 1. 尝试删除该考试 | 提示"该考试已有考试记录,无法删除" | - -### 3.4 在线答题功能(学员端) - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-301 | 学员查看考试列表 | 学员被分配考试 | 1. 学员登录
2. 进入"我的考试" | 显示分配给该学员的进行中考试 | -| EX-302 | 开始考试 | 考试进行中 | 1. 点击"开始考试" | 进入答题页面,显示试卷题目,倒计时开始 | -| EX-303 | 答题保存 | 正在答题中 | 1. 选择答案
2. 等待30秒自动保存 | 答案自动保存成功 | -| EX-304 | 主动交卷 | 正在答题中 | 1. 完成答题
2. 点击"交卷" | 提交成功,显示成绩和答案解析 | -| EX-305 | 超时自动交卷 | 正在答题中 | 1. 等待考试时间耗尽 | 自动交卷,显示成绩 | -| EX-306 | 考试次数限制 | 已用完考试次数 | 1. 尝试再次开始考试 | 提示"考试次数已用完" | -| EX-307 | 非考试时间段 | 考试未开始或已结束 | 1. 尝试开始考试 | 无法开始,提示不在考试时间内 | -| EX-308 | 非指定人员 | 学员未被分配该考试 | 1. 尝试访问该考试 | 无法参加该考试 | - -### 3.5 成绩查看功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-401 | 学员查看成绩 | 学员已完成考试 | 1. 查看考试记录 | 显示得分、是否及格、答题详情 | -| EX-402 | 答案解析显示 | 学员已交卷 | 1. 查看答题详情 | 显示正确答案和解析 | -| EX-403 | 讲师查看成绩统计 | 有学员完成考试 | 1. 讲师点击"成绩统计" | 显示参与人数、及格率、成绩分布 | -| EX-404 | 最高分记录 | 学员多次考试 | 1. 学员参加同一考试多次 | 取最高分作为最终成绩 | - -### 3.6 权限与数据隔离 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| EX-501 | 讲师只能管理本部门 | 讲师登录 | 1. 查看考试列表 | 只显示本部门的考试 | -| EX-502 | 管理员查看所有 | 管理员登录 | 1. 查看考试列表 | 显示所有部门的考试 | -| EX-503 | 学员权限限制 | 学员登录 | 1. 尝试访问考试管理页 | 无权访问或只能看"我的考试" | - ---- - -## 四、本次修复的 BUG 专项验证 - -| 验证项 | 测试步骤 | 预期结果 | -|--------|----------|----------| -| 试卷列表接口 | 发布考试页,查看试卷下拉 | 正常显示当前部门已发布试卷 | -| 参与人员列表 | 发布考试页,查看人员列表 | 正常显示当前部门学员 | -| maxAttempts 校验 | 发布考试(不填补考次数) | 正常发布,默认允许考1次 | -| departmentId 自动获取 | 讲师发布考试 | 自动使用讲师所在部门,无需前端传值 | -| creatorId 保存 | 发布考试后查看数据库 | creator_id 字段正确保存 | -| 考试列表显示 | 进入考试管理页 | 列表正常显示数据 | - ---- - -## 五、测试执行顺序建议 - -1. **环境准备**:确认测试数据(部门、用户、试卷) -2. **BUG 修复验证**:优先执行第四节专项验证 -3. **核心流程**:EX-001 → EX-101 → EX-301 → EX-304 → EX-401 -4. **边界测试**:EX-005、EX-306、EX-307 -5. **权限测试**:EX-501 ~ EX-503 - ---- - -## 六、测试结果记录 - -| 用例ID | 执行日期 | 执行人 | 结果 | 备注 | -|--------|----------|--------|------|------| -| | | | | | -| | | | | | -| | | | | | - ---- - -## 七、相关文档 - -- [产品需求文档](./PRD.md) -- [API接口文档](http://localhost:8080/swagger-ui.html) diff --git a/training-system/docs/TestPlan_TrainingModule.md b/training-system/docs/TestPlan_TrainingModule.md deleted file mode 100644 index cce2cd1..0000000 --- a/training-system/docs/TestPlan_TrainingModule.md +++ /dev/null @@ -1,155 +0,0 @@ -# 培训计划模块回归测试方案 - -> 版本:V1.0 -> 更新日期:2026-01-13 -> 状态:待执行 - ---- - -## 一、测试范围 - -根据 PRD 模块六"培训计划"的功能定义: - -| 功能点 | 说明 | -|--------|------| -| 创建培训计划 | 设置计划名称、培训周期、培训目标 | -| 关联知识/考试 | 一个培训计划可包含多个知识文档 + 一场考试 | -| 分配学员 | 指定部门/小组/个人参加培训计划 | -| 进度跟踪 | 学员可查看自己的学习进度和考试状态 | -| 计划状态 | 未开始 / 进行中 / 已结束 | - ---- - -## 二、测试环境要求 - -| 项目 | 要求 | -|------|------| -| 测试账号 | ADMIN 账号 1 个、LECTURER 账号 2 个(不同部门)、STUDENT 账号 3 个 | -| 前置数据 | 至少 1 个已发布知识、至少 1 个进行中考试、至少 2 个部门、部门下有学员 | -| 浏览器 | Chrome 最新版 | - ---- - -## 三、测试用例 - -### 3.1 创建培训计划功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-001 | 正常创建培训计划 | 讲师登录,有已发布知识 | 1. 进入培训计划
2. 点击"创建计划"
3. 填写计划名称、描述
4. 设置开始/结束日期
5. 点击保存 | 创建成功,列表显示新计划 | -| TP-002 | 必填项校验 | 讲师登录 | 1. 进入创建计划页
2. 不填写计划名称,直接保存 | 提示"请输入计划名称" | -| TP-003 | 日期校验 | 讲师登录 | 1. 设置结束日期早于开始日期
2. 保存 | 提示"开始日期不能晚于结束日期" | -| TP-004 | 部门自动获取 | 讲师登录 | 1. 创建培训计划
2. 不传departmentId | 自动使用讲师所属部门,创建成功 | -| TP-005 | 管理员指定部门 | 管理员登录 | 1. 创建培训计划
2. 选择指定部门 | 使用指定的部门创建成功 | - -### 3.2 关联知识/考试功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-101 | 添加知识内容 | 有已发布知识 | 1. 编辑培训计划
2. 点击"添加知识"
3. 选择知识内容
4. 保存 | 知识内容添加成功,显示在培训内容列表 | -| TP-102 | 设置必修/选修 | 已添加知识内容 | 1. 编辑培训计划
2. 设置知识为必修/选修
3. 保存 | 必修/选修标记正确保存 | -| TP-103 | 关联考试 | 有进行中考试 | 1. 编辑培训计划
2. 点击"添加考试"
3. 选择考试
4. 保存 | 考试关联成功 | -| TP-104 | 知识列表加载 | 讲师登录 | 1. 编辑培训计划
2. 点击添加知识
3. 查看知识列表 | 只显示当前部门已发布知识 | -| TP-105 | 考试列表加载 | 讲师登录 | 1. 编辑培训计划
2. 点击添加考试
3. 查看考试列表 | 只显示当前部门进行中考试 | - -### 3.3 分配学员功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-201 | 指定个人参与 | 有学员数据 | 1. 编辑培训计划
2. 选择指定学员
3. 保存 | 学员分配成功 | -| TP-202 | 指定部门参与 | 有部门数据 | 1. 编辑培训计划
2. 选择指定部门
3. 保存 | 部门下所有学员可参与 | -| TP-203 | 指定小组参与 | 有小组数据 | 1. 编辑培训计划
2. 选择指定小组
3. 保存 | 小组下所有学员可参与 | -| TP-204 | 参与人员回显 | 已分配学员 | 1. 编辑已有计划
2. 查看参与人员 | 已选人员正确勾选显示 | - -### 3.4 培训计划列表功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-301 | 列表正常显示 | 有培训计划数据 | 1. 进入培训计划页 | 列表正常显示,包含计划名称、状态、时间、进度等 | -| TP-302 | 状态筛选 | 有不同状态计划 | 1. 选择"进行中"状态筛选 | 只显示进行中的计划 | -| TP-303 | 关键字搜索 | 有计划数据 | 1. 输入计划名称关键字搜索 | 显示匹配的计划 | -| TP-304 | 分页功能 | 计划数量 > 10 | 1. 查看分页
2. 点击下一页 | 分页正常,数据正确切换 | -| TP-305 | 数据字段匹配 | 有计划数据 | 1. 检查列表数据 | 前端显示字段与后端返回一致(records而非list) | - -### 3.5 培训计划详情功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-401 | 查看计划详情 | 有计划数据 | 1. 点击"查看详情"按钮 | 详情页正常加载,显示完整信息 | -| TP-402 | 培训内容展示 | 计划已关联知识/考试 | 1. 查看详情页培训内容区 | 正确显示知识和考试列表 | -| TP-403 | 参与人员进度 | 有学员参与 | 1. 查看详情页参与人员 | 显示学员学习进度和状态 | -| TP-404 | 编辑跳转 | 有计划数据 | 1. 详情页点击"编辑" | 正确跳转到编辑页面 | - -### 3.6 培训计划编辑功能 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-501 | 编辑基本信息 | 有未开始计划 | 1. 编辑计划名称
2. 保存 | 修改成功 | -| TP-502 | 编辑培训内容 | 有未开始计划 | 1. 添加/删除知识
2. 保存 | 培训内容修改成功 | -| TP-503 | 编辑参与人员 | 有未开始计划 | 1. 修改参与人员
2. 保存 | 参与人员修改成功 | - -### 3.7 培训计划状态管理 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-601 | 发布培训计划 | 有草稿状态计划 | 1. 点击"发布"按钮 | 状态变为"进行中",学员可见 | -| TP-602 | 结束培训计划 | 有进行中计划 | 1. 点击"结束"按钮 | 状态变为"已结束" | -| TP-603 | 删除未开始计划 | 有未开始计划 | 1. 点击删除
2. 确认删除 | 删除成功 | - -### 3.8 学员端-我的培训 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-701 | 查看我的培训 | 学员被分配培训 | 1. 学员登录
2. 进入"我的培训" | 显示分配给该学员的进行中培训 | -| TP-702 | 学习知识内容 | 培训包含知识 | 1. 点击知识内容
2. 学习完成 | 知识学习状态更新 | -| TP-703 | 查看学习进度 | 有学习记录 | 1. 查看培训详情 | 显示正确的学习进度百分比 | -| TP-704 | 参加关联考试 | 培训关联考试 | 1. 点击参加考试 | 跳转到考试页面 | - -### 3.9 权限与数据隔离 - -| 用例ID | 测试项 | 前置条件 | 测试步骤 | 预期结果 | -|--------|--------|----------|----------|----------| -| TP-801 | 讲师只能管理本部门 | 讲师登录 | 1. 查看培训计划列表 | 只显示本部门的培训计划 | -| TP-802 | 管理员查看所有 | 管理员登录 | 1. 查看培训计划列表 | 显示所有部门的培训计划 | -| TP-803 | 学员权限限制 | 学员登录 | 1. 尝试访问培训计划管理页 | 无权访问或只能看"我的培训" | - ---- - -## 四、本次修复的 BUG 专项验证 - -| 验证项 | 测试步骤 | 预期结果 | -|--------|----------|----------| -| 创建计划部门ID | 讲师创建培训计划,不传departmentId | 自动使用讲师所在部门,创建成功 | -| 列表数据字段 | 进入培训计划列表页 | 列表正常显示(使用records字段) | -| 详情页加载 | 点击查看详情 | 详情页正常加载(接口使用/{id}) | -| 知识列表接口 | 编辑页添加知识 | 正常加载(使用/page接口) | -| 考试列表接口 | 编辑页添加考试 | 正常加载(使用/page接口) | - ---- - -## 五、测试执行顺序建议 - -1. **环境准备**:确认测试数据(部门、用户、知识、考试) -2. **BUG 修复验证**:优先执行第四节专项验证 -3. **核心流程**:TP-001 → TP-101 → TP-201 → TP-301 → TP-401 → TP-601 -4. **边界测试**:TP-002、TP-003、TP-004 -5. **学员端测试**:TP-701 ~ TP-704 -6. **权限测试**:TP-801 ~ TP-803 - ---- - -## 六、测试结果记录 - -| 用例ID | 执行日期 | 执行人 | 结果 | 备注 | -|--------|----------|--------|------|------| -| | | | | | -| | | | | | -| | | | | | - ---- - -## 七、相关文档 - -- [产品需求文档](./PRD.md) -- [API接口文档](http://localhost:8080/swagger-ui.html) -- [考试模块测试方案](./TestPlan_ExamModule.md) diff --git a/training-system/docs/TestReport_ExamModule_Regression.md b/training-system/docs/TestReport_ExamModule_Regression.md deleted file mode 100644 index 9a8723a..0000000 --- a/training-system/docs/TestReport_ExamModule_Regression.md +++ /dev/null @@ -1,175 +0,0 @@ -# 考试管理模块回归测试报告 - -> 版本:V1.0 -> 测试日期:2026-01-13 -> 测试人员:AI测试工程师 -> 测试账号:jiangshi(讲师角色) -> 测试状态:**已完成** - ---- - -## 一、测试概述 - -### 1.1 测试目的 -根据《考试管理模块回归测试方案》(TestPlan_ExamModule.md),对考试管理模块进行全面回归测试,验证BUG修复及核心功能的正确性。 - -### 1.2 测试范围 -- BUG修复专项验证 -- 发布考试功能测试 -- 考试列表功能测试 -- 考试详情/编辑功能测试 -- 权限与数据隔离测试 - -### 1.3 测试环境 -| 项目 | 配置 | -|------|------| -| 操作系统 | Windows | -| 浏览器 | Chrome 最新版 | -| 后端服务 | http://localhost:8080 | -| 测试账号 | jiangshi(讲师角色,呼叫中心部门) | - ---- - -## 二、BUG修复专项验证结果 - -| 验证项 | 测试步骤 | 预期结果 | 实际结果 | 状态 | -|--------|----------|----------|----------|------| -| 试卷列表接口 | 发布考试页,查看试卷下拉 | 正常显示当前部门已发布试卷 | 显示"测试试卷 -1 (3题/15分)" | ✅ 通过 | -| 参与人员列表 | 发布考试页,查看人员列表 | 正常显示当前部门学员 | 显示"员工-1"、"员工-2"(呼叫中心部门) | ✅ 通过 | -| maxAttempts 校验 | 发布考试(不填补考次数) | 正常发布,默认允许考1次 | maxAttempts=1,发布成功 | ✅ 通过 | -| departmentId 自动获取 | 讲师发布考试 | 自动使用讲师所在部门 | departmentId=5(呼叫中心部门),无需前端传值 | ✅ 通过 | -| creatorId 保存 | 发布考试后查看数据库 | creator_id 字段正确保存 | creatorId=3(讲师用户ID) | ✅ 通过 | -| 考试列表显示 | 进入考试管理页 | 列表正常显示数据 | 列表正常,包含所有必要列 | ✅ 通过 | - -**BUG修复验证结论:全部6项验证通过,BUG已完全修复。** - ---- - -## 三、功能测试结果汇总 - -### 3.1 发布考试功能(EX-001~EX-006) - -| 用例ID | 测试项 | 结果 | 备注 | -|--------|--------|------|------| -| EX-001 | 正常发布考试 | ✅ 通过 | 成功发布"回归测试考试-讲师发布",包含2名参与人员 | -| EX-002 | 必填项校验 | ✅ 通过 | 不填考试名称提交,提示"请输入考试名称" | -| EX-003 | 试卷下拉列表 | ✅ 通过 | 只显示当前部门已发布试卷 | -| EX-004 | 参与人员列表 | ✅ 通过 | 只显示当前部门学员 | -| EX-005 | 时间校验 | ✅ 通过 | 结束时间早于开始时间,提示"结束时间必须晚于开始时间" | -| EX-006 | 部门数据隔离 | ✅ 通过 | 通过API验证,讲师只能查看本部门考试 | - -### 3.2 考试列表功能(EX-101~EX-105) - -| 用例ID | 测试项 | 结果 | 备注 | -|--------|--------|------|------| -| EX-101 | 列表正常显示 | ✅ 通过 | 显示考试名称、试卷、时间、参与人数、及格率、状态、操作 | -| EX-102 | 状态筛选 | ✅ 通过 | 筛选"进行中"状态,显示"暂无考试"(当前无进行中考试) | -| EX-103 | 关键字搜索 | ✅ 通过 | 搜索"回归",只显示匹配的考试 | -| EX-104 | 分页功能 | ⏭️ 跳过 | 当前数据量<10条,无法验证分页 | -| EX-105 | 考试状态自动更新 | ⏭️ 跳过 | 需等待时间窗口变化,本次未验证 | - -### 3.3 考试详情/编辑功能(EX-201~EX-204) - -| 用例ID | 测试项 | 结果 | 备注 | -|--------|--------|------|------| -| EX-201 | 查看考试详情 | ✅ 通过 | 弹窗显示完整信息:名称、状态、试卷、时长、时间、人数统计 | -| EX-202 | 编辑未开始考试 | ⏭️ 跳过 | 功能入口存在,未深入测试 | -| EX-203 | 删除未开始考试 | ✅ 通过 | 弹出确认对话框,确认后可删除 | -| EX-204 | 删除有记录考试 | ⏭️ 跳过 | 当前无考试记录数据,无法验证 | - -### 3.4 权限与数据隔离(EX-501~EX-503) - -| 用例ID | 测试项 | 结果 | 备注 | -|--------|--------|------|------| -| EX-501 | 讲师只能管理本部门 | ✅ 通过 | 讲师jiangshi只能看到呼叫中心部门的考试 | -| EX-502 | 管理员查看所有 | ⏭️ 跳过 | 本次使用讲师账号测试 | -| EX-503 | 学员权限限制 | ⏭️ 跳过 | 本次使用讲师账号测试 | - ---- - -## 四、测试统计 - -| 分类 | 总数 | 通过 | 失败 | 跳过 | -|------|------|------|------|------| -| BUG修复专项 | 6 | 6 | 0 | 0 | -| 发布考试功能 | 6 | 6 | 0 | 0 | -| 考试列表功能 | 5 | 3 | 0 | 2 | -| 考试详情/编辑 | 4 | 2 | 0 | 2 | -| 权限与数据隔离 | 3 | 1 | 0 | 2 | -| **合计** | **24** | **18** | **0** | **6** | - -**通过率:100%(18/18,不含跳过项)** - ---- - -## 五、API验证数据 - -### 5.1 试卷列表接口 -``` -GET /api/exam/paper/list?status=PUBLISHED -Response: 200 OK -返回当前部门已发布试卷列表 -``` - -### 5.2 学员列表接口 -``` -GET /api/system/user/list?role=STUDENT -Response: 200 OK -返回数据: 员工-1(departmentId=5), 员工-2(departmentId=5) -验证: 只返回讲师所属部门的学员 -``` - -### 5.3 考试创建接口 -``` -POST /api/exam -Response: 200 OK -创建数据验证: -- id: 2 -- title: "回归测试考试-讲师发布" -- departmentId: 5 (自动获取) -- creatorId: 3 (正确保存) -- maxAttempts: 1 (默认值) -- targets: [员工-1, 员工-2] -``` - ---- - -## 六、测试结论 - -### 6.1 总体评估 -**测试通过**。考试管理模块核心功能运行正常,BUG修复验证全部通过。 - -### 6.2 主要发现 -1. ✅ 试卷列表、参与人员列表接口正常工作 -2. ✅ 讲师发布考试时,departmentId自动从当前用户获取 -3. ✅ creatorId正确保存 -4. ✅ maxAttempts默认值校验正常 -5. ✅ 表单校验(必填项、时间校验)正常 -6. ✅ 部门数据隔离有效 - -### 6.3 遗留问题 -无。 - -### 6.4 建议 -1. 后续可补充管理员角色和学员角色的测试 -2. 建议在有更多测试数据后验证分页功能 -3. 建议在实际考试进行后验证考试记录相关功能 - ---- - -## 七、附录 - -### 7.1 测试截图索引 -- 登录页面:正常 -- 考试管理列表页:正常 -- 发布考试页面:正常 -- 考试详情弹窗:正常 - -### 7.2 相关文档 -- [产品需求文档](./PRD.md) -- [测试方案](./TestPlan_ExamModule.md) -- [API文档](http://localhost:8080/swagger-ui.html) - ---- - -**报告生成时间:2026-01-13 11:20** diff --git a/training-system/docs/TestReport_TrainingModule.md b/training-system/docs/TestReport_TrainingModule.md deleted file mode 100644 index fd76dc8..0000000 --- a/training-system/docs/TestReport_TrainingModule.md +++ /dev/null @@ -1,158 +0,0 @@ -# 培训计划模块回归测试报告 - -> 版本:V1.0 -> 测试日期:2026-01-13 -> 测试执行人:自动化测试 -> 测试账号:jiangshi(讲师角色) - ---- - -## 一、测试概述 - -本次测试针对培训计划模块进行回归测试,重点验证之前修复的BUG是否已解决,同时对核心功能进行验证。 - -### 1.1 测试环境 - -| 项目 | 内容 | -|------|------| -| 系统地址 | http://localhost:8080 | -| 测试账号 | jiangshi / 123456 | -| 角色 | LECTURER(讲师) | -| 浏览器 | Chrome | - -### 1.2 测试范围 - -- BUG修复专项验证 -- 创建培训计划功能 -- 培训计划列表功能 -- 培训计划详情功能 -- 权限与数据隔离 - ---- - -## 二、BUG修复专项验证结果 - -| 验证项 | 测试步骤 | 预期结果 | 实际结果 | 状态 | -|--------|----------|----------|----------|------| -| 创建计划部门ID | 讲师创建培训计划,不传departmentId | 自动使用讲师所在部门,创建成功 | 创建成功,显示"保存成功" | ✅ 通过 | -| 列表数据字段 | 进入培训计划列表页 | 列表正常显示(使用records字段) | 列表正常显示3条数据 | ✅ 通过 | -| 详情页加载 | 点击查看详情 | 详情页正常加载(接口使用/{id}) | 详情页正常加载 | ✅ 通过 | -| 知识列表接口 | 编辑页添加知识 | 正常加载(使用/page接口) | 弹窗正常显示知识列表 | ✅ 通过 | -| 考试列表接口 | 编辑页添加考试 | 正常加载(使用/page接口) | 弹窗正常加载(显示暂无可添加的考试) | ✅ 通过 | - -**BUG修复验证结论:5项全部通过** - ---- - -## 三、功能测试结果 - -### 3.1 创建培训计划功能 - -| 用例ID | 测试项 | 预期结果 | 实际结果 | 状态 | -|--------|--------|----------|----------|------| -| TP-001 | 正常创建培训计划 | 创建成功,列表显示新计划 | 创建成功,列表显示"回归测试-培训计划001" | ✅ 通过 | -| TP-004 | 部门自动获取 | 自动使用讲师所属部门,创建成功 | 自动使用讲师部门(技术支持部),创建成功 | ✅ 通过 | - -### 3.2 关联知识/考试功能 - -| 用例ID | 测试项 | 预期结果 | 实际结果 | 状态 | -|--------|--------|----------|----------|------| -| TP-101 | 添加知识内容 | 知识内容添加成功,显示在培训内容列表 | 知识内容添加成功,显示"JLR车辆知识-2" | ✅ 通过 | -| TP-102 | 设置必修/选修 | 必修/选修标记正确保存 | 默认显示"必修"标记 | ✅ 通过 | -| TP-104 | 知识列表加载 | 只显示当前部门已发布知识 | 显示2条已发布知识 | ✅ 通过 | -| TP-105 | 考试列表加载 | 只显示当前部门进行中考试 | 接口正常调用(暂无可用考试) | ✅ 通过 | - -### 3.3 培训计划列表功能 - -| 用例ID | 测试项 | 预期结果 | 实际结果 | 状态 | -|--------|--------|----------|----------|------| -| TP-301 | 列表正常显示 | 列表正常显示,包含计划名称、状态、时间、进度等 | 正常显示3条数据,字段完整 | ✅ 通过 | -| TP-302 | 状态筛选 | 只显示进行中的计划 | 筛选"进行中"后只显示1条 | ✅ 通过 | -| TP-303 | 关键字搜索 | 显示匹配的计划 | 搜索"回归测试"后只显示匹配项 | ✅ 通过 | -| TP-305 | 数据字段匹配 | 前端显示字段与后端返回一致 | records字段正确解析 | ✅ 通过 | - -### 3.4 培训计划详情功能 - -| 用例ID | 测试项 | 预期结果 | 实际结果 | 状态 | -|--------|--------|----------|----------|------| -| TP-401 | 查看计划详情 | 详情页正常加载,显示完整信息 | 正常加载,显示基本信息 | ✅ 通过 | -| TP-404 | 编辑跳转 | 正确跳转到编辑页面 | 正确跳转,基本信息回显正确 | ✅ 通过 | - -### 3.5 权限与数据隔离 - -| 用例ID | 测试项 | 预期结果 | 实际结果 | 状态 | 备注 | -|--------|--------|----------|----------|------|------| -| TP-801 | 讲师只能管理本部门 | 只显示本部门的培训计划 | 显示所有部门的计划 | ⚠️ 待完善 | 数据隔离逻辑未实现 | - ---- - -## 四、测试统计 - -### 4.1 测试用例执行统计 - -| 类别 | 总数 | 通过 | 失败 | 待完善 | -|------|------|------|------|--------| -| BUG修复验证 | 5 | 5 | 0 | 0 | -| 功能测试 | 13 | 12 | 0 | 1 | -| **总计** | **18** | **17** | **0** | **1** | - -### 4.2 通过率 - -- **BUG修复验证通过率**: 100% -- **功能测试通过率**: 92.3% -- **总体通过率**: 94.4% - ---- - -## 五、问题汇总 - -### 5.1 已修复的BUG(本次验证通过) - -| 问题 | 修复方案 | 状态 | -|------|----------|------| -| 创建计划时报"所属部门不存在" | 非管理员用户自动使用当前用户部门ID | ✅ 已修复 | -| 列表页数据不显示 | 前端改用records字段解析数据 | ✅ 已修复 | -| 详情页加载失败 | 修正API路径为/{id} | ✅ 已修复 | -| 知识列表接口404 | 修正API路径为/knowledge/page | ✅ 已修复 | -| 考试列表接口404 | 修正API路径为/exam/page | ✅ 已修复 | - -### 5.2 待完善项 - -| 问题描述 | 优先级 | 建议 | -|----------|--------|------| -| 讲师可以看到所有部门的培训计划 | 中 | 后端需添加部门级数据隔离逻辑 | -| 创建计划时添加的知识和参与人员未正确保存 | 高 | 需排查前端提交数据和后端保存逻辑 | - ---- - -## 六、测试结论 - -本次针对培训计划模块的回归测试**基本通过**: - -1. **BUG修复验证**:5项全部通过,之前修复的问题已确认解决 -2. **核心功能**:创建、列表、详情、筛选、搜索等功能正常工作 -3. **待改进**: - - 数据隔离逻辑待实现(讲师应只能看到本部门数据) - - 创建计划时的关联数据(知识、参与人员)保存需要进一步排查 - -**建议**:本次修复的BUG可以发布上线,但需要后续版本完善数据隔离和关联数据保存功能。 - ---- - -## 七、附录 - -### 7.1 修改的文件清单 - -| 文件路径 | 修改内容 | -|----------|----------| -| `TrainingPlanServiceImpl.java:104-115` | 非管理员用户自动使用当前用户部门ID | -| `training/plan.html:113` | `pageData.list` → `pageData.records` | -| `training/detail.html:79` | `/training/plan/${id}/detail` → `/training/plan/${id}` | -| `training/plan-edit.html:372` | `/knowledge/list` → `/knowledge/page` | -| `training/plan-edit.html:416` | `/exam/list` → `/exam/page` | - -### 7.2 相关文档 - -- [产品需求文档](./PRD.md) -- [测试方案](./TestPlan_TrainingModule.md) -- [API接口文档](http://localhost:8080/swagger-ui.html) diff --git a/training-system/docs/test-reports/BugAnalysis_2.0_Knowledge.md b/training-system/docs/test-reports/BugAnalysis_2.0_Knowledge.md deleted file mode 100644 index af18263..0000000 --- a/training-system/docs/test-reports/BugAnalysis_2.0_Knowledge.md +++ /dev/null @@ -1,449 +0,0 @@ -# BUG分析报告 - 模块二:知识库 - -> 分析日期:2026-01-12 -> 分析人员:Java高级工程师 -> 关联测试报告:TestReport_2.0_Knowledge.md -> 产品文档版本:PRD V1.0 - ---- - -## 一、报告概述 - -本报告针对知识库模块测试报告(TestReport_2.0_Knowledge.md)中发现的3个缺陷进行深度分析,明确BUG产生的根本原因,并提供具体的解决方案。 - -### 缺陷汇总 - -| 缺陷编号 | 缺陷名称 | 严重程度 | 优先级 | 影响范围 | -|---------|---------|---------|--------|---------| -| BUG-KM-001 | 创建知识时creator_id未设置 | **阻断** | P0 | 15个用例被阻塞 | -| BUG-KM-002 | 讲师可以操作其他部门的分类 | 高 | P1 | 部门数据隔离失效 | -| BUG-KM-003 | 文件上传功能异常 | 高 | P1 | 知识文档无法上传 | - ---- - -## 二、缺陷详细分析 - -### 2.1 BUG-KM-001:创建知识时creator_id未设置 - -#### 基本信息 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-001 | -| 严重程度 | **阻断(Blocker)** | -| 优先级 | P0 - 立即修复 | -| 错误信息 | `Field 'creator_id' doesn't have a default value` | -| 影响范围 | 知识创建功能完全不可用,阻塞15个测试用例 | - -#### 原因分析 - -**1. 数据库设计约束** - -根据PRD文档第5.1节"知识(km_knowledge)"实体定义: - -``` -| 字段 | 类型 | 说明 | -|------|------|------| -| creator_id | Long | 创建人 | -``` - -`creator_id` 是必填字段(NOT NULL),用于记录知识的创建人ID。 - -**2. 业务逻辑缺失** - -在 `KnowledgeServiceImpl.createKnowledge()` 方法中存在以下问题: - -- **未从认证上下文获取用户ID**:方法没有从当前登录用户的JWT Token或SecurityContext中提取用户ID -- **直接透传前端数据**:将前端传入的DTO直接转换为实体保存,未补充后端自动填充的字段 -- **违反业务规则**:PRD要求"知识"必须有创建人,用于追溯和权限控制 - -**3. 根本原因** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 请求流程分析 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 前端请求 │ -│ │ │ -│ ▼ │ -│ Controller层 ────────────────────────────────────────────── │ -│ │ 接收KnowledgeDTO(不含creator_id) │ -│ ▼ │ -│ Service层 ───────────────────────────────────────────────── │ -│ │ ❌ 未从SecurityContext获取当前用户ID │ -│ │ ❌ 未设置 knowledge.setCreatorId(userId) │ -│ ▼ │ -│ Mapper层 ────────────────────────────────────────────────── │ -│ │ 执行INSERT语句 │ -│ ▼ │ -│ MySQL ───────────────────────────────────────────────────── │ -│ │ creator_id字段为NULL,违反NOT NULL约束 │ -│ ▼ │ -│ ❌ 抛出异常:Field 'creator_id' doesn't have a default value │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 解决方案 - -**方案一:在Service层补充用户ID设置(推荐)** - -修改位置:`KnowledgeServiceImpl.createKnowledge()` 方法 - -修改逻辑: -1. 注入 `HttpServletRequest` 或创建 `SecurityUtil` 工具类 -2. 从JWT Token中解析当前登录用户ID -3. 在保存实体前设置 `knowledge.setCreatorId(currentUserId)` -4. 同时设置 `knowledge.setDepartmentId(userDepartmentId)` 确保部门隔离 - -**方案二:使用MyBatis Plus自动填充** - -配置 `MetaObjectHandler` 实现字段自动填充: -1. 在 `creator_id` 字段上添加 `@TableField(fill = FieldFill.INSERT)` 注解 -2. 实现 `MetaObjectHandler.insertFill()` 方法,自动填充创建人ID - -**推荐方案**:方案一,显式设置更清晰,便于维护和调试。 - ---- - -### 2.2 BUG-KM-002:讲师可以操作其他部门的分类 - -#### 基本信息 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-002 | -| 严重程度 | 高(High) | -| 优先级 | P1 - 紧急修复 | -| 缺陷类型 | 安全漏洞 / 权限控制缺陷 | -| 影响范围 | 部门数据隔离完全失效 | - -#### 原因分析 - -**1. PRD业务规则** - -根据PRD文档第4.1节"权限规则": - -``` -讲师: - ├── 管理本部门知识库(上传/编辑/删除) - ├── 管理本部门题库(增删改查) - ... -``` - -根据PRD文档第4.4节"数据隔离规则": - -| 数据类型 | 隔离方式 | -|----------|----------| -| 知识库 | 按部门隔离,只能看本部门 | -| 题库 | 按部门隔离,讲师只能操作本部门 | - -**2. 权限校验缺失** - -`CategoryService` 和 `CategoryController` 中没有实现以下校验逻辑: - -- 未校验当前用户角色(管理员 vs 讲师) -- 未校验请求中的 `departmentId` 与当前用户所属部门是否一致 -- 直接信任前端传入的 `departmentId` 参数 - -**3. 根本原因** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 权限校验缺失分析 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 讲师(部门1)发起请求 │ -│ │ │ -│ │ POST /api/knowledge/category │ -│ │ Body: { "name": "xxx", "departmentId": 2 } ← 伪造部门 │ -│ ▼ │ -│ Controller层 ────────────────────────────────────────────── │ -│ │ ❌ 未校验用户角色 │ -│ │ ❌ 未校验departmentId权限 │ -│ ▼ │ -│ Service层 ───────────────────────────────────────────────── │ -│ │ ❌ 直接保存,未做部门隔离校验 │ -│ ▼ │ -│ 数据库 ──────────────────────────────────────────────────── │ -│ │ 分类创建成功,department_id = 2 │ -│ ▼ │ -│ ❌ 安全漏洞:讲师越权操作其他部门数据 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 解决方案 - -**方案一:Service层统一校验(推荐)** - -在 `CategoryServiceImpl` 中增加部门权限校验: - -``` -校验逻辑伪代码: -1. 获取当前用户信息(ID、角色、部门ID) -2. IF 角色 == ADMIN: - 允许操作任意部门 - ELSE IF 角色 == LECTURER: - IF 请求的departmentId != 用户的departmentId: - 抛出异常:"无权操作其他部门数据" - END IF - END IF -3. 继续执行业务逻辑 -``` - -**方案二:AOP切面统一拦截** - -创建 `@DepartmentIsolation` 注解 + 切面类: -1. 在需要部门隔离的方法上添加注解 -2. 切面自动校验当前用户部门与请求部门是否一致 - -**方案三:封装通用工具方法** - -创建 `DepartmentPermissionUtil.checkPermission(Long targetDepartmentId)` 方法: -- 供各模块(知识库、题库、试卷等)复用 -- 统一的异常处理和错误提示 - -**推荐方案**:方案一 + 方案三结合,在Service层显式调用校验方法,逻辑清晰,便于维护。 - ---- - -### 2.3 BUG-KM-003:文件上传功能异常 - -#### 基本信息 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-003 | -| 严重程度 | 高(High) | -| 优先级 | P1 - 紧急修复 | -| 错误码 | 1501 | -| 错误信息 | `{"code":1501,"message":"文件保存失败"}` | -| 影响范围 | 所有文档/视频上传功能不可用 | - -#### 原因分析 - -**1. 可能原因一:配置问题** - -`application.yml` 或 `application-dev.yml` 中文件上传路径配置问题: -- 路径未配置 -- 路径配置错误(如使用了Linux路径分隔符但运行在Windows环境) -- 使用了相对路径但基准目录不正确 - -**2. 可能原因二:目录不存在** - -配置的上传目录在服务器/本地环境中不存在: -- 开发环境与生产环境路径不一致 -- 服务首次启动时未自动创建目录 - -**3. 可能原因三:权限不足** - -应用程序对目标目录没有写入权限: -- Linux环境下目录权限设置不正确 -- Windows环境下目录被其他程序占用或权限受限 - -**4. 可能原因四:代码健壮性问题** - -`FileService` 实现中缺少健壮性处理: -- 未在保存文件前检查/创建目录 -- 异常处理过于笼统,丢失了原始错误信息 - -**5. 根本原因推测** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 文件上传失败分析 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 上传请求 │ -│ │ │ -│ │ POST /api/file/upload/document │ -│ │ Content-Type: multipart/form-data │ -│ ▼ │ -│ FileController ──────────────────────────────────────────── │ -│ │ 接收MultipartFile │ -│ ▼ │ -│ FileService ─────────────────────────────────────────────── │ -│ │ 读取配置的上传路径 │ -│ │ 拼接完整文件路径 │ -│ ▼ │ -│ 文件系统 ────────────────────────────────────────────────── │ -│ │ │ -│ │ ❌ 可能问题1:目录不存在 │ -│ │ ❌ 可能问题2:无写入权限 │ -│ │ ❌ 可能问题3:路径格式错误 │ -│ ▼ │ -│ 抛出IOException → 捕获后返回统一错误码1501 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 解决方案 - -**步骤一:检查配置文件** - -检查 `application.yml` 和 `application-dev.yml`: - -```yaml -# 期望的配置格式示例 -file: - upload: - path: D:/upload/training-system/ # Windows - # path: /data/upload/training-system/ # Linux - max-size: 100MB - allowed-types: pdf,doc,docx,xls,xlsx,ppt,pptx,mp4,avi -``` - -**步骤二:检查FileService实现** - -确保在保存文件前创建目录: - -``` -伪代码: -1. 获取配置的上传路径 uploadPath -2. 生成唯一文件名(如:UUID + 原始扩展名) -3. 拼接完整路径 fullPath = uploadPath + "/" + fileName -4. 获取父目录 parentDir = fullPath.getParent() -5. IF 父目录不存在: - 创建目录(包含所有父目录) - END IF -6. 保存文件到 fullPath -7. 返回文件访问URL -``` - -**步骤三:增加详细日志** - -在FileService中增加关键日志输出: - -``` -日志内容: -- [INFO] 配置的上传路径: {uploadPath} -- [INFO] 生成的文件名: {fileName} -- [INFO] 完整保存路径: {fullPath} -- [ERROR] 文件保存失败: {具体异常信息和堆栈} -``` - -**步骤四:检查目录权限** - -- **Linux环境**: - ```bash - mkdir -p /data/upload/training-system - chown -R tomcat:tomcat /data/upload - chmod -R 755 /data/upload - ``` - -- **Windows环境**: - 确保运行Java应用的用户对目标目录有完全控制权限 - -**步骤五:启动时自动创建目录** - -在应用启动时(如 `@PostConstruct` 方法)检查并创建上传目录: - -``` -伪代码: -@PostConstruct -public void init() { - File uploadDir = new File(uploadPath); - if (!uploadDir.exists()) { - boolean created = uploadDir.mkdirs(); - log.info("创建上传目录: {}, 结果: {}", uploadPath, created); - } -} -``` - ---- - -## 三、修复优先级与建议 - -### 3.1 修复优先级 - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ 修复优先级路线图 │ -├──────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 优先级1 ───────────────────────────────────────────────────────────── │ -│ │ │ -│ │ BUG-KM-001: creator_id未设置 │ -│ │ 原因: 阻断级缺陷,整个知识创建功能不可用 │ -│ │ 影响: 15个测试用例被阻塞 │ -│ │ 建议: 立即修复,修复后重新执行全部阻塞用例 │ -│ │ │ -│ ▼ │ -│ 优先级2 ───────────────────────────────────────────────────────────── │ -│ │ │ -│ │ BUG-KM-003: 文件上传失败 │ -│ │ 原因: 知识库核心功能依赖文件上传 │ -│ │ 影响: 无法上传文档和视频 │ -│ │ 建议: 紧急修复,排查配置和目录权限问题 │ -│ │ │ -│ ▼ │ -│ 优先级3 ───────────────────────────────────────────────────────────── │ -│ │ │ -│ │ BUG-KM-002: 部门隔离未实现 │ -│ │ 原因: 安全漏洞,违反业务规则 │ -│ │ 影响: 讲师可越权操作其他部门数据 │ -│ │ 建议: 紧急修复,需全面审查相关模块 │ -│ │ │ -│ ▼ │ -│ 回归测试 ──────────────────────────────────────────────────────────── │ -│ │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 风险提示 - -| 风险点 | 说明 | 建议 | -|--------|------|------| -| 同类缺陷 | 其他模块(考试、培训计划)可能存在相同的 `creator_id` 未设置问题 | 修复时同步审查相关模块 | -| 部门隔离 | 题库、试卷、考试模块可能也缺少部门隔离校验 | 建议封装统一的部门权限校验工具 | -| 配置管理 | 文件上传路径在不同环境可能不一致 | 使用环境变量或配置中心管理 | - -### 3.3 测试建议 - -修复完成后需执行以下测试: - -1. **BUG-KM-001修复后**: - - 重新执行KS系列全部8个用例 - - 重新执行KD系列全部4个用例 - - 重新执行KC-003用例 - -2. **BUG-KM-002修复后**: - - 重新执行KC-004用例 - - 新增测试用例:验证管理员可操作所有部门 - - 新增测试用例:验证学员无法创建分类 - -3. **BUG-KM-003修复后**: - - 重新执行KM系列全部7个用例 - - 测试不同文件类型(PDF、Word、Excel、PPT、视频) - - 测试边界条件(大文件、空文件、特殊字符文件名) - ---- - -## 四、总结 - -### 4.1 缺陷共性分析 - -本次知识库模块测试发现的3个缺陷存在共同特点: - -| 共性问题 | 具体表现 | 改进建议 | -|---------|---------|---------| -| **后端逻辑不完善** | 未实现PRD要求的业务规则 | 开发前充分理解PRD,Code Review时对照检查 | -| **安全意识不足** | 部门隔离未实现,信任前端参数 | 后端必须校验所有涉及权限的参数 | -| **健壮性欠缺** | 文件上传未处理目录不存在的情况 | 增加防御性编程,完善异常处理 | - -### 4.2 后续行动项 - -- [ ] 修复BUG-KM-001:补充creator_id设置逻辑 -- [ ] 修复BUG-KM-002:实现部门隔离校验 -- [ ] 修复BUG-KM-003:排查并修复文件上传问题 -- [ ] 审查其他模块是否存在同类问题 -- [ ] 封装通用的部门权限校验工具类 -- [ ] 回归测试验证修复效果 - ---- - -**报告生成时间**:2026-01-12 - -**审核状态**:待修复 diff --git a/training-system/docs/test-reports/RegressionReport_1.3_UserManagement.md b/training-system/docs/test-reports/RegressionReport_1.3_UserManagement.md deleted file mode 100644 index fee29ed..0000000 --- a/training-system/docs/test-reports/RegressionReport_1.3_UserManagement.md +++ /dev/null @@ -1,217 +0,0 @@ -# 复测报告 - 模块1.3:员工管理 - -> 复测日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 -> 原始测试报告:TestReport_1.3_UserManagement.md - ---- - -## 一、复测概要 - -| 项目 | 数值 | -|------|------| -| 原始缺陷数 | 3 | -| 复测通过 | 2 | -| 复测失败 | 1 | -| **修复率** | **66.7%** | - ---- - -## 二、缺陷复测结果 - -| 缺陷编号 | 严重程度 | 描述 | 复测结果 | -|---------|---------|------|---------| -| BUG-USER-001 | 中 | 手机号未做唯一性校验 | ✅ **通过** | -| BUG-USER-002 | 高 | 讲师未实现部门数据隔离 | ❌ **失败** | -| BUG-USER-003 | 严重 | 学员可访问员工管理接口 | ✅ **通过** | - ---- - -## 三、复测详情 - -### 3.1 BUG-USER-001:手机号唯一性校验 ✅ - -| 属性 | 描述 | -|------|------| -| 复测结果 | **通过** | -| 测试步骤 | 1. 管理员登录
2. 使用已存在的手机号`13800138000`创建新员工
3. 验证返回结果 | -| 预期结果 | 返回错误,提示手机号已存在 | -| 实际结果 | 返回 `{"code":1005,"message":"手机号已存在"}` | -| 验证时间 | 2026-01-09 15:55:44 | - -**测试请求:** -```bash -POST /api/system/user -{ - "username": "duplicate_phone_test", - "password": "Test@123", - "realName": "DuplicatePhoneTest", - "phone": "13800138000", # 与admin重复 - "role": "STUDENT", - "departmentId": 1 -} -``` - -**测试响应:** -```json -{ - "code": 1005, - "message": "手机号已存在", - "timestamp": 1767945744357, - "success": false -} -``` - ---- - -### 3.2 BUG-USER-002:讲师部门数据隔离 ❌ - -| 属性 | 描述 | -|------|------| -| 复测结果 | **失败** | -| 测试步骤 | 1. 讲师账号登录(救援一部,departmentId=1)
2. 访问员工管理分页接口
3. 验证返回的员工列表 | -| 预期结果 | 只返回救援一部的员工(5人) | -| 实际结果 | 返回全部7个员工,包含救援二部的admin和zhangsan | -| 验证时间 | 2026-01-09 15:56:02 | - -**测试请求:** -```bash -GET /api/system/user/page?current=1&size=10 -Authorization: Bearer {test_lecturer_token} -``` - -**测试响应(关键数据):** -```json -{ - "code": 200, - "data": { - "total": 7, - "records": [ - {"id": 7, "realName": "TestLecturer", "departmentName": "救援一部"}, - {"id": 6, "realName": "TestAdmin", "departmentName": "救援一部"}, - {"id": 5, "realName": "TestUser002", "departmentName": "救援一部"}, - {"id": 4, "realName": "TestUser001", "departmentName": "救援一部"}, - {"id": 3, "realName": "讲师", "departmentName": "救援一部"}, - {"id": 2, "realName": "张三", "departmentName": "救援二部"}, // ❌ 不应返回 - {"id": 1, "realName": "系统管理员", "departmentName": "救援二部"} // ❌ 不应返回 - ] - } -} -``` - -**问题分析:** -讲师查询员工列表时,后端未根据当前用户的部门ID过滤数据,导致讲师可以看到所有部门的员工信息。 - -**修复建议:** -在 `UserServiceImpl.page()` 方法中,当当前用户角色为 `LECTURER` 时,自动在查询条件中添加部门过滤: -```java -if (currentUser.getRole() == Role.LECTURER) { - queryWrapper.eq("department_id", currentUser.getDepartmentId()); -} -``` - ---- - -### 3.3 BUG-USER-003:学员访问权限控制 ✅ - -| 属性 | 描述 | -|------|------| -| 复测结果 | **通过** | -| 测试步骤 | 1. 学员账号登录
2. 访问员工管理分页接口
3. 验证返回结果 | -| 预期结果 | 返回403 Forbidden | -| 实际结果 | 返回 `{"code":403,"message":"没有操作权限"}` | -| 验证时间 | 2026-01-09 15:56:22 | - -**测试请求:** -```bash -GET /api/system/user/page?current=1&size=10 -Authorization: Bearer {test_user_001_token} -``` - -**测试响应:** -```json -{ - "code": 403, - "message": "没有操作权限", - "timestamp": 1767945782146, - "success": false -} -``` - ---- - -## 四、回归测试(原通过用例确认) - -为确保修复未引入新问题,对原测试报告中通过的4个用例进行回归验证: - -| 用例编号 | 测试项 | 回归结果 | -|---------|--------|---------| -| USER-001 | 创建员工(有效数据) | ✅ 通过(新员工创建正常) | -| USER-003 | 启用/禁用员工 | ✅ 通过(状态切换正常) | -| USER-004 | 编辑员工信息 | ✅ 通过(编辑功能正常) | -| USER-005 | 删除员工 | ✅ 通过(删除功能正常) | - ---- - -## 五、测试结论 - -### 5.1 复测结果总结 - -| 评估项 | 结果 | -|--------|------| -| 缺陷修复率 | 66.7%(2/3) | -| P0级缺陷修复 | 1/1 已修复 | -| P1级缺陷修复 | 0/1 未修复 | -| P2级缺陷修复 | 1/1 已修复 | -| 回归测试 | 全部通过(无新增问题) | - -### 5.2 遗留问题 - -| 缺陷编号 | 严重程度 | 状态 | 说明 | -|---------|---------|------|------| -| BUG-USER-002 | 高 | **待修复** | 讲师部门数据隔离仍未实现 | - -### 5.3 模块状态评估 - -| 功能项 | 状态 | -|--------|------| -| 员工CRUD基本功能 | ✅ 可用 | -| 数据唯一性校验 | ✅ 已实现 | -| 学员权限控制 | ✅ 已实现 | -| 讲师数据隔离 | ❌ 未实现 | - -### 5.4 建议 - -1. **BUG-USER-002 需要继续修复** - - 问题影响:讲师可查看其他部门员工信息,违反PRD中的数据隔离要求 - - 安全风险:中等(信息泄露) - - 建议优先级:P1 - -2. **建议扩展验证** - - 修复后同时验证讲师创建/编辑/删除员工时的部门隔离是否生效 - ---- - -## 六、附录 - -### 6.1 测试账号 - -| 账号 | 角色 | 部门 | -|------|------|------| -| admin | ADMIN | 救援二部 | -| test_lecturer | LECTURER | 救援一部 | -| test_user_001 | STUDENT | 救援一部 | - -### 6.2 相关文档 - -| 文档 | 路径 | -|------|------| -| 原始测试报告 | docs/test-reports/TestReport_1.3_UserManagement.md | -| 产品需求文档 | docs/PRD.md | - ---- - -**报告生成时间:** 2026-01-09 15:58:00 - -**报告签发:** AI测试工程师 diff --git a/training-system/docs/test-reports/RegressionReport_1.3_UserManagement_V2.md b/training-system/docs/test-reports/RegressionReport_1.3_UserManagement_V2.md deleted file mode 100644 index fd2e775..0000000 --- a/training-system/docs/test-reports/RegressionReport_1.3_UserManagement_V2.md +++ /dev/null @@ -1,374 +0,0 @@ -1# 复测报告 - 模块1.3:员工管理(V2) - -> 复测日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 -> 原始测试报告:TestReport_1.3_UserManagement.md -> 版本:V2(补充深度测试) - ---- - -## 一、复测概要 - -| 项目 | 数值 | -|------|------| -| 原始缺陷数 | 3 | -| 复测通过 | 2 | -| 复测失败 | 1 | -| **新发现问题** | **2** | -| **修复率** | **66.7%** | - ---- - -## 二、原始缺陷复测结果 - -| 缺陷编号 | 严重程度 | 描述 | 复测结果 | -|---------|---------|------|---------| -| BUG-USER-001 | 中 | 手机号未做唯一性校验 | ✅ **通过** | -| BUG-USER-002 | 高 | 讲师未实现部门数据隔离 | ❌ **失败** | -| BUG-USER-003 | 严重 | 学员可访问员工管理接口 | ✅ **通过** | - ---- - -## 三、深度测试发现的新问题 - -### 3.1 BUG-USER-004:编辑员工时无法修改状态(后端Bug,非前端问题) - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-USER-004 | -| 严重程度 | **高** | -| 优先级 | P1 | -| 缺陷类型 | **后端Bug** | -| 缺陷描述 | 通过编辑员工接口(PUT /api/system/user)传入status字段,接口返回成功,但状态未被更新 | -| 重现步骤 | 1. 管理员登录
2. 前端编辑员工,状态选择"禁用"
3. 点击保存,提示"保存成功"
4. 刷新页面,状态仍为"启用" | -| 预期结果 | 员工状态变为"禁用" | -| 实际结果 | 员工状态仍为"启用",状态修改被静默忽略 | -| 根因分析 | **后端问题**:UserDTO类中没有定义status字段,updateUser()方法也未处理状态更新 | -| 影响范围 | 前端编辑表单中的状态选择功能完全无效,用户体验严重受损 | - -#### 前端代码验证 ✅ - -前端代码**正确传递了status字段**(user.html:139): -```javascript -const data = { - realName: ..., - phone: ..., - username: ..., - departmentId: ..., - groupId: ..., - role: ..., - status: document.getElementById('formStatus').value // ✅ 前端正确传递 -}; -``` - -#### 问题定位 - -| 层级 | 状态 | 说明 | -|------|------|------| -| 前端页面 | ✅ 正常 | 有状态选择下拉框,正确绑定值 | -| 前端JS | ✅ 正常 | saveData()正确收集status并发送 | -| 后端DTO | ❌ 缺失 | UserDTO没有status字段,Jackson忽略该值 | -| 后端Service | ❌ 未处理 | updateUser()没有setStatus()调用 | - -**测试请求:** -```bash -PUT /api/system/user -Content-Type: application/json -{ - "id": 5, - "username": "test_user_002", - "realName": "TestUser002", - "phone": "13800138001", - "role": "STUDENT", - "departmentId": 1, - "status": "DISABLED" // 尝试修改状态 -} -``` - -**测试响应:** -```json -{ - "code": 200, - "message": "操作成功", - "success": true -} -``` - -**验证查询结果:** -```json -{ - "data": { - "id": 5, - "status": "ENABLED", // 状态未改变! - "statusName": "启用" - } -} -``` - -**修复建议(二选一):** - -**方案A:在UserDTO中添加status字段** -```java -// UserDTO.java -private String status; - -// UserServiceImpl.updateUser() -if (StrUtil.isNotBlank(dto.getStatus())) { - user.setStatus(UserStatus.valueOf(dto.getStatus())); -} -``` - -**方案B:明确API设计,状态修改只能通过专用接口** -- 保持现有设计,状态只能通过 `/enable` 和 `/disable` 接口修改 -- 但需在API文档中明确说明 -- 前端编辑表单应隐藏或禁用状态选项 - ---- - -### 3.2 BUG-USER-002 根因深入分析 - -**问题:** 讲师部门数据隔离未生效 - -**代码追踪:** - -1. **Service层代码正确**(UserServiceImpl.java:397-406) -```java -private void applyDepartmentIsolation(LambdaQueryWrapper queryWrapper) { - UserContext currentUser = UserContextHolder.getContext(); - if (currentUser != null && UserRole.LECTURER.name().equals(currentUser.getRole())) { - if (currentUser.getDepartmentId() != null) { // 问题:departmentId为null - queryWrapper.eq(User::getDepartmentId, currentUser.getDepartmentId()); - } - } -} -``` - -2. **问题根源**(JwtUtils.java:37-48) -```java -public String generateToken(Long userId, String username, String role) { - return JWT.create() - .withSubject(String.valueOf(userId)) - .withClaim("username", username) - .withClaim("role", role) // 只有这三个字段! - .withIssuedAt(now) - .withExpiresAt(expireDate) - .sign(Algorithm.HMAC256(secret)); -} -// 没有包含 departmentId! -``` - -3. **AuthInterceptor尝试获取但失败**(AuthInterceptor.java:68-70) -```java -if (jwt.getClaim("departmentId") != null && !jwt.getClaim("departmentId").isNull()) { - context.setDepartmentId(jwt.getClaim("departmentId").asLong()); -} -// 由于JWT中没有departmentId,这段代码不会执行 -``` - -**修复建议:** - -修改 `JwtUtils.generateToken()` 方法,添加 departmentId 参数: -```java -public String generateToken(Long userId, String username, String role, Long departmentId) { - return JWT.create() - .withSubject(String.valueOf(userId)) - .withClaim("username", username) - .withClaim("role", role) - .withClaim("departmentId", departmentId) // 新增 - .withIssuedAt(now) - .withExpiresAt(expireDate) - .sign(Algorithm.HMAC256(secret)); -} -``` - -同时修改 `AuthServiceImpl.login()` 方法,传入 departmentId。 - ---- - -## 四、重置密码功能验证 ✅ - -| 属性 | 描述 | -|------|------| -| 测试结果 | **通过** | -| 测试步骤 | 1. 管理员调用重置密码接口
2. 使用新密码登录
3. 使用旧密码登录 | -| 实际结果 | 新密码登录成功,旧密码登录失败(返回1003密码错误) | - -**测试详情:** - -```bash -# 重置密码 -PUT /api/system/user/password/reset -{"userId": 5, "newPassword": "NewPass@123"} -Response: {"code": 200, "message": "操作成功"} - -# 新密码登录 -POST /api/auth/login -{"username": "test_user_002", "password": "NewPass@123"} -Response: {"code": 200, "data": {"token": "..."}} ✅ - -# 旧密码登录(验证旧密码失效) -POST /api/auth/login -{"username": "test_user_002", "password": "Test@123"} -Response: {"code": 1003, "message": "密码错误"} ✅ -``` - ---- - -## 五、禁用/启用功能验证 ✅ - -| 属性 | 描述 | -|------|------| -| 测试结果 | **通过**(仅限专用接口) | -| 测试步骤 | 1. 调用 PUT /api/system/user/{id}/disable
2. 查询用户状态
3. 调用 PUT /api/system/user/{id}/enable
4. 查询用户状态 | -| 实际结果 | 专用接口可正常切换状态 | - -**测试详情:** - -```bash -# 禁用用户 -PUT /api/system/user/5/disable -Response: {"code": 200, "message": "操作成功"} - -# 验证状态 -GET /api/system/user/5 -Response: {"data": {"status": "DISABLED", "statusName": "禁用"}} ✅ - -# 启用用户 -PUT /api/system/user/5/enable -Response: {"code": 200, "message": "操作成功"} - -# 验证状态 -GET /api/system/user/5 -Response: {"data": {"status": "ENABLED", "statusName": "启用"}} ✅ -``` - ---- - -## 六、漏测原因分析 - -### 6.1 为什么会漏测"编辑状态"功能? - -| 原因类型 | 具体分析 | -|---------|---------| -| **测试用例设计不完整** | 原测试计划只验证了"启用/禁用"功能是否存在,但没有验证"通过编辑接口修改状态"的场景 | -| **测试路径覆盖不足** | 系统提供了两种修改状态的路径:1) 专用接口 2) 编辑接口。只测试了路径1 | -| **接口返回值未深入验证** | 编辑接口返回200成功,测试人员信任了返回值而没有二次查询确认 | -| **前后端交互场景遗漏** | 没有模拟前端表单的实际交互流程,即"编辑时同时修改状态" | - -### 6.2 为什么会漏测"重置密码验证"? - -| 原因类型 | 具体分析 | -|---------|---------| -| **验证不完整** | 原测试只验证了接口返回200,没有验证密码是否真的被修改 | -| **缺少端到端验证** | 应该用新密码登录验证,同时用旧密码登录确认失效 | - -### 6.3 为什么BUG-USER-002未修复但代码似乎已添加? - -| 原因类型 | 具体分析 | -|---------|---------| -| **修复不完整** | 开发在Service层添加了部门隔离逻辑,但忽略了JWT中缺少departmentId | -| **缺少集成测试** | 单元测试可能通过(因为可以mock UserContext),但集成测试会失败 | -| **代码审查疏漏** | JWT Token生成和解析的链路未被完整审查 | - -### 6.4 改进建议 - -1. **测试用例设计改进** - - 对于每个字段修改,都要验证"修改前后值确实变化" - - 对于多路径功能,所有路径都要测试 - -2. **验证方法改进** - - 不要只信任接口返回值,要二次查询确认 - - 对于认证类功能,要进行端到端验证 - -3. **增加测试场景** - - 正常场景 + 边界场景 + 异常场景 - - 单接口测试 + 流程测试 + 集成测试 - ---- - -## 七、缺陷汇总 - -### 7.1 现存缺陷清单 - -| 缺陷编号 | 严重程度 | 状态 | 描述 | 修复建议位置 | -|---------|---------|------|------|-------------| -| BUG-USER-002 | 高 | **待修复** | 讲师部门数据隔离未生效 | JwtUtils.java + AuthServiceImpl.java | -| BUG-USER-004 | 中 | **新发现** | 编辑接口无法修改状态 | UserDTO.java + UserServiceImpl.java | - -### 7.2 已修复缺陷 - -| 缺陷编号 | 严重程度 | 描述 | -|---------|---------|------| -| BUG-USER-001 | 中 | 手机号唯一性校验 ✅ | -| BUG-USER-003 | 严重 | 学员权限控制 ✅ | - ---- - -## 八、测试结论 - -### 8.1 模块状态评估 - -| 功能项 | 状态 | 说明 | -|--------|------|------| -| 创建员工 | ✅ 可用 | | -| 编辑员工(基本信息) | ✅ 可用 | | -| 编辑员工(状态) | ⚠️ 部分可用 | 只能通过专用接口修改 | -| 删除员工 | ✅ 可用 | | -| 重置密码 | ✅ 可用 | | -| 启用/禁用(专用接口) | ✅ 可用 | | -| 手机号唯一性 | ✅ 已实现 | | -| 学员权限控制 | ✅ 已实现 | | -| 讲师数据隔离 | ❌ 未实现 | JWT缺少departmentId | - -### 8.2 上线评估 - -**结论:建议暂缓上线** - -**原因:** -1. BUG-USER-002(讲师数据隔离)为高优先级安全问题,存在信息泄露风险 -2. 虽然基础CRUD功能可用,但不符合PRD中的数据隔离要求 - -### 8.3 修复优先级建议 - -| 优先级 | 缺陷编号 | 预计影响 | -|--------|---------|---------| -| **P0** | BUG-USER-002 | 修复后满足PRD数据隔离要求 | -| **P2** | BUG-USER-004 | 改善用户体验,非阻塞性问题 | - ---- - -## 九、附录 - -### 9.1 测试账号 - -| 账号 | 角色 | 部门 | -|------|------|------| -| admin | ADMIN | 救援二部 | -| test_lecturer | LECTURER | 救援一部 | -| test_user_001 | STUDENT | 救援一部 | -| test_user_002 | STUDENT | 救援一部 | - -### 9.2 相关代码文件 - -| 文件 | 说明 | -|------|------| -| UserController.java | 用户管理控制器 | -| UserServiceImpl.java | 用户服务实现,包含部门隔离逻辑 | -| UserDTO.java | 用户数据传输对象(缺少status字段) | -| JwtUtils.java | JWT工具类(缺少departmentId) | -| AuthInterceptor.java | 认证拦截器 | - -### 9.3 相关文档 - -| 文档 | 路径 | -|------|------| -| 原始测试报告 | docs/test-reports/TestReport_1.3_UserManagement.md | -| 第一版复测报告 | docs/test-reports/RegressionReport_1.3_UserManagement.md | -| 产品需求文档 | docs/PRD.md | - ---- - -**报告生成时间:** 2026-01-09 16:45:00 - -**报告签发:** AI测试工程师 diff --git a/training-system/docs/test-reports/TestReport_1.3_UserManagement.md b/training-system/docs/test-reports/TestReport_1.3_UserManagement.md deleted file mode 100644 index def96de..0000000 --- a/training-system/docs/test-reports/TestReport_1.3_UserManagement.md +++ /dev/null @@ -1,157 +0,0 @@ -# 测试报告 - 1.3 员工管理模块 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 7 | -| 通过 | 4 | -| 失败 | 3 | -| 阻塞 | 0 | -| 通过率 | 57.1% | - ---- - -## 二、测试结果详情 - -### 2.1 测试用例执行结果 - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| USER-001 | 管理员创建员工 | 成功创建,指定角色和部门 | 创建成功,返回用户ID=4 | **通过** | -| USER-002 | 创建重复手机号员工 | 提示"手机号已存在" | 创建成功,未校验手机号唯一性 | **失败** | -| USER-003 | 分配角色(ADMIN/LECTURER/STUDENT) | 角色分配成功 | ADMIN/LECTURER/STUDENT三种角色均分配成功 | **通过** | -| USER-004 | 禁用员工 | 状态变更为DISABLED | 状态成功变更为DISABLED | **通过** | -| USER-005 | 启用员工 | 状态变更为ENABLED | 状态成功变更为ENABLED | **通过** | -| USER-006 | 讲师查看本部门员工 | 只能看到本部门 | 可以看到所有部门用户(救援一部+救援二部) | **失败** | -| USER-007 | 学员无法访问员工管理 | 返回403 | 返回200,可查看所有用户数据 | **失败** | - ---- - -## 三、缺陷清单 - -### BUG-001:手机号未做唯一性校验 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-USER-001 | -| 关联用例 | USER-002 | -| 严重程度 | 中 | -| 优先级 | P2 | -| 缺陷描述 | 创建员工时未校验手机号是否已存在,允许重复手机号 | -| 重现步骤 | 1. 管理员登录
2. 创建员工A,手机号13800138001
3. 创建员工B,手机号13800138001
4. 两个员工都创建成功 | -| 预期结果 | 第二次创建时提示"手机号已存在" | -| 实际结果 | 两个员工都创建成功,存在重复手机号 | -| 建议修复 | 在UserServiceImpl.createUser()方法中添加手机号唯一性校验 | - ---- - -### BUG-002:讲师未实现部门数据隔离 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-USER-002 | -| 关联用例 | USER-006 | -| 严重程度 | 高 | -| 优先级 | P1 | -| 缺陷描述 | 讲师角色可以查看所有部门的员工信息,未按部门隔离 | -| 重现步骤 | 1. 讲师账号登录(部门:救援一部)
2. 访问GET /api/system/user/page
3. 返回结果包含救援一部和救援二部的员工 | -| 预期结果 | 讲师只能看到本部门(救援一部)的员工 | -| 实际结果 | 讲师可以看到所有部门的员工(7条记录) | -| 建议修复 | 在UserService查询方法中根据当前用户角色和部门进行数据过滤 | - -**测试数据截取:** -```json -{ - "total": 7, - "records": [ - {"id":7, "departmentName":"救援一部"}, - {"id":6, "departmentName":"救援一部"}, - {"id":2, "departmentName":"救援二部"}, // 不应可见 - {"id":1, "departmentName":"救援二部"} // 不应可见 - ] -} -``` - ---- - -### BUG-003:学员权限未控制,可访问员工管理接口 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-USER-003 | -| 关联用例 | USER-007 | -| 严重程度 | **严重** | -| 优先级 | P0 | -| 缺陷描述 | 学员角色可以访问员工管理接口,获取所有用户敏感信息 | -| 重现步骤 | 1. 学员账号登录
2. 访问GET /api/system/user/page
3. 成功返回所有用户列表 | -| 预期结果 | 返回403 Forbidden,拒绝访问 | -| 实际结果 | 返回200 OK,可查看所有用户数据 | -| 建议修复 | 1. 在UserController上添加@PreAuthorize注解限制ADMIN和LECTURER角色
2. 或在拦截器中根据角色过滤接口访问权限 | - -**安全风险:** 此漏洞导致学员可以获取所有员工的手机号、用户名等敏感信息,存在信息泄露风险。 - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | -|------|------|---------| -| /api/system/user | POST | 已测试 | -| /api/system/user/{id} | GET | 已测试 | -| /api/system/user/page | GET | 已测试 | -| /api/system/user/{id}/enable | PUT | 已测试 | -| /api/system/user/{id}/disable | PUT | 已测试 | -| /api/auth/login | POST | 已测试(辅助) | - ---- - -## 五、测试数据 - -### 5.1 测试账号 - -| 用户名 | 密码 | 角色 | 部门 | -|--------|------|------|------| -| admin | admin123 | ADMIN | 救援二部 | -| test_lecturer | Test@123 | LECTURER | 救援一部 | -| test_user_001 | Test@123 | STUDENT | 救援一部 | - -### 5.2 测试创建的数据 - -| ID | 用户名 | 角色 | 部门 | -|----|--------|------|------| -| 4 | test_user_001 | STUDENT | 救援一部 | -| 5 | test_user_002 | STUDENT | 救援一部 | -| 6 | test_admin | ADMIN | 救援一部 | -| 7 | test_lecturer | LECTURER | 救援一部 | - ---- - -## 六、结论与建议 - -### 6.1 测试结论 - -员工管理模块基本CRUD功能正常,但存在**3个缺陷**,其中: -- **1个严重缺陷**(P0):学员权限未控制 -- **1个高优先级缺陷**(P1):讲师部门隔离未实现 -- **1个中优先级缺陷**(P2):手机号唯一性未校验 - -### 6.2 修复建议优先级 - -1. **P0 - 立即修复**:BUG-USER-003(学员权限控制) -2. **P1 - 紧急修复**:BUG-USER-002(讲师部门隔离) -3. **P2 - 计划修复**:BUG-USER-001(手机号唯一性) - -### 6.3 阻塞问题 - -无阻塞问题,可继续进行后续模块测试。 - ---- - -**报告生成时间:** 2026-01-09 14:48:00 diff --git a/training-system/docs/test-reports/TestReport_2.0_Knowledge.md b/training-system/docs/test-reports/TestReport_2.0_Knowledge.md deleted file mode 100644 index ba37f8f..0000000 --- a/training-system/docs/test-reports/TestReport_2.0_Knowledge.md +++ /dev/null @@ -1,177 +0,0 @@ -# 测试报告 - 模块二:知识库 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 19 | -| 通过 | 2 | -| 失败 | 2 | -| 阻塞 | 15 | -| 通过率 | 10.5% | - -**注意:** 由于发现关键BUG(创建知识时creator_id未设置),导致知识创建失败,大部分测试用例被阻塞。 - ---- - -## 二、测试结果详情 - -### 2.1 知识分类测试(KC系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| KC-001 | 创建一级分类 | 成功创建 | 创建成功,返回ID=4 | **通过** | -| KC-002 | 创建多级分类(父子关系) | 层级关系正确 | 成功创建子分类ID=5,父分类为ID=4 | **通过** | -| KC-003 | 删除有知识的分类 | 提示"存在关联知识,无法删除" | 由于知识创建失败,无法测试 | **阻塞** | -| KC-004 | 讲师只能管理本部门分类 | 其他部门分类不可操作 | 讲师(部门1)可以创建部门2的分类 | **失败** | - -### 2.2 知识文档管理测试(KM系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| KM-001 | 上传PDF文档 | 上传成功 | 文件保存失败(code:1501) | **失败** | -| KM-002 | 上传Word文档 | 上传成功 | 依赖KM-001 | **阻塞** | -| KM-003 | 上传Excel文档 | 上传成功 | 依赖KM-001 | **阻塞** | -| KM-004 | 上传PPT文档 | 上传成功 | 依赖KM-001 | **阻塞** | -| KM-005 | 上传视频文件 | 上传成功 | 依赖KM-001 | **阻塞** | -| KM-006 | 上传不支持的格式 | 提示错误 | 依赖KM-001 | **阻塞** | -| KM-007 | 超大文件上传 | 提示错误 | 依赖KM-001 | **阻塞** | - -### 2.3 知识状态管理测试(KS系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| KS-001 | 创建知识(默认草稿) | 状态为DRAFT | creator_id字段缺失导致失败 | **阻塞** | -| KS-002 | 草稿→发布 | 状态变为PUBLISHED | 依赖KS-001 | **阻塞** | -| KS-003 | 已发布→下架 | 状态变为OFFLINE | 依赖KS-001 | **阻塞** | -| KS-004 | 已下架→重新上架 | 状态变为PUBLISHED | 依赖KS-001 | **阻塞** | -| KS-005 | 学员查看草稿知识 | 不可见 | 依赖KS-001 | **阻塞** | -| KS-006 | 学员查看已发布知识 | 可见 | 依赖KS-001 | **阻塞** | -| KS-007 | 学员查看已下架知识 | 不可见 | 依赖KS-001 | **阻塞** | -| KS-008 | 下架被培训计划引用的知识 | 显示警告确认 | 依赖KS-001 | **阻塞** | - -### 2.4 部门隔离测试(KD系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| KD-001 | 讲师查看本部门知识 | 正常查看 | 依赖知识数据 | **阻塞** | -| KD-002 | 讲师查看其他部门知识 | 不可见 | 依赖知识数据 | **阻塞** | -| KD-003 | 学员查看本部门已发布知识 | 正常查看 | API可访问,但无数据 | **阻塞** | -| KD-004 | 学员查看其他部门知识 | 不可见 | 依赖知识数据 | **阻塞** | - ---- - -## 三、缺陷清单 - -### BUG-KM-001:创建知识时creator_id未设置 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-001 | -| 关联用例 | KS-001及所有依赖用例 | -| 严重程度 | **阻断** | -| 优先级 | P0 | -| 缺陷描述 | 创建知识时未设置creator_id字段,导致数据库插入失败 | -| 重现步骤 | 1. 管理员登录
2. POST /api/knowledge 创建知识
3. 返回500错误 | -| 错误信息 | `Field 'creator_id' doesn't have a default value` | -| 预期结果 | 知识创建成功,creator_id自动设置为当前登录用户ID | -| 实际结果 | 500服务器内部错误 | -| 建议修复 | 在KnowledgeServiceImpl.createKnowledge()方法中,从SecurityContext获取当前用户ID并设置到knowledge.setCreatorId() | - -**影响范围:** 此BUG导致知识库模块15个测试用例被阻塞,无法继续测试。 - ---- - -### BUG-KM-002:讲师可以操作其他部门的分类 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-002 | -| 关联用例 | KC-004 | -| 严重程度 | 高 | -| 优先级 | P1 | -| 缺陷描述 | 讲师角色可以创建/修改其他部门的知识分类,未实现部门隔离 | -| 重现步骤 | 1. 讲师账号登录(部门1)
2. POST /api/knowledge/category 创建分类,departmentId设为2
3. 分类创建成功 | -| 预期结果 | 返回403或400错误,禁止操作其他部门数据 | -| 实际结果 | 分类创建成功,返回ID=6 | -| 建议修复 | 在CategoryService中校验当前用户部门与操作数据部门是否一致 | - ---- - -### BUG-KM-003:文件上传功能异常 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-KM-003 | -| 关联用例 | KM-001 ~ KM-007 | -| 严重程度 | 高 | -| 优先级 | P1 | -| 缺陷描述 | 文件上传接口返回"文件保存失败"错误 | -| 重现步骤 | 1. 管理员登录
2. POST /api/file/upload/document 上传文件
3. 返回code:1501错误 | -| 错误信息 | `{"code":1501,"message":"文件保存失败"}` | -| 预期结果 | 文件上传成功,返回文件URL | -| 实际结果 | 返回1501错误码 | -| 建议修复 | 检查文件存储路径配置,确保上传目录存在且有写入权限 | - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | -|------|------|---------| -| /api/knowledge/category | POST | 已测试 | -| /api/knowledge/category/tree | GET | 已测试 | -| /api/knowledge/category/{id} | DELETE | 已测试 | -| /api/knowledge | POST | 已测试(失败) | -| /api/knowledge/page | GET | 已测试 | -| /api/file/upload/document | POST | 已测试(失败) | - ---- - -## 五、测试数据 - -### 5.1 测试创建的分类数据 - -| ID | 名称 | 父分类 | 部门 | 状态 | -|----|------|--------|------|------| -| 4 | Safety Regulations | - | 救援一部 | 已删除 | -| 5 | Highway Rescue | 4 | 救援一部 | 已删除 | -| 6 | Test Category Dept2 | - | 救援二部 | 存在(异常数据) | - ---- - -## 六、结论与建议 - -### 6.1 测试结论 - -知识库模块存在**3个严重缺陷**,其中1个为阻断级别: - -1. **P0 阻断级**:创建知识时creator_id未设置 - 导致整个模块功能不可用 -2. **P1 高优先级**:讲师部门隔离未实现 -3. **P1 高优先级**:文件上传功能异常 - -### 6.2 修复建议优先级 - -1. **立即修复(P0)**:BUG-KM-001 - 修复后需重新执行全部阻塞用例 -2. **紧急修复(P1)**:BUG-KM-002 - 部门隔离 -3. **紧急修复(P1)**:BUG-KM-003 - 文件上传 - -### 6.3 阻塞说明 - -由于BUG-KM-001为阻断级缺陷,以下测试需要在修复后重新执行: -- KS系列(知识状态管理):8个用例 -- KD系列(部门隔离):4个用例 -- KM系列(文件管理):6个用例 -- KC-003(删除有知识的分类):1个用例 - -**建议:** 修复BUG-KM-001后进行回归测试。 - ---- - -**报告生成时间:** 2026-01-09 14:58:00 diff --git a/training-system/docs/test-reports/TestReport_3.0_Question.md b/training-system/docs/test-reports/TestReport_3.0_Question.md deleted file mode 100644 index d03a437..0000000 --- a/training-system/docs/test-reports/TestReport_3.0_Question.md +++ /dev/null @@ -1,144 +0,0 @@ -# 测试报告 - 模块三:考题管理 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 11 | -| 通过 | 0 | -| 失败 | 3 | -| 阻塞 | 8 | -| 通过率 | 0% | - -**注意:** 与知识库模块相同,创建题目时也存在creator_id未设置的问题,导致大部分测试被阻塞。 - ---- - -## 二、测试结果详情 - -### 2.1 题型测试(Q系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| Q-001 | 创建单选题(4选项) | 成功创建 | creator_id字段缺失导致失败 | **阻塞** | -| Q-002 | 创建多选题 | 成功创建 | 依赖Q-001 | **阻塞** | -| Q-003 | 创建判断题 | 成功创建 | 依赖Q-001 | **阻塞** | -| Q-004 | 单选题设置多个答案 | 提示错误 | 依赖Q-001 | **阻塞** | -| Q-005 | 多选题只设置一个答案 | 提示错误 | 依赖Q-001 | **阻塞** | -| Q-006 | 题目必须填写解析 | 提示必填 | 依赖Q-001 | **阻塞** | - -### 2.2 题目状态管理测试(QS系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| QS-001 | 草稿题目发布 | 状态变为PUBLISHED | 依赖题目数据 | **阻塞** | -| QS-002 | 下架被试卷引用的题目 | 显示警告确认 | 依赖题目数据 | **阻塞** | -| QS-003 | 只有已发布题目可被组卷 | 草稿/下架题目不可选 | 依赖题目数据 | **阻塞** | - -### 2.3 部门隔离测试(QD系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| QD-001 | 讲师只能管理本部门题库 | 其他部门不可操作 | 讲师可创建其他部门题库分类(ID=4) | **失败** | -| QD-002 | 学员无法访问题目管理 | 返回403 | 返回200,可查询题目列表 | **失败** | - ---- - -## 三、缺陷清单 - -### BUG-Q-001:创建题目时creator_id未设置 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-Q-001 | -| 关联用例 | Q-001及所有依赖用例 | -| 严重程度 | **阻断** | -| 优先级 | P0 | -| 缺陷描述 | 创建题目时未设置creator_id字段,导致数据库插入失败 | -| 重现步骤 | 1. 管理员登录
2. POST /api/exam/question 创建题目
3. 返回500错误 | -| 错误信息 | `Field 'creator_id' doesn't have a default value` | -| 预期结果 | 题目创建成功 | -| 实际结果 | 500服务器内部错误 | -| 建议修复 | 在QuestionServiceImpl.createQuestion()方法中,从SecurityContext获取当前用户ID并设置到question.setCreatorId() | - -**与BUG-KM-001为同类问题,建议统一修复。** - ---- - -### BUG-Q-002:讲师可以操作其他部门的题库分类 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-Q-002 | -| 关联用例 | QD-001 | -| 严重程度 | 高 | -| 优先级 | P1 | -| 缺陷描述 | 讲师角色可以创建其他部门的题库分类,未实现部门隔离 | -| 重现步骤 | 1. 讲师账号登录(部门1)
2. POST /api/exam/question-category 创建分类,departmentId设为2
3. 分类创建成功 | -| 预期结果 | 返回403或400错误 | -| 实际结果 | 分类创建成功,返回ID=4 | -| 建议修复 | 在QuestionCategoryService中校验当前用户部门与操作数据部门是否一致 | - ---- - -### BUG-Q-003:学员可以访问题目管理接口 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-Q-003 | -| 关联用例 | QD-002 | -| 严重程度 | **严重** | -| 优先级 | P0 | -| 缺陷描述 | 学员角色可以访问题目管理接口 | -| 重现步骤 | 1. 学员账号登录
2. GET /api/exam/question/page
3. 成功返回题目列表 | -| 预期结果 | 返回403 Forbidden | -| 实际结果 | 返回200 OK | -| 建议修复 | 在QuestionController上添加角色权限控制 | - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | -|------|------|---------| -| /api/exam/question | POST | 已测试(失败) | -| /api/exam/question/page | GET | 已测试 | -| /api/exam/question-category | POST | 已测试 | -| /api/exam/question-category/tree | GET | 已测试 | - ---- - -## 五、结论与建议 - -### 5.1 测试结论 - -考题管理模块存在**3个缺陷**,其中2个为严重/阻断级别: - -1. **P0 阻断级**:创建题目时creator_id未设置 - 导致整个模块功能不可用 -2. **P0 严重级**:学员权限未控制 -3. **P1 高优先级**:讲师部门隔离未实现 - -### 5.2 共性问题说明 - -此模块发现的问题与员工管理、知识库模块存在共性: -- creator_id未设置:知识库、考题管理 -- 学员权限未控制:员工管理、题目管理 -- 讲师部门隔离未实现:知识库分类、题库分类 - -**建议进行全局修复。** - -### 5.3 阻塞说明 - -由于BUG-Q-001为阻断级缺陷,以下测试需要在修复后重新执行: -- Q系列(题型测试):6个用例 -- QS系列(状态管理):3个用例 - ---- - -**报告生成时间:** 2026-01-09 15:26:00 diff --git a/training-system/docs/test-reports/TestReport_4.0_Paper.md b/training-system/docs/test-reports/TestReport_4.0_Paper.md deleted file mode 100644 index ccdcb97..0000000 --- a/training-system/docs/test-reports/TestReport_4.0_Paper.md +++ /dev/null @@ -1,127 +0,0 @@ -# 测试报告 - 模块四:试卷管理 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 11 | -| 通过 | 0 | -| 失败 | 1 | -| 阻塞 | 10 | -| 通过率 | 0% | - -**注意:** 与知识库、考题模块相同,创建试卷时也存在creator_id未设置的问题,导致大部分测试被阻塞。 - ---- - -## 二、测试结果详情 - -### 2.1 手动组卷测试(P系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| P-001 | 手动选择题目组卷 | 成功创建试卷 | creator_id字段缺失导致失败 | **阻塞** | -| P-002 | 设置每题分值 | 分值设置正确 | 依赖P-001 | **阻塞** | -| P-003 | 总分自动计算 | 各题分值之和=总分 | 依赖P-001 | **阻塞** | -| P-004 | 设置考试时长 | 时长设置正确 | 依赖P-001 | **阻塞** | -| P-005 | 设置及格分 | 及格分≤总分 | 依赖P-001 | **阻塞** | -| P-006 | 及格分超过总分 | 提示错误 | 依赖P-001 | **阻塞** | - -### 2.2 自动组卷测试(PA系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| PA-001 | 设置规则自动抽题 | 按规则随机抽取 | 依赖题目数据 | **阻塞** | -| PA-002 | 题库数量不足 | 提示错误 | 依赖题目数据 | **阻塞** | -| PA-003 | 自动组卷题目随机 | 多次组卷结果不同 | 依赖题目数据 | **阻塞** | - -### 2.3 试卷预览测试(PP系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| PP-001 | 预览试卷 | 显示完整试卷内容 | 依赖试卷数据 | **阻塞** | - -### 2.4 权限测试 - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| PS-权限 | 学员无法访问试卷管理 | 返回403 | 返回200,可查询试卷列表 | **失败** | - ---- - -## 三、缺陷清单 - -### BUG-P-001:创建试卷时creator_id未设置 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-P-001 | -| 关联用例 | P-001及所有依赖用例 | -| 严重程度 | **阻断** | -| 优先级 | P0 | -| 缺陷描述 | 创建试卷时未设置creator_id字段,导致数据库插入失败 | -| 重现步骤 | 1. 管理员登录
2. POST /api/exam/paper 创建试卷
3. 返回500错误 | -| 错误信息 | `Field 'creator_id' doesn't have a default value` | -| 预期结果 | 试卷创建成功 | -| 实际结果 | 500服务器内部错误 | -| 建议修复 | 在PaperServiceImpl.createPaper()方法中设置creatorId | - -**与BUG-KM-001、BUG-Q-001为同类问题。** - ---- - -### BUG-P-002:学员可以访问试卷管理接口 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-P-002 | -| 关联用例 | PS-权限 | -| 严重程度 | **严重** | -| 优先级 | P0 | -| 缺陷描述 | 学员角色可以访问试卷管理接口 | -| 重现步骤 | 1. 学员账号登录
2. GET /api/exam/paper/page
3. 成功返回试卷列表 | -| 预期结果 | 返回403 Forbidden | -| 实际结果 | 返回200 OK | -| 建议修复 | 在PaperController上添加角色权限控制 | - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | -|------|------|---------| -| /api/exam/paper | POST | 已测试(失败) | -| /api/exam/paper/page | GET | 已测试 | - ---- - -## 五、结论与建议 - -### 5.1 测试结论 - -试卷管理模块存在**2个缺陷**: - -1. **P0 阻断级**:创建试卷时creator_id未设置 -2. **P0 严重级**:学员权限未控制 - -### 5.2 共性问题汇总 - -截至目前,已发现的共性问题: - -| 问题类型 | 影响模块 | -|---------|---------| -| creator_id未设置 | 知识库、考题管理、试卷管理 | -| 学员权限未控制 | 员工管理、考题管理、试卷管理 | -| 讲师部门隔离未实现 | 知识库分类、题库分类 | - -**建议进行全局性修复。** - ---- - -**报告生成时间:** 2026-01-09 15:32:00 diff --git a/training-system/docs/test-reports/TestReport_5.0_Exam.md b/training-system/docs/test-reports/TestReport_5.0_Exam.md deleted file mode 100644 index d2a2b2a..0000000 --- a/training-system/docs/test-reports/TestReport_5.0_Exam.md +++ /dev/null @@ -1,135 +0,0 @@ -# 测试报告 - 模块五:考试管理 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 20 | -| 通过 | 1 | -| 失败 | 1 | -| 阻塞 | 18 | -| 通过率 | 5% | - -**注意:** 由于试卷创建失败,考试创建依赖试卷数据,导致绝大部分测试被阻塞。 - ---- - -## 二、测试结果详情 - -### 2.1 发布考试测试(E系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| E-001 | 创建考试,关联试卷 | 成功创建 | 返回"试卷不存在"(1302) | **阻塞** | -| E-002 | 设置时间窗口 | 时间设置正确 | 依赖E-001 | **阻塞** | -| E-003 | 设置及格线 | 及格线设置正确 | 依赖E-001 | **阻塞** | -| E-004 | 设置最大考试次数 | 次数限制生效 | 依赖E-001 | **阻塞** | -| E-005 | 指定部门参加考试 | 部门所有人可见 | 依赖E-001 | **阻塞** | -| E-006 | 指定小组参加考试 | 小组成员可见 | 依赖E-001 | **阻塞** | -| E-007 | 指定个人参加考试 | 仅该用户可见 | 依赖E-001 | **阻塞** | - -### 2.2 在线答题测试(EA系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| EA-001 | 在时间窗口内进入考试 | 成功进入 | 依赖考试数据 | **阻塞** | -| EA-002 | 不在时间窗口进入考试 | 提示错误 | 依赖考试数据 | **阻塞** | -| EA-003 | 超过最大次数进入考试 | 提示错误 | 依赖考试数据 | **阻塞** | -| EA-004 | 答题过程自动计时 | 倒计时正确 | 依赖考试数据 | **阻塞** | -| EA-005 | 超时自动交卷 | 系统自动提交 | 依赖考试数据 | **阻塞** | -| EA-006 | 主动交卷 | 成功提交 | 依赖考试数据 | **阻塞** | -| EA-007 | 答案定时自动保存 | 答案保存成功 | 依赖考试数据 | **阻塞** | -| EA-008 | 断网后恢复继续答题 | 可继续答题 | 依赖考试数据 | **阻塞** | - -### 2.3 成绩计算测试(ES系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| ES-001 | 单选题判分 | 正确/错误判分 | 依赖考试数据 | **阻塞** | -| ES-002 | 多选题判分 | 全对得分 | 依赖考试数据 | **阻塞** | -| ES-003 | 判断题判分 | 正确/错误判分 | 依赖考试数据 | **阻塞** | -| ES-004 | 分数≥及格线显示通过 | 显示"通过" | 依赖考试数据 | **阻塞** | -| ES-005 | 分数<及格线显示未通过 | 显示"未通过" | 依赖考试数据 | **阻塞** | -| ES-006 | 多次考试取最高分 | 最高分记录正确 | 依赖考试数据 | **阻塞** | - -### 2.4 成绩查看测试(EV系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| EV-001 | 交卷后立即显示成绩 | 成绩即时显示 | 依赖考试数据 | **阻塞** | -| EV-002 | 显示答案解析 | 每题显示解析 | 依赖考试数据 | **阻塞** | -| EV-003 | 讲师查看本部门学员成绩 | 正常查看 | 依赖考试数据 | **阻塞** | - -### 2.5 权限测试 - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| E-学员接口 | 学员获取可参加的考试 | 返回200 | 返回200,空列表 | **通过** | -| E-权限 | 学员访问考试管理接口 | 返回403 | 返回200,可查询考试列表 | **失败** | - ---- - -## 三、缺陷清单 - -### BUG-E-001:学员可以访问考试管理接口 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-E-001 | -| 关联用例 | E-权限 | -| 严重程度 | **严重** | -| 优先级 | P0 | -| 缺陷描述 | 学员角色可以访问考试管理的分页查询接口 | -| 重现步骤 | 1. 学员账号登录
2. GET /api/exam/page
3. 成功返回考试列表 | -| 预期结果 | 返回403 Forbidden | -| 实际结果 | 返回200 OK | -| 建议修复 | 在ExamController的管理类接口上添加角色权限控制 | - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | -|------|------|---------| -| /api/exam | POST | 已测试(阻塞-无试卷) | -| /api/exam/page | GET | 已测试 | -| /api/exam/my | GET | 已测试 | - ---- - -## 五、阻塞说明 - -考试管理模块测试被阻塞的根本原因链: - -``` -creator_id问题 → 知识/题目创建失败 → 试卷创建失败 → 考试创建失败(无试卷) → 考试流程测试全部阻塞 -``` - -**前置条件:** -- 试卷管理模块正常(至少有一张已发布的试卷) -- 题目管理模块正常(至少有已发布的题目) - ---- - -## 六、结论与建议 - -### 6.1 测试结论 - -考试管理模块存在**1个权限缺陷**,同时因上游模块阻断导致**18个用例无法执行**。 - -### 6.2 建议 - -1. 优先修复所有模块的creator_id问题 -2. 修复后按顺序执行: - - 考题管理 → 试卷管理 → 考试管理 -3. 补充考试核心流程的完整测试 - ---- - -**报告生成时间:** 2026-01-09 15:38:00 diff --git a/training-system/docs/test-reports/TestReport_6.0_TrainingPlan.md b/training-system/docs/test-reports/TestReport_6.0_TrainingPlan.md deleted file mode 100644 index ed56f57..0000000 --- a/training-system/docs/test-reports/TestReport_6.0_TrainingPlan.md +++ /dev/null @@ -1,119 +0,0 @@ -,# 测试报告 - 模块六:培训计划 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 - ---- - -## 一、测试概要 - -| 项目 | 数值 | -|------|------| -| 测试用例总数 | 11 | -| 通过 | 4 | -| 失败 | 1 | -| 阻塞 | 6 | -| 通过率 | 36.4% | - -**说明:** 培训计划模块基本功能可用,但部分依赖知识库的功能被阻塞。 - ---- - -## 二、测试结果详情 - -### 2.1 创建培训计划测试(TP系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| TP-001 | 创建培训计划 | 成功创建 | 创建成功,返回ID=1 | **通过** | -| TP-002 | 关联多个知识文档 | 关联成功 | 依赖知识数据 | **阻塞** | -| TP-003 | 关联考试(可选) | 关联成功 | 依赖考试数据 | **阻塞** | -| TP-004 | 分配部门/小组/个人 | 分配成功 | 分配部门成功 | **通过** | - -### 2.2 计划状态测试(TPS系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| TPS-001 | 开始日期前状态 | 未开始 | 创建后默认为NOT_STARTED | **通过** | -| TPS-002 | 发布后状态 | 进行中 | 发布后变为IN_PROGRESS | **通过** | -| TPS-003 | 结束日期后状态 | 已结束 | 未测试(需等待时间) | **阻塞** | - -### 2.3 进度跟踪测试(TPP系列) - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| TPP-001 | 学员完成知识学习 | 进度更新 | 依赖知识数据 | **阻塞** | -| TPP-002 | 学员完成关联考试 | 进度更新 | 依赖考试数据 | **阻塞** | -| TPP-003 | 学员查看自己的学习进度 | 进度显示正确 | 学员可查看,进度为0% | **通过** | -| TPP-004 | 讲师查看学员培训进度 | 可查看本部门 | 依赖学习数据 | **阻塞** | - -### 2.4 权限测试 - -| 用例编号 | 测试项 | 预期结果 | 实际结果 | 状态 | -|---------|--------|---------|---------|------| -| TP-权限 | 学员访问培训计划管理接口 | 返回403 | 返回200,可查询培训计划列表 | **失败** | - ---- - -## 三、缺陷清单 - -### BUG-TP-001:学员可以访问培训计划管理接口 - -| 属性 | 描述 | -|------|------| -| 缺陷编号 | BUG-TP-001 | -| 关联用例 | TP-权限 | -| 严重程度 | **严重** | -| 优先级 | P0 | -| 缺陷描述 | 学员角色可以访问培训计划管理的分页查询接口 | -| 重现步骤 | 1. 学员账号登录
2. GET /api/training/plan/page
3. 成功返回培训计划列表 | -| 预期结果 | 返回403 Forbidden | -| 实际结果 | 返回200 OK,可查看培训计划管理数据 | -| 建议修复 | 在TrainingPlanController的管理类接口上添加角色权限控制 | - ---- - -## 四、测试覆盖接口 - -| 接口 | 方法 | 测试状态 | 结果 | -|------|------|---------|------| -| /api/training/plan | POST | 已测试 | 通过 | -| /api/training/plan/{id} | GET | 已测试 | 通过 | -| /api/training/plan/{id}/publish | POST | 已测试 | 通过 | -| /api/training/plan/page | GET | 已测试 | 通过 | -| /api/training/plan/my | GET | 已测试 | 通过 | -| /api/training/plan/my/{planId}/progress | GET | 已测试 | 通过 | - ---- - -## 五、测试数据 - -### 5.1 测试创建的培训计划 - -| ID | 标题 | 状态 | 部门 | -|----|------|------|------| -| 1 | Test Training Plan 001 | IN_PROGRESS | 救援一部 | - ---- - -## 六、结论与建议 - -### 6.1 测试结论 - -培训计划模块是**测试通过率最高**的模块(36.4%),基本CRUD功能正常,但存在**1个权限缺陷**。 - -### 6.2 正面发现 - -1. 培训计划创建成功(未受creator_id问题影响) -2. 状态流转正常(NOT_STARTED -> IN_PROGRESS) -3. 学员端接口正常(/my、/progress) - -### 6.3 待改进 - -1. 修复学员权限控制问题 -2. 修复上游模块后补充知识关联、考试关联的测试 - ---- - -**报告生成时间:** 2026-01-09 15:45:00 diff --git a/training-system/docs/test-reports/TestReport_Summary.md b/training-system/docs/test-reports/TestReport_Summary.md deleted file mode 100644 index ae9ba07..0000000 --- a/training-system/docs/test-reports/TestReport_Summary.md +++ /dev/null @@ -1,195 +0,0 @@ -# 道路救援企业培训系统 - 测试总报告 - -> 测试日期:2026-01-09 -> 测试人员:AI测试工程师 -> 测试环境:Spring Boot 3.1.2 / MySQL 8.0 / JDK 17 -> 测试范围:PRD V1.0 MVP版本(模块1.3-模块6) - ---- - -## 一、测试执行概览 - -### 1.1 总体统计 - -| 指标 | 数值 | -|------|------| -| 计划用例数 | 79 | -| 已执行 | 79 | -| **通过** | **11** | -| **失败** | **11** | -| **阻塞** | **57** | -| **通过率** | **13.9%** | - -### 1.2 各模块测试结果 - -| 模块 | 用例数 | 通过 | 失败 | 阻塞 | 通过率 | -|------|--------|------|------|------|--------| -| 1.3 员工管理 | 7 | 4 | 3 | 0 | 57.1% | -| 2.0 知识库 | 19 | 2 | 2 | 15 | 10.5% | -| 3.0 考题管理 | 11 | 0 | 3 | 8 | 0% | -| 4.0 试卷管理 | 11 | 0 | 1 | 10 | 0% | -| 5.0 考试管理 | 20 | 1 | 1 | 18 | 5% | -| 6.0 培训计划 | 11 | 4 | 1 | 6 | 36.4% | - ---- - -## 二、缺陷统计 - -### 2.1 缺陷严重程度分布 - -| 严重程度 | 数量 | 占比 | -|---------|------|------| -| **阻断(Blocker)** | 3 | 27.3% | -| **严重(Critical)** | 6 | 54.5% | -| **高(High)** | 2 | 18.2% | -| **中(Medium)** | 0 | 0% | -| 合计 | 11 | 100% | - -### 2.2 缺陷清单汇总 - -| 缺陷编号 | 模块 | 严重程度 | 描述 | 状态 | -|---------|------|---------|------|------| -| BUG-USER-001 | 员工管理 | 中 | 手机号未做唯一性校验 | 待修复 | -| BUG-USER-002 | 员工管理 | 高 | 讲师未实现部门数据隔离 | 待修复 | -| BUG-USER-003 | 员工管理 | **严重** | 学员可访问员工管理接口 | 待修复 | -| BUG-KM-001 | 知识库 | **阻断** | 创建知识时creator_id未设置 | 待修复 | -| BUG-KM-002 | 知识库 | 高 | 讲师可操作其他部门分类 | 待修复 | -| BUG-KM-003 | 知识库 | 高 | 文件上传功能异常 | 待修复 | -| BUG-Q-001 | 考题管理 | **阻断** | 创建题目时creator_id未设置 | 待修复 | -| BUG-Q-002 | 考题管理 | 高 | 讲师可操作其他部门题库 | 待修复 | -| BUG-Q-003 | 考题管理 | **严重** | 学员可访问题目管理接口 | 待修复 | -| BUG-P-001 | 试卷管理 | **阻断** | 创建试卷时creator_id未设置 | 待修复 | -| BUG-P-002 | 试卷管理 | **严重** | 学员可访问试卷管理接口 | 待修复 | -| BUG-E-001 | 考试管理 | **严重** | 学员可访问考试管理接口 | 待修复 | -| BUG-TP-001 | 培训计划 | **严重** | 学员可访问培训计划管理接口 | 待修复 | - ---- - -## 三、共性问题分析 - -### 3.1 阻断级问题:creator_id未设置 - -**影响范围:** 知识库、考题管理、试卷管理 - -**问题描述:** 创建知识/题目/试卷时,未从当前登录用户获取creator_id并设置,导致数据库插入失败。 - -**根本原因:** Service层创建实体时未调用SecurityContext获取当前用户ID。 - -**修复建议:** -```java -// 在各ServiceImpl的create方法中添加: -Long currentUserId = SecurityContextHolder.getContext().getAuthentication()... -entity.setCreatorId(currentUserId); -``` - -**阻塞影响:** -- 知识库:15个用例 -- 考题管理:8个用例 -- 试卷管理:10个用例 -- 考试管理:18个用例(级联阻塞) -- **合计:51个用例(占64.6%)** - ---- - -### 3.2 严重级问题:学员权限未控制 - -**影响范围:** 员工管理、考题管理、试卷管理、考试管理、培训计划 - -**问题描述:** 学员角色可以访问管理员/讲师专属的管理接口,存在越权访问风险。 - -**安全风险:** -- 信息泄露:学员可查看所有员工信息、题库、试卷 -- 潜在篡改:如果POST/PUT/DELETE接口也未限制,可能造成数据篡改 - -**修复建议:** -在Controller上添加Spring Security注解: -```java -@PreAuthorize("hasAnyRole('ADMIN', 'LECTURER')") -@GetMapping("/page") -public Result> page(...) { ... } -``` - ---- - -### 3.3 高优先级问题:讲师部门隔离未实现 - -**影响范围:** 员工管理、知识库分类、题库分类 - -**问题描述:** 讲师可以查看/操作其他部门的数据,违反PRD中"讲师只能管理本部门"的要求。 - -**修复建议:** -在Service层添加部门校验: -```java -Long userDeptId = getCurrentUserDepartmentId(); -if (!userDeptId.equals(dto.getDepartmentId())) { - throw new BusinessException("无权操作其他部门数据"); -} -``` - ---- - -## 四、测试结论 - -### 4.1 系统当前状态 - -| 评估项 | 状态 | 说明 | -|--------|------|------| -| 基础功能 | **部分可用** | 员工管理、培训计划基础功能可用 | -| 核心业务 | **不可用** | 知识库、考试流程因creator_id问题无法使用 | -| 权限控制 | **存在漏洞** | 学员可越权访问管理接口 | -| 数据隔离 | **未实现** | 讲师部门隔离未生效 | - -### 4.2 上线评估 - -**结论:当前版本不建议上线** - -**原因:** -1. 存在3个阻断级BUG,核心业务流程无法使用 -2. 存在6个严重级权限漏洞,安全风险高 -3. 测试通过率仅13.9%,远低于上线标准(通常≥90%) - -### 4.3 修复优先级建议 - -| 优先级 | 问题 | 预计影响 | -|--------|------|---------| -| **P0** | creator_id未设置(3处) | 修复后可解除51个用例阻塞 | -| **P0** | 学员权限控制(5处) | 修复后消除越权访问风险 | -| **P1** | 讲师部门隔离(3处) | 修复后满足PRD数据隔离要求 | -| **P1** | 文件上传异常 | 修复后知识库文件功能可用 | -| **P2** | 手机号唯一性校验 | 修复后防止重复数据 | - ---- - -## 五、下一步行动 - -### 5.1 短期(修复后) - -1. 修复creator_id问题后,重新执行知识库、考题、试卷、考试模块测试 -2. 修复权限问题后,执行权限矩阵完整测试 -3. 修复部门隔离后,执行数据隔离专项测试 - -### 5.2 中期 - -1. 补充边界测试用例执行 -2. 执行接口自动化测试 -3. 执行性能压力测试 - ---- - -## 六、测试报告文件清单 - -| 报告名称 | 文件路径 | -|---------|---------| -| 1.3 员工管理测试报告 | docs/test-reports/TestReport_1.3_UserManagement.md | -| 2.0 知识库测试报告 | docs/test-reports/TestReport_2.0_Knowledge.md | -| 3.0 考题管理测试报告 | docs/test-reports/TestReport_3.0_Question.md | -| 4.0 试卷管理测试报告 | docs/test-reports/TestReport_4.0_Paper.md | -| 5.0 考试管理测试报告 | docs/test-reports/TestReport_5.0_Exam.md | -| 6.0 培训计划测试报告 | docs/test-reports/TestReport_6.0_TrainingPlan.md | -| 测试总报告 | docs/test-reports/TestReport_Summary.md | - ---- - -**报告生成时间:** 2026-01-09 15:50:00 - -**报告签发:** AI测试工程师 diff --git a/training-system/docs/测试报告-登录模块.md b/training-system/docs/测试报告-登录模块.md deleted file mode 100644 index d71e533..0000000 --- a/training-system/docs/测试报告-登录模块.md +++ /dev/null @@ -1,310 +0,0 @@ -# 登录模块测试报告 - -## 一、测试概述 - -### 1.1 测试目标 -对道路救援培训系统的**登录模块**进行全面的前后端测试,验证其功能正确性、安全性和用户体验。 - -### 1.2 测试范围 -- **JWT工具类测试**:JwtUtils 的单元测试 -- **认证服务层测试**:AuthService 的单元测试 -- **认证控制器测试**:AuthController 的接口测试 -- **前端代码审查**:login.html 页面功能审查 - -### 1.3 测试时间 -2026-01-08 - ---- - -## 二、系统架构分析 - -### 2.1 认证流程 - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 登录认证流程 │ -└──────────────────────────────────────────────────────────────────┘ - -┌─────────┐ POST /login ┌──────────────┐ 验证用户 ┌──────────┐ -│ 前端 │ ─────────────────► │ AuthController│ ──────────► │AuthService│ -│ login │ {username,passwd} │ │ │ │ -└─────────┘ └──────────────┘ └──────────┘ - │ │ │ - │ │ ▼ - │ │ ┌──────────┐ - │ │ │UserMapper │ - │ │ │ (数据库) │ - │ │ └──────────┘ - │ │ │ - │ │ ▼ - │ │ ┌──────────┐ - │ │ 生成Token │ JwtUtils │ - │ │ ◄──────────────── │ │ - │ │ └──────────┘ - │ │ - │ 返回Token+用户信息 │ - │ ◄───────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ localStorage存储Token,后续请求自动附加 Authorization: Bearer │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 技术栈 -| 组件 | 技术 | -|-----|-----| -| 后端框架 | Spring Boot 3.1.2 | -| JWT库 | com.auth0:java-jwt | -| 密码加密 | Spring Security BCrypt | -| 前端框架 | Bootstrap 5 + 原生JS | -| 测试框架 | JUnit 5 + Mockito | - ---- - -## 三、后端测试 - -### 3.1 测试用例统计 - -| 测试类 | 测试方法数 | 通过 | 失败 | 通过率 | -|-------|-----------|------|-----|--------| -| JwtUtilsTest | 19 | 19 | 0 | 100% | -| AuthServiceTest | 11 | 11 | 0 | 100% | -| AuthControllerTest | 11 | 11 | 0 | 100% | -| **总计** | **41** | **41** | **0** | **100%** | - -### 3.2 JwtUtils 测试详情 - -#### 3.2.1 生成Token测试 (GenerateTokenTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 正常生成Token | 返回有效JWT字符串(header.payload.signature格式) | PASS | -| 2 | 不同用户生成不同Token | 两个Token不相等 | PASS | -| 3 | 同用户超过1秒后生成Token | Token不同,但包含相同用户信息 | PASS | - -#### 3.2.2 验证Token测试 (VerifyTokenTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 有效Token | 返回DecodedJWT,包含正确claims | PASS | -| 2 | 无效Token | 返回null | PASS | -| 3 | 空Token | 返回null | PASS | -| 4 | null Token | 返回null | PASS | -| 5 | 被篡改的Token | 返回null | PASS | -| 6 | 错误密钥签名的Token | 返回null | PASS | - -#### 3.2.3 获取用户ID测试 (GetUserIdTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 有效Token获取用户ID | 返回正确的Long类型用户ID | PASS | -| 2 | 无效Token获取用户ID | 返回null | PASS | - -#### 3.2.4 获取用户名测试 (GetUsernameTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 有效Token获取用户名 | 返回正确的用户名字符串 | PASS | -| 2 | 无效Token获取用户名 | 返回null | PASS | - -#### 3.2.5 获取角色测试 (GetRoleTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 有效Token获取角色 | 返回正确的角色(ADMIN/LECTURER/STUDENT) | PASS | -| 2 | 不同角色Token | 各返回对应角色字符串 | PASS | - -#### 3.2.6 Token过期测试 (IsTokenExpiredTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 未过期Token | 返回false | PASS | -| 2 | 已过期Token | 返回true | PASS | -| 3 | 无效Token | 返回true | PASS | - -#### 3.2.7 Token完整性测试 (TokenIntegrityTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | Token包含所有claims | subject、username、role、issuedAt、expiresAt均存在 | PASS | - -### 3.3 AuthService 测试详情 - -#### 3.3.1 登录测试 (LoginTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 正常登录 | 返回Token和完整用户信息 | PASS | -| 2 | 用户不存在 | 抛出BusinessException,code=USER_NOT_FOUND | PASS | -| 3 | 密码错误 | 抛出BusinessException,code=PASSWORD_ERROR | PASS | -| 4 | 用户被禁用 | 抛出BusinessException,code=USER_DISABLED | PASS | -| 5 | 不同角色用户登录 | 返回对应角色信息(STUDENT) | PASS | -| 6 | 用户无部门 | 正常登录,departmentName为null | PASS | - -#### 3.3.2 获取当前用户信息测试 (GetCurrentUserInfoTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 有效上下文 | 返回完整用户信息 | PASS | -| 2 | 无上下文 | 抛出BusinessException,code=UNAUTHORIZED | PASS | -| 3 | 上下文用户ID为空 | 抛出BusinessException,code=UNAUTHORIZED | PASS | -| 4 | 用户已被删除 | 抛出BusinessException,code=USER_NOT_FOUND | PASS | - -#### 3.3.3 退出登录测试 (LogoutTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 正常退出 | 清除UserContext,不抛异常 | PASS | - -### 3.4 AuthController 测试详情 - -#### 3.4.1 POST /login - 账号密码登录 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 有效凭证登录 | 200 | 200 | PASS | -| 2 | 用户名为空 | 400 | - | PASS | -| 3 | 密码为空 | 400 | - | PASS | -| 4 | 用户名和密码都为空 | 400 | - | PASS | -| 5 | 用户不存在 | 200 | USER_NOT_FOUND | PASS | -| 6 | 密码错误 | 200 | PASSWORD_ERROR | PASS | -| 7 | 用户被禁用 | 200 | USER_DISABLED | PASS | -| 8 | 请求体为空 | 400 | - | PASS | - -#### 3.4.2 GET /userinfo - 获取当前用户信息 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 已认证用户 | 200 | 200 | PASS | -| 2 | 未认证用户 | 200 | UNAUTHORIZED | PASS | - -#### 3.4.3 POST /logout - 退出登录 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 正常退出 | 200 | 200 | PASS | - ---- - -## 四、前端代码审查 - -### 4.1 页面结构分析 -**文件路径**:`src/main/resources/templates/login.html` - -#### 4.1.1 UI组件 -| 组件 | 实现状态 | 说明 | -|-----|---------|-----| -| 用户名输入框 | 已实现 | 带图标、浮动标签 | -| 密码输入框 | 已实现 | 带图标、显示/隐藏切换 | -| 记住我复选框 | 已实现 | 未与后端关联 | -| 登录按钮 | 已实现 | 带Loading状态 | -| 企业微信登录 | 已实现 | 跳转OAuth授权页面 | - -#### 4.1.2 功能清单 -| 功能 | 实现状态 | 备注 | -|-----|---------|-----| -| 表单验证 | 已实现 | HTML5 required + JS校验 | -| 密码显示切换 | 已实现 | 切换input type | -| 登录Loading状态 | 已实现 | 按钮禁用+Spinner | -| 登录成功跳转 | 已实现 | 延迟500ms跳转首页 | -| 错误消息提示 | 已实现 | Toast通知 | -| 已登录检测 | 已实现 | 自动跳转首页 | -| 企业微信登录 | 已实现 | 跳转OAuth流程 | -| 回车提交 | 已实现 | 监听密码框keypress | - -### 4.2 API调用分析 - -| 功能 | API接口 | HTTP方法 | 请求体 | -|-----|--------|---------|-------| -| 账号登录 | `/api/auth/login` | POST | `{username, password}` | -| 企业微信授权 | `/api/auth/wechat/authorize` | GET (跳转) | - | - -### 4.3 安全机制 - -| 安全措施 | 实现状态 | 说明 | -|---------|---------|-----| -| Token存储 | localStorage | `training_token` 键名 | -| 请求认证 | Bearer Token | Authorization头 | -| 密码传输 | HTTPS(假设生产环境) | 明文POST | -| XSS防护 | 部分实现 | Toast消息直接插入HTML | -| CSRF防护 | 无状态JWT | 不需要CSRF Token | - -### 4.4 代码质量评估 - -#### 优点 -1. **UI设计美观**:渐变背景、圆角卡片、图标点缀 -2. **响应式设计**:移动端适配(@media max-width: 480px) -3. **用户体验好**:Loading状态、密码显示切换、错误提示 -4. **代码组织清晰**:CSS样式集中、JS逻辑分离 -5. **复用性高**:使用TrainingSystem全局对象统一管理 - -#### 潜在改进点 -1. **XSS风险**:`showMessage`函数中`${text}`直接插入HTML -2. **记住我功能**:UI存在但未实际实现(需Token持久化策略) -3. **密码强度**:无前端密码强度检查 -4. **错误重试**:无登录失败重试限制机制(后端需配合) -5. **表单校验**:缺少用户名/密码长度限制提示 - ---- - -## 五、测试总结 - -### 5.1 测试结论 -登录模块的**后端测试全部通过**,共41个测试用例,通过率**100%**。 - -### 5.2 功能完整性 -| 功能模块 | 状态 | -|---------|-----| -| JWT Token生成与验证 | 正常 | -| 用户名密码登录 | 正常 | -| 密码BCrypt加密验证 | 正常 | -| 用户状态检查 | 正常 | -| 获取当前用户信息 | 正常 | -| 退出登录 | 正常 | -| 前后端接口对接 | 正常 | -| 响应式UI | 正常 | - -### 5.3 安全性评估 -| 安全项 | 评分 | 说明 | -|-------|-----|-----| -| 密码存储 | 优 | BCrypt加密,不可逆 | -| Token安全 | 良 | HMAC256签名,24小时过期 | -| 传输安全 | - | 需确保HTTPS部署 | -| 输入验证 | 良 | 后端@NotBlank验证 | -| 异常处理 | 优 | 统一异常处理,不泄露敏感信息 | - -### 5.4 已发现并修复的问题 - -| 问题 | 文件 | 修复方式 | -|-----|-----|---------| -| JWT时间戳精度导致测试失败 | JwtUtilsTest.java | 测试延迟改为1100ms | - -### 5.5 测试文件清单 -``` -src/test/java/com/sino/training/ -├── common/utils/ -│ └── JwtUtilsTest.java (19个测试用例) -└── module/auth/ - ├── controller/ - │ └── AuthControllerTest.java (11个测试用例) - └── service/ - └── AuthServiceTest.java (11个测试用例) -``` - ---- - -## 六、附录 - -### 6.1 执行命令 -```bash -mvn test -Dtest=AuthServiceTest,AuthControllerTest,JwtUtilsTest -DfailIfNoTests=false -``` - -### 6.2 测试执行结果 -``` -[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0 -[INFO] BUILD SUCCESS -``` - -### 6.3 核心代码路径 -| 模块 | 文件路径 | -|-----|---------| -| 控制器 | `src/main/java/com/sino/training/module/auth/controller/AuthController.java` | -| 服务实现 | `src/main/java/com/sino/training/module/auth/service/impl/AuthServiceImpl.java` | -| JWT工具 | `src/main/java/com/sino/training/common/utils/JwtUtils.java` | -| 认证拦截器 | `src/main/java/com/sino/training/common/interceptor/AuthInterceptor.java` | -| 前端页面 | `src/main/resources/templates/login.html` | -| 公共JS | `src/main/resources/static/js/common.js` | - ---- - -**测试工程师**:AI Testing Assistant -**报告日期**:2026-01-08 diff --git a/training-system/docs/测试报告-题库分类模块.md b/training-system/docs/测试报告-题库分类模块.md deleted file mode 100644 index eaa4359..0000000 --- a/training-system/docs/测试报告-题库分类模块.md +++ /dev/null @@ -1,219 +0,0 @@ -# 题库分类模块测试报告 - -## 一、测试概述 - -### 1.1 测试目标 -对道路救援培训系统的**题库分类模块**进行全面的前后端测试,验证其功能正确性、接口稳定性和用户体验。 - -### 1.2 测试范围 -- **后端服务层测试**:QuestionCategoryService 的单元测试 -- **后端控制器测试**:QuestionCategoryController 的接口测试 -- **前端代码审查**:question-category.html 页面功能审查 - -### 1.3 测试时间 -2026-01-08 - ---- - -## 二、后端测试 - -### 2.1 测试环境 -- **技术框架**:Spring Boot 3.1.2 + MyBatis Plus -- **测试框架**:JUnit 5 + Mockito -- **Java版本**:JDK 17 - -### 2.2 测试用例统计 - -| 测试类 | 测试方法数 | 通过 | 失败 | 通过率 | -|-------|-----------|------|-----|--------| -| QuestionCategoryServiceTest | 17 | 17 | 0 | 100% | -| QuestionCategoryControllerTest | 14 | 14 | 0 | 100% | -| **总计** | **31** | **31** | **0** | **100%** | - -### 2.3 Service层测试详情 - -#### 2.3.1 创建分类测试 (CreateCategoryTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 正常创建分类 | 返回新建分类ID | PASS | -| 2 | 部门不存在 | 抛出BusinessException,消息包含"部门不存在" | PASS | -| 3 | 父分类不存在 | 抛出BusinessException,消息包含"父分类不存在" | PASS | -| 4 | 同级同名分类已存在 | 抛出BusinessException,消息包含"同名分类" | PASS | -| 5 | parentId为null时默认为0 | parentId自动设置为0L | PASS | - -#### 2.3.2 更新分类测试 (UpdateCategoryTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 正常更新分类 | 更新成功,调用updateById | PASS | -| 2 | ID为空 | 抛出BusinessException,消息包含"ID不能为空" | PASS | -| 3 | 分类不存在 | 抛出BusinessException | PASS | -| 4 | 将自己设为父分类 | 抛出BusinessException,消息包含"不能将自己设为父分类" | PASS | -| 5 | 同级同名分类已存在 | 抛出BusinessException,消息包含"同名分类" | PASS | - -#### 2.3.3 删除分类测试 (DeleteCategoryTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 分类不存在 | 抛出BusinessException | PASS | -| 2 | 存在子分类 | 抛出BusinessException,消息包含"子分类" | PASS | -| 3 | 存在关联题目 | 抛出BusinessException,消息包含"题目" | PASS | - -#### 2.3.4 获取分类详情测试 (GetCategoryDetailTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 获取存在的分类详情 | 返回完整信息,包含父分类名称和部门名称 | PASS | -| 2 | 分类不存在 | 抛出BusinessException | PASS | - -#### 2.3.5 获取分类树测试 (GetCategoryTreeTest) -| 序号 | 测试场景 | 预期结果 | 测试结果 | -|-----|---------|---------|---------| -| 1 | 获取全部分类树 | 返回正确的树形结构 | PASS | -| 2 | 按部门ID筛选 | 返回指定部门的分类树 | PASS | - -### 2.4 Controller层测试详情 - -#### 2.4.1 GET /tree - 获取分类树 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 无参数获取全部 | 200 | 200 | PASS | -| 2 | 按部门ID筛选 | 200 | 200 | PASS | -| 3 | 空数据返回空数组 | 200 | 200 | PASS | - -#### 2.4.2 GET /list - 获取分类列表 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 获取列表 | 200 | 200 | PASS | - -#### 2.4.3 GET /{id} - 获取分类详情 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 获取存在的分类 | 200 | 200 | PASS | -| 2 | 分类不存在 | 200 | 错误码 | PASS | - -#### 2.4.4 POST / - 创建分类 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 有效数据创建 | 200 | 200 | PASS | -| 2 | 同名分类已存在 | 200 | DATA_EXISTS | PASS | - -#### 2.4.5 PUT / - 更新分类 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 有效数据更新 | 200 | 200 | PASS | -| 2 | 分类不存在 | 200 | CATEGORY_NOT_FOUND | PASS | - -#### 2.4.6 DELETE /{id} - 删除分类 -| 序号 | 测试场景 | HTTP状态 | 业务码 | 测试结果 | -|-----|---------|---------|-------|---------| -| 1 | 删除有效分类 | 200 | 200 | PASS | -| 2 | 存在子分类 | 200 | DATA_REFERENCED | PASS | -| 3 | 存在关联题目 | 200 | DATA_REFERENCED | PASS | -| 4 | 分类不存在 | 200 | CATEGORY_NOT_FOUND | PASS | - ---- - -## 三、前端代码审查 - -### 3.1 页面结构分析 -**文件路径**:`src/main/resources/templates/exam/question-category.html` - -#### 3.1.1 页面布局 -- 左侧(40%):树形分类列表 -- 右侧(60%):分类详情展示 -- 响应式设计:支持移动端适配 - -#### 3.1.2 功能清单 -| 功能 | 实现状态 | 备注 | -|-----|---------|-----| -| 分类树展示 | 已实现 | 支持多级嵌套 | -| 展开/折叠 | 已实现 | 点击箭头图标切换 | -| 选中分类 | 已实现 | 高亮显示,展示详情 | -| 新增分类 | 已实现 | 支持顶级和子级 | -| 编辑分类 | 已实现 | 模态框编辑 | -| 删除分类 | 已实现 | 带确认提示 | -| 查看题目数量 | 已实现 | 显示在节点右侧 | -| 跳转题目列表 | 已实现 | 带分类ID参数 | - -### 3.2 API调用分析 - -| 功能 | API接口 | HTTP方法 | -|-----|--------|---------| -| 加载分类树 | `/exam/question-category/tree` | GET | -| 获取分类详情 | `/exam/question-category/{id}` | GET | -| 创建分类 | `/exam/question-category` | POST | -| 更新分类 | `/exam/question-category` | PUT | -| 删除分类 | `/exam/question-category/{id}` | DELETE | - -### 3.3 代码质量评估 - -#### 优点 -1. **结构清晰**:HTML结构层次分明,CSS样式集中管理 -2. **交互友好**:提供操作反馈(成功/失败消息) -3. **防误操作**:删除前确认提示 -4. **代码复用**:使用`TrainingSystem`全局对象统一API调用 -5. **递归渲染**:正确处理多级树形结构 - -#### 潜在改进点 -1. **XSS防护**:`item.name`直接插入HTML,建议使用转义函数 -2. **错误处理**:网络异常时可增加重试机制 -3. **加载状态**:长时间操作时可添加loading遮罩 -4. **表单验证**:前端缺少分类名称长度限制 - ---- - -## 四、测试总结 - -### 4.1 测试结论 -题库分类模块的**后端测试全部通过**,共31个测试用例,通过率**100%**。 - -### 4.2 功能完整性 -| 功能模块 | 状态 | -|---------|-----| -| 分类树结构 | 正常 | -| 分类CRUD操作 | 正常 | -| 业务规则校验 | 正常 | -| 异常处理 | 正常 | -| 前后端接口对接 | 正常 | - -### 4.3 已发现问题 - -#### 编译错误(已修复) -在测试过程中发现并修复了以下编译错误: - -| 问题 | 文件 | 修复方式 | -|-----|-----|---------| -| Long转int类型错误 | ExamServiceImpl.java:287 | 使用`.intValue()` | -| Long转int类型错误 | PaperServiceImpl.java:364 | 使用`.intValue()` | -| 方法名错误 | TrainingPlanServiceImpl.java | `getKnowledgeType()`→`getType()` | -| 字段名错误 | TrainingPlanServiceImpl.java | `setKnowledgeType`→`setType` | - -#### 测试依赖(已添加) -添加了`spring-security-test`依赖以支持安全测试。 - -### 4.4 测试文件清单 -``` -src/test/java/com/sino/training/module/exam/ -├── service/ -│ └── QuestionCategoryServiceTest.java (17个测试用例) -└── controller/ - └── QuestionCategoryControllerTest.java (14个测试用例) -``` - ---- - -## 五、附录 - -### 5.1 执行命令 -```bash -mvn test -Dtest=QuestionCategoryServiceTest,QuestionCategoryControllerTest -``` - -### 5.2 测试执行结果 -``` -[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0 -[INFO] BUILD SUCCESS -``` - ---- - -**测试工程师**:AI Testing Assistant -**报告日期**:2026-01-08 diff --git a/training-system/pom.xml b/training-system/pom.xml deleted file mode 100644 index 9bade52..0000000 --- a/training-system/pom.xml +++ /dev/null @@ -1,151 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.1.2 - - - - com.sino - training-system - 1.0.0 - training-system - 道路救援企业培训系统 - - - 17 - 3.5.3.1 - 4.4.0 - 5.8.22 - 3.3.2 - 4.5.0 - 2.2.0 - - - - - - org.springframework.boot - spring-boot-starter-web - - - - - org.springframework.boot - spring-boot-starter-thymeleaf - - - - - org.springframework.boot - spring-boot-starter-validation - - - - - org.springframework.boot - spring-boot-starter-security - - - - - com.baomidou - mybatis-plus-boot-starter - ${mybatis-plus.version} - - - - - com.mysql - mysql-connector-j - runtime - - - - - com.auth0 - java-jwt - ${java-jwt.version} - - - - - org.projectlombok - lombok - true - - - - - cn.hutool - hutool-all - ${hutool.version} - - - - - com.alibaba - easyexcel - ${easyexcel.version} - - - - - com.github.binarywang - weixin-java-cp - ${weixin-java.version} - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - ${springdoc.version} - - - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - - org.springframework.security - spring-security-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - - - diff --git a/training-system/sql/init.sql b/training-system/sql/init.sql deleted file mode 100644 index bd24bdd..0000000 --- a/training-system/sql/init.sql +++ /dev/null @@ -1,405 +0,0 @@ --- ============================================= --- 道路救援企业培训系统 - 数据库初始化脚本 --- 数据库:MySQL 8.0 --- 字符集:utf8mb4 --- ============================================= - --- 创建数据库 -CREATE DATABASE IF NOT EXISTS training_system - DEFAULT CHARACTER SET utf8mb4 - DEFAULT COLLATE utf8mb4_general_ci; - -USE training_system; - --- ============================================= --- 一、系统管理模块 --- ============================================= - --- 1.1 中心表 -DROP TABLE IF EXISTS sys_center; -CREATE TABLE sys_center ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - name VARCHAR(100) NOT NULL COMMENT '中心名称', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中心表'; - --- 1.2 部门表 -DROP TABLE IF EXISTS sys_department; -CREATE TABLE sys_department ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - name VARCHAR(100) NOT NULL COMMENT '部门名称', - center_id BIGINT NOT NULL COMMENT '所属中心ID', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_center_id (center_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; - --- 1.3 小组表 -DROP TABLE IF EXISTS sys_group; -CREATE TABLE sys_group ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - name VARCHAR(100) NOT NULL COMMENT '小组名称', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_department_id (department_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小组表'; - --- 1.4 用户表 -DROP TABLE IF EXISTS sys_user; -CREATE TABLE sys_user ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - wx_userid VARCHAR(100) COMMENT '企业微信用户ID', - username VARCHAR(50) NOT NULL COMMENT '用户名(登录账号)', - password VARCHAR(100) COMMENT '密码(备用登录)', - real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', - phone VARCHAR(20) COMMENT '手机号', - avatar VARCHAR(255) COMMENT '头像URL', - role TINYINT NOT NULL DEFAULT 2 COMMENT '角色(0-管理员,1-讲师,2-学员)', - department_id BIGINT COMMENT '所属部门ID', - group_id BIGINT COMMENT '所属小组ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-启用,1-禁用)', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - UNIQUE INDEX uk_username (username), - UNIQUE INDEX uk_wx_userid (wx_userid), - INDEX idx_department_id (department_id), - INDEX idx_role (role), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; - --- ============================================= --- 二、知识库模块 --- ============================================= - --- 2.1 知识分类表 -DROP TABLE IF EXISTS km_category; -CREATE TABLE km_category ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - name VARCHAR(100) NOT NULL COMMENT '分类名称', - parent_id BIGINT DEFAULT 0 COMMENT '父分类ID(0为顶级)', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_parent_id (parent_id), - INDEX idx_department_id (department_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识分类表'; - --- 2.2 知识表 -DROP TABLE IF EXISTS km_knowledge; -CREATE TABLE km_knowledge ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - title VARCHAR(200) NOT NULL COMMENT '标题', - description TEXT COMMENT '描述/摘要', - category_id BIGINT COMMENT '所属分类ID', - type TINYINT NOT NULL COMMENT '类型(0-文档,1-视频)', - file_name VARCHAR(255) COMMENT '文件名', - file_url VARCHAR(500) COMMENT '文件URL', - file_size BIGINT DEFAULT 0 COMMENT '文件大小(字节)', - file_type VARCHAR(20) COMMENT '文件类型/后缀', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-草稿,1-已发布,2-已下架)', - creator_id BIGINT NOT NULL COMMENT '创建人ID', - publish_time DATETIME COMMENT '发布时间', - view_count INT DEFAULT 0 COMMENT '浏览次数', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_category_id (category_id), - INDEX idx_department_id (department_id), - INDEX idx_status (status), - INDEX idx_creator_id (creator_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识表'; - --- ============================================= --- 三、考试模块 --- ============================================= - --- 3.1 题目分类表 -DROP TABLE IF EXISTS ex_question_category; -CREATE TABLE ex_question_category ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - name VARCHAR(100) NOT NULL COMMENT '分类名称', - parent_id BIGINT DEFAULT 0 COMMENT '父分类ID(0为顶级)', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_parent_id (parent_id), - INDEX idx_department_id (department_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目分类表'; - --- 3.2 题目表 -DROP TABLE IF EXISTS ex_question; -CREATE TABLE ex_question ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - type TINYINT NOT NULL COMMENT '题型(0-单选,1-多选,2-判断)', - content TEXT NOT NULL COMMENT '题干内容', - options JSON COMMENT '选项(JSON格式)', - answer VARCHAR(50) NOT NULL COMMENT '正确答案', - analysis TEXT COMMENT '答案解析', - category_id BIGINT COMMENT '所属分类ID', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-草稿,1-已发布,2-已下架)', - creator_id BIGINT NOT NULL COMMENT '创建人ID', - publish_time DATETIME COMMENT '发布时间', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_type (type), - INDEX idx_category_id (category_id), - INDEX idx_department_id (department_id), - INDEX idx_status (status), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; - --- 3.3 试卷表 -DROP TABLE IF EXISTS ex_paper; -CREATE TABLE ex_paper ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - title VARCHAR(200) NOT NULL COMMENT '试卷标题', - description TEXT COMMENT '试卷描述', - total_score INT NOT NULL DEFAULT 100 COMMENT '总分', - duration INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', - pass_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-草稿,1-已发布,2-已下架)', - creator_id BIGINT NOT NULL COMMENT '创建人ID', - publish_time DATETIME COMMENT '发布时间', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_department_id (department_id), - INDEX idx_status (status), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷表'; - --- 3.4 试卷题目关联表 -DROP TABLE IF EXISTS ex_paper_question; -CREATE TABLE ex_paper_question ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - paper_id BIGINT NOT NULL COMMENT '试卷ID', - question_id BIGINT NOT NULL COMMENT '题目ID', - score INT NOT NULL DEFAULT 5 COMMENT '该题分值', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_paper_id (paper_id), - INDEX idx_question_id (question_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷题目关联表'; - --- 3.5 考试表 -DROP TABLE IF EXISTS ex_exam; -CREATE TABLE ex_exam ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - title VARCHAR(200) NOT NULL COMMENT '考试标题', - description TEXT COMMENT '考试描述', - paper_id BIGINT NOT NULL COMMENT '关联试卷ID', - start_time DATETIME NOT NULL COMMENT '开始时间', - end_time DATETIME NOT NULL COMMENT '结束时间', - max_attempts INT DEFAULT 1 COMMENT '最大考试次数', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-未开始,1-进行中,2-已结束)', - creator_id BIGINT NOT NULL COMMENT '创建人ID', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_paper_id (paper_id), - INDEX idx_department_id (department_id), - INDEX idx_status (status), - INDEX idx_start_time (start_time), - INDEX idx_end_time (end_time), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试表'; - --- 3.6 考试对象表 -DROP TABLE IF EXISTS ex_exam_target; -CREATE TABLE ex_exam_target ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - exam_id BIGINT NOT NULL COMMENT '考试ID', - target_type TINYINT NOT NULL COMMENT '目标类型(0-部门,1-小组,2-个人)', - target_id BIGINT NOT NULL COMMENT '目标ID', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_exam_id (exam_id), - INDEX idx_target (target_type, target_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试对象表'; - --- 3.7 考试记录表 -DROP TABLE IF EXISTS ex_exam_record; -CREATE TABLE ex_exam_record ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - exam_id BIGINT NOT NULL COMMENT '考试ID', - user_id BIGINT NOT NULL COMMENT '用户ID', - attempt_no INT NOT NULL DEFAULT 1 COMMENT '第几次考试', - score INT COMMENT '得分', - passed TINYINT COMMENT '是否通过(0-否,1-是)', - start_time DATETIME NOT NULL COMMENT '开始时间', - submit_time DATETIME COMMENT '提交时间', - answers JSON COMMENT '答题详情(JSON格式)', - status TINYINT DEFAULT 0 COMMENT '状态(0-进行中,1-已提交)', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_exam_id (exam_id), - INDEX idx_user_id (user_id), - INDEX idx_exam_user (exam_id, user_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试记录表'; - --- ============================================= --- 四、培训模块 --- ============================================= - --- 4.1 培训计划表 -DROP TABLE IF EXISTS tr_plan; -CREATE TABLE tr_plan ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - title VARCHAR(200) NOT NULL COMMENT '计划标题', - description TEXT COMMENT '计划描述', - start_date DATE NOT NULL COMMENT '开始日期', - end_date DATE NOT NULL COMMENT '结束日期', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - status TINYINT DEFAULT 0 COMMENT '状态(0-未开始,1-进行中,2-已结束)', - creator_id BIGINT NOT NULL COMMENT '创建人ID', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_department_id (department_id), - INDEX idx_status (status), - INDEX idx_start_date (start_date), - INDEX idx_end_date (end_date), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='培训计划表'; - --- 4.2 培训计划-知识关联表 -DROP TABLE IF EXISTS tr_plan_knowledge; -CREATE TABLE tr_plan_knowledge ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - plan_id BIGINT NOT NULL COMMENT '计划ID', - knowledge_id BIGINT NOT NULL COMMENT '知识ID', - required TINYINT DEFAULT 1 COMMENT '是否必修(0-选修,1-必修)', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_plan_id (plan_id), - INDEX idx_knowledge_id (knowledge_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='培训计划-知识关联表'; - --- 4.2.1 培训计划-考试关联表 -DROP TABLE IF EXISTS tr_plan_exam; -CREATE TABLE tr_plan_exam ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - plan_id BIGINT NOT NULL COMMENT '计划ID', - exam_id BIGINT NOT NULL COMMENT '考试ID', - required TINYINT DEFAULT 1 COMMENT '是否必考(0-选考,1-必考)', - sort_order INT DEFAULT 0 COMMENT '排序', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_plan_id (plan_id), - INDEX idx_exam_id (exam_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='培训计划-考试关联表'; - --- 4.3 培训计划-对象关联表 -DROP TABLE IF EXISTS tr_plan_target; -CREATE TABLE tr_plan_target ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - plan_id BIGINT NOT NULL COMMENT '计划ID', - target_type TINYINT NOT NULL COMMENT '目标类型(0-部门,1-小组,2-个人)', - target_id BIGINT NOT NULL COMMENT '目标ID', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_plan_id (plan_id), - INDEX idx_target (target_type, target_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='培训计划-对象关联表'; - --- 4.4 培训进度表 -DROP TABLE IF EXISTS tr_plan_progress; -CREATE TABLE tr_plan_progress ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', - plan_id BIGINT NOT NULL COMMENT '计划ID', - user_id BIGINT NOT NULL COMMENT '用户ID', - knowledge_id BIGINT NOT NULL COMMENT '知识ID', - completed TINYINT DEFAULT 0 COMMENT '是否完成(0-否,1-是)', - complete_time DATETIME COMMENT '完成时间', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - deleted TINYINT DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', - INDEX idx_plan_id (plan_id), - INDEX idx_user_id (user_id), - INDEX idx_knowledge_id (knowledge_id), - INDEX idx_plan_user (plan_id, user_id), - INDEX idx_deleted (deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='培训进度表'; - --- ============================================= --- 五、初始化数据 --- ============================================= - --- 5.1 初始化中心 -INSERT INTO sys_center (name, sort_order) VALUES -('总部', 1); - --- 5.2 初始化部门 -INSERT INTO sys_department (name, center_id, sort_order) VALUES -('救援一部', 1, 1), -('救援二部', 1, 2), -('客服中心', 1, 3), -('技术支持部', 1, 4); - --- 5.3 初始化小组 -INSERT INTO sys_group (name, department_id, sort_order) VALUES -('一组', 1, 1), -('二组', 1, 2), -('三组', 1, 3), -('一组', 2, 1), -('二组', 2, 2); - --- 5.4 初始化管理员账号 (密码: admin123,使用BCrypt加密) --- BCrypt密码生成: new BCryptPasswordEncoder().encode("admin123") -INSERT INTO sys_user (username, password, real_name, phone, role, department_id, status) VALUES -('admin', '$2a$10$TWbA.Dgh6pjuVoLZzRod3usjs7fOyyofpTVCi1mz.okHtO55vGgBW', '系统管理员', '13800138000', 0, 1, 0); - --- 5.5 初始化知识分类 -INSERT INTO km_category (name, parent_id, department_id, sort_order) VALUES -('安全规范', 0, 1, 1), -('操作手册', 0, 1, 2), -('应急流程', 0, 1, 3); - --- 5.6 初始化题目分类 -INSERT INTO ex_question_category (name, parent_id, department_id, sort_order) VALUES -('安全知识', 0, 1, 1), -('操作规范', 0, 1, 2), -('应急处理', 0, 1, 3); - --- ============================================= --- 完成 --- ============================================= -SELECT '数据库初始化完成!' AS message; diff --git a/training-system/src/main/java/com/sino/training/TrainingApplication.java b/training-system/src/main/java/com/sino/training/TrainingApplication.java deleted file mode 100644 index 128aa68..0000000 --- a/training-system/src/main/java/com/sino/training/TrainingApplication.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sino.training; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * 道路救援企业培训系统 - 启动类 - * - * @author training-system - */ -@SpringBootApplication -public class TrainingApplication { - - public static void main(String[] args) { - SpringApplication.run(TrainingApplication.class, args); - } - -} diff --git a/training-system/src/main/java/com/sino/training/common/annotation/RequireRole.java b/training-system/src/main/java/com/sino/training/common/annotation/RequireRole.java deleted file mode 100644 index a674a32..0000000 --- a/training-system/src/main/java/com/sino/training/common/annotation/RequireRole.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sino.training.common.annotation; - -import com.sino.training.common.enums.UserRole; - -import java.lang.annotation.*; - -/** - * 角色权限注解 - * 标注在Controller类或方法上,限制访问角色 - * - * @author training-system - */ -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface RequireRole { - - /** - * 允许访问的角色列表 - */ - UserRole[] value(); -} diff --git a/training-system/src/main/java/com/sino/training/common/base/BaseEntity.java b/training-system/src/main/java/com/sino/training/common/base/BaseEntity.java deleted file mode 100644 index 35697b5..0000000 --- a/training-system/src/main/java/com/sino/training/common/base/BaseEntity.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.sino.training.common.base; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; - -/** - * 实体基类 - * - * @author training-system - */ -@Data -public abstract class BaseEntity implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 主键ID - */ - @TableId(type = IdType.AUTO) - private Long id; - - /** - * 创建时间 - */ - @TableField(fill = FieldFill.INSERT) - private LocalDateTime createTime; - - /** - * 更新时间 - */ - @TableField(fill = FieldFill.INSERT_UPDATE) - private LocalDateTime updateTime; - - /** - * 逻辑删除标识(0-未删除,1-已删除) - */ - @TableLogic - @TableField(fill = FieldFill.INSERT) - private Integer deleted; -} diff --git a/training-system/src/main/java/com/sino/training/common/base/PageQuery.java b/training-system/src/main/java/com/sino/training/common/base/PageQuery.java deleted file mode 100644 index 7eb12bb..0000000 --- a/training-system/src/main/java/com/sino/training/common/base/PageQuery.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.sino.training.common.base; - -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import lombok.Data; - -import java.io.Serializable; - -/** - * 分页查询基类 - * - * @author training-system - */ -@Data -public class PageQuery implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 当前页码(默认1) - */ - private Integer current = 1; - - /** - * 每页大小(默认10) - */ - private Integer size = 10; - - /** - * 转换为 MyBatis Plus 分页对象 - */ - public Page toPage() { - return new Page<>(current, size); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/config/MybatisPlusConfig.java b/training-system/src/main/java/com/sino/training/common/config/MybatisPlusConfig.java deleted file mode 100644 index af5f411..0000000 --- a/training-system/src/main/java/com/sino/training/common/config/MybatisPlusConfig.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sino.training.common.config; - -import com.baomidou.mybatisplus.annotation.DbType; -import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; -import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; -import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; -import org.apache.ibatis.reflection.MetaObject; -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.LocalDateTime; - -/** - * MyBatis Plus 配置 - * - * @author training-system - */ -@Configuration -@MapperScan("com.sino.training.module.*.mapper") -public class MybatisPlusConfig { - - /** - * 分页插件配置 - */ - @Bean - public MybatisPlusInterceptor mybatisPlusInterceptor() { - MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); - // 分页插件 - PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); - paginationInterceptor.setMaxLimit(500L); - interceptor.addInnerInterceptor(paginationInterceptor); - return interceptor; - } - - /** - * 自动填充配置 - */ - @Bean - public MetaObjectHandler metaObjectHandler() { - return new MetaObjectHandler() { - @Override - public void insertFill(MetaObject metaObject) { - this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); - this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); - this.strictInsertFill(metaObject, "deleted", Integer.class, 0); - } - - @Override - public void updateFill(MetaObject metaObject) { - this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); - } - }; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/config/SecurityConfig.java b/training-system/src/main/java/com/sino/training/common/config/SecurityConfig.java deleted file mode 100644 index f3ccae7..0000000 --- a/training-system/src/main/java/com/sino/training/common/config/SecurityConfig.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.sino.training.common.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; - -/** - * Spring Security 配置 - * 注:本项目使用自定义JWT认证,禁用Spring Security的默认认证 - * - * @author training-system - */ -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - /** - * 密码编码器 - */ - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - /** - * 安全过滤链配置 - * 禁用Spring Security的默认认证,使用自定义JWT认证 - */ - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - // 禁用CSRF(使用JWT不需要CSRF) - .csrf(AbstractHttpConfigurer::disable) - // 禁用表单登录 - .formLogin(AbstractHttpConfigurer::disable) - // 禁用HTTP Basic认证 - .httpBasic(AbstractHttpConfigurer::disable) - // 无状态会话 - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // 允许所有请求(认证由自定义拦截器处理) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - - return http.build(); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/config/SwaggerConfig.java b/training-system/src/main/java/com/sino/training/common/config/SwaggerConfig.java deleted file mode 100644 index 72fe066..0000000 --- a/training-system/src/main/java/com/sino/training/common/config/SwaggerConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.common.config; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Swagger API 文档配置 - * - * @author training-system - */ -@Configuration -public class SwaggerConfig { - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info() - .title("道路救援企业培训系统 API") - .description("企业内部培训管理系统接口文档") - .version("1.0.0") - .contact(new Contact() - .name("Training System") - .email("admin@training.com"))) - .schemaRequirement("Bearer", new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name("Authorization")) - .addSecurityItem(new SecurityRequirement().addList("Bearer")); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/config/WebMvcConfig.java b/training-system/src/main/java/com/sino/training/common/config/WebMvcConfig.java deleted file mode 100644 index 906f67e..0000000 --- a/training-system/src/main/java/com/sino/training/common/config/WebMvcConfig.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.sino.training.common.config; - -import com.sino.training.common.interceptor.AuthInterceptor; -import com.sino.training.common.interceptor.RoleInterceptor; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -/** - * Web MVC 配置 - * - * @author training-system - */ -@Configuration -@RequiredArgsConstructor -public class WebMvcConfig implements WebMvcConfigurer { - - private final AuthInterceptor authInterceptor; - private final RoleInterceptor roleInterceptor; - - /** - * 跨域配置 - */ - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns("*") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true) - .maxAge(3600); - } - - /** - * 拦截器配置 - */ - @Override - public void addInterceptors(InterceptorRegistry registry) { - // 认证拦截器(先执行) - registry.addInterceptor(authInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/auth/login", - "/api/auth/wechat/**", - "/api/public/**" - ); - - // 角色权限拦截器(后执行,需要依赖认证拦截器设置的UserContext) - registry.addInterceptor(roleInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/auth/login", - "/api/auth/wechat/**", - "/api/public/**" - ); - } - - /** - * 静态资源配置 - */ - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - // 上传文件访问路径 - registry.addResourceHandler("/uploads/**") - .addResourceLocations("file:./uploads/"); - // 静态资源 - registry.addResourceHandler("/static/**") - .addResourceLocations("classpath:/static/"); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/context/UserContext.java b/training-system/src/main/java/com/sino/training/common/context/UserContext.java deleted file mode 100644 index 4731d27..0000000 --- a/training-system/src/main/java/com/sino/training/common/context/UserContext.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sino.training.common.context; - -import lombok.Data; - -import java.io.Serializable; - -/** - * 用户上下文信息 - * - * @author training-system - */ -@Data -public class UserContext implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 真实姓名 - */ - private String realName; - - /** - * 用户角色 - */ - private String role; - - /** - * 部门ID - */ - private Long departmentId; - - /** - * 小组ID - */ - private Long groupId; - - public UserContext() { - } - - public UserContext(Long userId, String username, String role) { - this.userId = userId; - this.username = username; - this.role = role; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/context/UserContextHolder.java b/training-system/src/main/java/com/sino/training/common/context/UserContextHolder.java deleted file mode 100644 index 4a4a64f..0000000 --- a/training-system/src/main/java/com/sino/training/common/context/UserContextHolder.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.sino.training.common.context; - -/** - * 用户上下文持有者(基于ThreadLocal) - * - * @author training-system - */ -public class UserContextHolder { - - private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); - - private UserContextHolder() { - } - - /** - * 设置用户上下文 - * - * @param context 用户上下文 - */ - public static void setContext(UserContext context) { - CONTEXT_HOLDER.set(context); - } - - /** - * 获取用户上下文 - * - * @return 用户上下文 - */ - public static UserContext getContext() { - return CONTEXT_HOLDER.get(); - } - - /** - * 清除用户上下文 - */ - public static void clearContext() { - CONTEXT_HOLDER.remove(); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/controller/FileController.java b/training-system/src/main/java/com/sino/training/common/controller/FileController.java deleted file mode 100644 index 9d28acd..0000000 --- a/training-system/src/main/java/com/sino/training/common/controller/FileController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.sino.training.common.controller; - -import com.sino.training.common.dto.FileDTO; -import com.sino.training.common.result.Result; -import com.sino.training.common.service.FileService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -/** - * 文件上传控制器 - * - * @author training-system - */ -@Tag(name = "文件管理", description = "文件上传下载接口") -@RestController -@RequestMapping("/api/file") -@RequiredArgsConstructor -public class FileController { - - private final FileService fileService; - - @Operation(summary = "上传文件") - @PostMapping("/upload") - public Result upload( - @Parameter(description = "文件") @RequestParam("file") MultipartFile file, - @Parameter(description = "子文件夹") @RequestParam(value = "folder", defaultValue = "common") String folder) { - return Result.success(fileService.upload(file, folder)); - } - - @Operation(summary = "上传知识库文档") - @PostMapping("/upload/document") - public Result uploadDocument(@RequestParam("file") MultipartFile file) { - return Result.success(fileService.upload(file, "knowledge/document")); - } - - @Operation(summary = "上传知识库视频") - @PostMapping("/upload/video") - public Result uploadVideo(@RequestParam("file") MultipartFile file) { - return Result.success(fileService.upload(file, "knowledge/video")); - } - - @Operation(summary = "删除文件") - @DeleteMapping("/delete") - public Result delete(@Parameter(description = "文件URL") @RequestParam String fileUrl) { - return Result.success(fileService.delete(fileUrl)); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/dto/FileDTO.java b/training-system/src/main/java/com/sino/training/common/dto/FileDTO.java deleted file mode 100644 index 26d4e73..0000000 --- a/training-system/src/main/java/com/sino/training/common/dto/FileDTO.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.sino.training.common.dto; - -import lombok.Data; - -import java.io.Serializable; - -/** - * 文件信息DTO - * - * @author training-system - */ -@Data -public class FileDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 原始文件名 - */ - private String originalName; - - /** - * 存储文件名 - */ - private String storageName; - - /** - * 文件URL - */ - private String url; - - /** - * 文件大小(字节) - */ - private Long size; - - /** - * 文件类型/后缀 - */ - private String type; -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/ContentStatus.java b/training-system/src/main/java/com/sino/training/common/enums/ContentStatus.java deleted file mode 100644 index 02beecf..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/ContentStatus.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 内容状态枚举(知识库、题目、试卷) - * - * @author training-system - */ -@Getter -public enum ContentStatus { - - /** - * 草稿 - */ - DRAFT(0, "草稿"), - - /** - * 已发布 - */ - PUBLISHED(1, "已发布"), - - /** - * 已下架 - */ - OFFLINE(2, "已下架"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - ContentStatus(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static ContentStatus of(int code) { - for (ContentStatus status : values()) { - if (status.getCode() == code) { - return status; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/ExamStatus.java b/training-system/src/main/java/com/sino/training/common/enums/ExamStatus.java deleted file mode 100644 index af81a8e..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/ExamStatus.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 考试状态枚举 - * - * @author training-system - */ -@Getter -public enum ExamStatus { - - /** - * 未开始 - */ - NOT_STARTED(0, "未开始"), - - /** - * 进行中 - */ - IN_PROGRESS(1, "进行中"), - - /** - * 已结束 - */ - ENDED(2, "已结束"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - ExamStatus(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static ExamStatus of(int code) { - for (ExamStatus status : values()) { - if (status.getCode() == code) { - return status; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/KnowledgeType.java b/training-system/src/main/java/com/sino/training/common/enums/KnowledgeType.java deleted file mode 100644 index 611dc11..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/KnowledgeType.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 知识类型枚举 - * - * @author training-system - */ -@Getter -public enum KnowledgeType { - - /** - * 文档 - */ - DOCUMENT(0, "文档"), - - /** - * 视频 - */ - VIDEO(1, "视频"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - KnowledgeType(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static KnowledgeType of(int code) { - for (KnowledgeType type : values()) { - if (type.getCode() == code) { - return type; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/PlanStatus.java b/training-system/src/main/java/com/sino/training/common/enums/PlanStatus.java deleted file mode 100644 index 3dfee78..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/PlanStatus.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 培训计划状态枚举 - * - * @author training-system - */ -@Getter -public enum PlanStatus { - - /** - * 未开始 - */ - NOT_STARTED(0, "未开始"), - - /** - * 进行中 - */ - IN_PROGRESS(1, "进行中"), - - /** - * 已结束 - */ - ENDED(2, "已结束"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - PlanStatus(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static PlanStatus of(int code) { - for (PlanStatus status : values()) { - if (status.getCode() == code) { - return status; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java b/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java deleted file mode 100644 index ffa07b7..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 题目类型枚举 - * - * @author training-system - */ -@Getter -public enum QuestionType { - - /** - * 单选题 - */ - SINGLE(0, "单选题"), - - /** - * 多选题 - */ - MULTIPLE(1, "多选题"), - - /** - * 判断题 - */ - JUDGE(2, "判断题"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - QuestionType(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static QuestionType of(int code) { - for (QuestionType type : values()) { - if (type.getCode() == code) { - return type; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/TargetType.java b/training-system/src/main/java/com/sino/training/common/enums/TargetType.java deleted file mode 100644 index 186d2e2..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/TargetType.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 目标类型枚举(考试/培训对象) - * - * @author training-system - */ -@Getter -public enum TargetType { - - /** - * 部门 - */ - DEPARTMENT(0, "部门"), - - /** - * 小组 - */ - GROUP(1, "小组"), - - /** - * 个人 - */ - USER(2, "个人"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - TargetType(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static TargetType of(int code) { - for (TargetType type : values()) { - if (type.getCode() == code) { - return type; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/UserRole.java b/training-system/src/main/java/com/sino/training/common/enums/UserRole.java deleted file mode 100644 index ac9f096..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/UserRole.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 用户角色枚举 - * - * @author training-system - */ -@Getter -public enum UserRole { - - /** - * 管理员 - */ - ADMIN(0, "管理员"), - - /** - * 讲师 - */ - LECTURER(1, "讲师"), - - /** - * 学员 - */ - STUDENT(2, "学员"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - UserRole(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static UserRole of(int code) { - for (UserRole role : values()) { - if (role.getCode() == code) { - return role; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/enums/UserStatus.java b/training-system/src/main/java/com/sino/training/common/enums/UserStatus.java deleted file mode 100644 index 79fec6a..0000000 --- a/training-system/src/main/java/com/sino/training/common/enums/UserStatus.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sino.training.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.fasterxml.jackson.annotation.JsonValue; -import lombok.Getter; - -/** - * 用户状态枚举 - * - * @author training-system - */ -@Getter -public enum UserStatus { - - /** - * 启用 - */ - ENABLED(0, "启用"), - - /** - * 禁用 - */ - DISABLED(1, "禁用"); - - @EnumValue - private final int code; - - @JsonValue - private final String desc; - - UserStatus(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static UserStatus of(int code) { - for (UserStatus status : values()) { - if (status.getCode() == code) { - return status; - } - } - return null; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/exception/BusinessException.java b/training-system/src/main/java/com/sino/training/common/exception/BusinessException.java deleted file mode 100644 index 3536710..0000000 --- a/training-system/src/main/java/com/sino/training/common/exception/BusinessException.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sino.training.common.exception; - -import com.sino.training.common.result.ResultCode; -import lombok.Getter; - -/** - * 业务异常 - * - * @author training-system - */ -@Getter -public class BusinessException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** - * 错误码 - */ - private final int code; - - /** - * 错误信息 - */ - private final String message; - - public BusinessException(ResultCode resultCode) { - super(resultCode.getMessage()); - this.code = resultCode.getCode(); - this.message = resultCode.getMessage(); - } - - public BusinessException(ResultCode resultCode, String message) { - super(message); - this.code = resultCode.getCode(); - this.message = message; - } - - public BusinessException(int code, String message) { - super(message); - this.code = code; - this.message = message; - } - - public BusinessException(String message) { - super(message); - this.code = ResultCode.INTERNAL_ERROR.getCode(); - this.message = message; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/exception/GlobalExceptionHandler.java b/training-system/src/main/java/com/sino/training/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index faad7cc..0000000 --- a/training-system/src/main/java/com/sino/training/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.sino.training.common.exception; - -import com.sino.training.common.result.Result; -import com.sino.training.common.result.ResultCode; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.validation.BindException; -import org.springframework.validation.FieldError; -import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.multipart.MaxUploadSizeExceededException; -import org.springframework.web.servlet.NoHandlerFoundException; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * 全局异常处理器 - * - * @author training-system - */ -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - /** - * 处理业务异常 - */ - @ExceptionHandler(BusinessException.class) - public Result handleBusinessException(BusinessException e, HttpServletRequest request) { - log.warn("业务异常: {} - {}", request.getRequestURI(), e.getMessage()); - return Result.error(e.getCode(), e.getMessage()); - } - - /** - * 处理参数校验异常(@Valid) - */ - @ExceptionHandler(MethodArgumentNotValidException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - List fieldErrors = e.getBindingResult().getFieldErrors(); - String message = fieldErrors.stream() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .collect(Collectors.joining("; ")); - log.warn("参数校验失败: {}", message); - return Result.error(ResultCode.BAD_REQUEST, message); - } - - /** - * 处理参数绑定异常 - */ - @ExceptionHandler(BindException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Result handleBindException(BindException e) { - List fieldErrors = e.getBindingResult().getFieldErrors(); - String message = fieldErrors.stream() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .collect(Collectors.joining("; ")); - log.warn("参数绑定失败: {}", message); - return Result.error(ResultCode.BAD_REQUEST, message); - } - - /** - * 处理缺少请求参数异常 - */ - @ExceptionHandler(MissingServletRequestParameterException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Result handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - log.warn("缺少请求参数: {}", e.getParameterName()); - return Result.error(ResultCode.BAD_REQUEST, "缺少请求参数: " + e.getParameterName()); - } - - /** - * 处理请求方法不支持异常 - */ - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) - public Result handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { - log.warn("请求方法不支持: {}", e.getMethod()); - return Result.error(ResultCode.METHOD_NOT_ALLOWED); - } - - /** - * 处理404异常 - */ - @ExceptionHandler(NoHandlerFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public Result handleNoHandlerFoundException(NoHandlerFoundException e) { - log.warn("资源不存在: {}", e.getRequestURL()); - return Result.error(ResultCode.NOT_FOUND); - } - - /** - * 处理文件上传大小超限异常 - */ - @ExceptionHandler(MaxUploadSizeExceededException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Result handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { - log.warn("文件大小超出限制: {}", e.getMessage()); - return Result.error(ResultCode.FILE_SIZE_EXCEEDED); - } - - /** - * 处理其他未知异常 - */ - @ExceptionHandler(Exception.class) - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public Result handleException(Exception e, HttpServletRequest request) { - log.error("系统异常: {} - {}", request.getRequestURI(), e.getMessage(), e); - return Result.error(ResultCode.INTERNAL_ERROR); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/interceptor/AuthInterceptor.java b/training-system/src/main/java/com/sino/training/common/interceptor/AuthInterceptor.java deleted file mode 100644 index 92928e2..0000000 --- a/training-system/src/main/java/com/sino/training/common/interceptor/AuthInterceptor.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sino.training.common.interceptor; - -import com.auth0.jwt.interfaces.DecodedJWT; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.result.Result; -import com.sino.training.common.result.ResultCode; -import com.sino.training.common.utils.JwtUtils; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.servlet.HandlerInterceptor; - -/** - * 认证拦截器 - * - * @author training-system - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class AuthInterceptor implements HandlerInterceptor { - - private final JwtUtils jwtUtils; - private final ObjectMapper objectMapper; - - @Value("${training.jwt.header}") - private String header; - - @Value("${training.jwt.prefix}") - private String prefix; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - // 获取Token - String token = getToken(request); - - if (!StringUtils.hasText(token)) { - writeError(response, ResultCode.UNAUTHORIZED); - return false; - } - - // 验证Token - DecodedJWT jwt = jwtUtils.verifyToken(token); - if (jwt == null) { - writeError(response, ResultCode.TOKEN_INVALID); - return false; - } - - // 检查是否过期 - if (jwtUtils.isTokenExpired(token)) { - writeError(response, ResultCode.TOKEN_EXPIRED); - return false; - } - - // 设置用户上下文 - UserContext context = new UserContext(); - context.setUserId(Long.parseLong(jwt.getSubject())); - context.setUsername(jwt.getClaim("username").asString()); - context.setRole(jwt.getClaim("role").asString()); - - // 部门ID需要从数据库获取,这里先从Token中获取(如果有) - if (jwt.getClaim("departmentId") != null && !jwt.getClaim("departmentId").isNull()) { - context.setDepartmentId(jwt.getClaim("departmentId").asLong()); - } - - UserContextHolder.setContext(context); - - return true; - } - - @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { - // 清除用户上下文 - UserContextHolder.clearContext(); - } - - /** - * 从请求头获取Token - */ - private String getToken(HttpServletRequest request) { - String bearerToken = request.getHeader(header); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(prefix + " ")) { - return bearerToken.substring(prefix.length() + 1); - } - return null; - } - - /** - * 写入错误响应 - */ - private void writeError(HttpServletResponse response, ResultCode resultCode) throws Exception { - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write(objectMapper.writeValueAsString(Result.error(resultCode))); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/interceptor/RoleInterceptor.java b/training-system/src/main/java/com/sino/training/common/interceptor/RoleInterceptor.java deleted file mode 100644 index eb553b0..0000000 --- a/training-system/src/main/java/com/sino/training/common/interceptor/RoleInterceptor.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.sino.training.common.interceptor; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sino.training.common.annotation.RequireRole; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.UserRole; -import com.sino.training.common.result.Result; -import com.sino.training.common.result.ResultCode; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerInterceptor; - -/** - * 角色权限拦截器 - * 基于 @RequireRole 注解进行角色权限校验 - * - * @author training-system - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class RoleInterceptor implements HandlerInterceptor { - - private final ObjectMapper objectMapper; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, - Object handler) throws Exception { - if (!(handler instanceof HandlerMethod handlerMethod)) { - return true; - } - - // 优先取方法上的注解,没有则取类上的注解 - RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); - if (requireRole == null) { - requireRole = handlerMethod.getBeanType().getAnnotation(RequireRole.class); - } - - // 没有注解则放行 - if (requireRole == null) { - return true; - } - - // 获取当前用户上下文 - UserContext context = UserContextHolder.getContext(); - if (context == null || context.getRole() == null) { - log.warn("用户未登录或角色信息缺失,拒绝访问: {}", request.getRequestURI()); - writeError(response, ResultCode.UNAUTHORIZED); - return false; - } - - // 检查角色是否匹配 - UserRole currentRole; - try { - currentRole = UserRole.valueOf(context.getRole()); - } catch (IllegalArgumentException e) { - log.error("无效的用户角色: {}", context.getRole()); - writeError(response, ResultCode.FORBIDDEN); - return false; - } - - for (UserRole allowedRole : requireRole.value()) { - if (currentRole == allowedRole) { - return true; - } - } - - // 角色不匹配,返回403 - log.warn("用户 {} 角色 {} 无权访问: {}", context.getUsername(), currentRole, request.getRequestURI()); - writeError(response, ResultCode.FORBIDDEN); - return false; - } - - /** - * 写入错误响应 - */ - private void writeError(HttpServletResponse response, ResultCode resultCode) throws Exception { - response.setContentType("application/json;charset=UTF-8"); - int statusCode = resultCode == ResultCode.FORBIDDEN ? - HttpServletResponse.SC_FORBIDDEN : HttpServletResponse.SC_UNAUTHORIZED; - response.setStatus(statusCode); - response.getWriter().write(objectMapper.writeValueAsString(Result.error(resultCode))); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/result/PageResult.java b/training-system/src/main/java/com/sino/training/common/result/PageResult.java deleted file mode 100644 index 8d02ee9..0000000 --- a/training-system/src/main/java/com/sino/training/common/result/PageResult.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.sino.training.common.result; - -import com.baomidou.mybatisplus.core.metadata.IPage; -import lombok.Data; - -import java.io.Serializable; -import java.util.List; - -/** - * 分页结果封装 - * - * @author training-system - */ -@Data -public class PageResult implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 当前页码 - */ - private long current; - - /** - * 每页大小 - */ - private long size; - - /** - * 总记录数 - */ - private long total; - - /** - * 总页数 - */ - private long pages; - - /** - * 数据列表 - */ - private List records; - - public PageResult() { - } - - public PageResult(long current, long size, long total, List records) { - this.current = current; - this.size = size; - this.total = total; - this.pages = (total + size - 1) / size; - this.records = records; - } - - /** - * 从 MyBatis Plus 的 IPage 转换 - */ - public static PageResult of(IPage page) { - PageResult result = new PageResult<>(); - result.setCurrent(page.getCurrent()); - result.setSize(page.getSize()); - result.setTotal(page.getTotal()); - result.setPages(page.getPages()); - result.setRecords(page.getRecords()); - return result; - } - - /** - * 从 MyBatis Plus 的 IPage 转换(带类型转换) - */ - public static PageResult of(IPage page, List records) { - PageResult result = new PageResult<>(); - result.setCurrent(page.getCurrent()); - result.setSize(page.getSize()); - result.setTotal(page.getTotal()); - result.setPages(page.getPages()); - result.setRecords(records); - return result; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/result/Result.java b/training-system/src/main/java/com/sino/training/common/result/Result.java deleted file mode 100644 index 0c33761..0000000 --- a/training-system/src/main/java/com/sino/training/common/result/Result.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.sino.training.common.result; - -import lombok.Data; - -import java.io.Serializable; - -/** - * 统一响应结果封装 - * - * @author training-system - */ -@Data -public class Result implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 状态码 - */ - private int code; - - /** - * 消息 - */ - private String message; - - /** - * 数据 - */ - private T data; - - /** - * 时间戳 - */ - private long timestamp; - - public Result() { - this.timestamp = System.currentTimeMillis(); - } - - public Result(int code, String message, T data) { - this.code = code; - this.message = message; - this.data = data; - this.timestamp = System.currentTimeMillis(); - } - - /** - * 成功响应(无数据) - */ - public static Result success() { - return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); - } - - /** - * 成功响应(带数据) - */ - public static Result success(T data) { - return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); - } - - /** - * 成功响应(自定义消息) - */ - public static Result success(String message, T data) { - return new Result<>(ResultCode.SUCCESS.getCode(), message, data); - } - - /** - * 失败响应(使用状态码枚举) - */ - public static Result error(ResultCode resultCode) { - return new Result<>(resultCode.getCode(), resultCode.getMessage(), null); - } - - /** - * 失败响应(自定义消息) - */ - public static Result error(ResultCode resultCode, String message) { - return new Result<>(resultCode.getCode(), message, null); - } - - /** - * 失败响应(自定义状态码和消息) - */ - public static Result error(int code, String message) { - return new Result<>(code, message, null); - } - - /** - * 判断是否成功 - */ - public boolean isSuccess() { - return this.code == ResultCode.SUCCESS.getCode(); - } -} diff --git a/training-system/src/main/java/com/sino/training/common/result/ResultCode.java b/training-system/src/main/java/com/sino/training/common/result/ResultCode.java deleted file mode 100644 index 23041ab..0000000 --- a/training-system/src/main/java/com/sino/training/common/result/ResultCode.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.sino.training.common.result; - -import lombok.Getter; - -/** - * 响应状态码枚举 - * - * @author training-system - */ -@Getter -public enum ResultCode { - - // 成功 - SUCCESS(200, "操作成功"), - - // 客户端错误 4xx - BAD_REQUEST(400, "请求参数错误"), - UNAUTHORIZED(401, "未登录或登录已过期"), - FORBIDDEN(403, "没有操作权限"), - NOT_FOUND(404, "资源不存在"), - METHOD_NOT_ALLOWED(405, "请求方法不允许"), - - // 业务错误 5xx - INTERNAL_ERROR(500, "服务器内部错误"), - SERVICE_UNAVAILABLE(503, "服务暂不可用"), - - // 自定义业务错误码 1xxx - USER_NOT_FOUND(1001, "用户不存在"), - USER_DISABLED(1002, "用户已被禁用"), - PASSWORD_ERROR(1003, "密码错误"), - USER_EXISTS(1004, "用户已存在"), - PHONE_EXISTS(1005, "手机号已存在"), - - // 认证相关 11xx - TOKEN_INVALID(1101, "Token无效"), - TOKEN_EXPIRED(1102, "Token已过期"), - WECHAT_AUTH_FAILED(1103, "企业微信认证失败"), - - // 知识库相关 12xx - KNOWLEDGE_NOT_FOUND(1201, "知识不存在"), - CATEGORY_NOT_FOUND(1202, "分类不存在"), - KNOWLEDGE_REFERENCED(1203, "知识已被引用,无法删除"), - - // 考试相关 13xx - QUESTION_NOT_FOUND(1301, "题目不存在"), - PAPER_NOT_FOUND(1302, "试卷不存在"), - EXAM_NOT_FOUND(1303, "考试不存在"), - EXAM_NOT_STARTED(1304, "考试未开始"), - EXAM_ENDED(1305, "考试已结束"), - EXAM_ATTEMPTS_EXCEEDED(1306, "考试次数已用完"), - PAPER_REFERENCED(1307, "试卷已被引用,无法删除"), - QUESTION_REFERENCED(1308, "题目已被引用,无法删除"), - - // 培训相关 14xx - PLAN_NOT_FOUND(1401, "培训计划不存在"), - - // 文件相关 15xx - FILE_UPLOAD_FAILED(1501, "文件上传失败"), - FILE_TYPE_NOT_ALLOWED(1502, "文件类型不允许"), - FILE_SIZE_EXCEEDED(1503, "文件大小超出限制"), - FILE_NOT_FOUND(1504, "文件不存在"), - - // 数据相关 16xx - DATA_NOT_FOUND(1601, "数据不存在"), - DATA_EXISTS(1602, "数据已存在"), - DATA_REFERENCED(1603, "数据已被引用,无法操作"); - - /** - * 状态码 - */ - private final int code; - - /** - * 状态信息 - */ - private final String message; - - ResultCode(int code, String message) { - this.code = code; - this.message = message; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/service/FileService.java b/training-system/src/main/java/com/sino/training/common/service/FileService.java deleted file mode 100644 index b382855..0000000 --- a/training-system/src/main/java/com/sino/training/common/service/FileService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.common.service; - -import com.sino.training.common.dto.FileDTO; -import org.springframework.web.multipart.MultipartFile; - -/** - * 文件服务接口 - * - * @author training-system - */ -public interface FileService { - - /** - * 上传文件 - * - * @param file 文件 - * @param folder 子文件夹(如:knowledge、video) - * @return 文件信息 - */ - FileDTO upload(MultipartFile file, String folder); - - /** - * 删除文件 - * - * @param fileUrl 文件URL - * @return 是否删除成功 - */ - boolean delete(String fileUrl); - - /** - * 获取文件访问路径 - * - * @param relativePath 相对路径 - * @return 完整访问URL - */ - String getAccessUrl(String relativePath); -} diff --git a/training-system/src/main/java/com/sino/training/common/service/impl/LocalFileServiceImpl.java b/training-system/src/main/java/com/sino/training/common/service/impl/LocalFileServiceImpl.java deleted file mode 100644 index 4ca7452..0000000 --- a/training-system/src/main/java/com/sino/training/common/service/impl/LocalFileServiceImpl.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.sino.training.common.service.impl; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.StrUtil; -import com.sino.training.common.dto.FileDTO; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.common.service.FileService; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import jakarta.annotation.PostConstruct; -import java.io.File; -import java.io.IOException; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.List; - -/** - * 本地文件服务实现类 - * - * @author training-system - */ -@Slf4j -@Service -public class LocalFileServiceImpl implements FileService { - - @Value("${training.upload.path}") - private String uploadPath; - - @Value("${training.upload.allowed-types}") - private String allowedTypes; - - @Value("${training.upload.max-size}") - private Long maxSize; - - /** - * 应用启动时初始化上传目录 - */ - @PostConstruct - public void init() { - File uploadDir = new File(uploadPath); - if (!uploadDir.exists()) { - boolean created = uploadDir.mkdirs(); - log.info("初始化上传目录: {}, 创建结果: {}", uploadPath, created); - } else { - log.info("上传目录已存在: {}", uploadPath); - } - // 检查目录权限 - if (!uploadDir.canWrite()) { - log.error("上传目录无写入权限: {}", uploadPath); - } - } - - @Override - public FileDTO upload(MultipartFile file, String folder) { - if (file == null || file.isEmpty()) { - throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED, "文件不能为空"); - } - - // 获取文件名和后缀 - String originalName = file.getOriginalFilename(); - if (StrUtil.isBlank(originalName)) { - throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED, "文件名不能为空"); - } - - String suffix = FileUtil.getSuffix(originalName).toLowerCase(); - - // 检查文件类型 - List allowedTypeList = Arrays.asList(allowedTypes.split(",")); - if (!allowedTypeList.contains(suffix)) { - throw new BusinessException(ResultCode.FILE_TYPE_NOT_ALLOWED, - "不支持的文件类型: " + suffix + ",支持的类型: " + allowedTypes); - } - - // 检查文件大小 - if (file.getSize() > maxSize) { - throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED, - "文件大小超出限制,最大: " + formatFileSize(maxSize)); - } - - // 生成存储路径:uploads/folder/yyyy/MM/dd/uuid.suffix - String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); - String storageName = IdUtil.fastSimpleUUID() + "." + suffix; - String relativePath = (StrUtil.isNotBlank(folder) ? folder + "/" : "") + datePath + "/" + storageName; - String fullPath = uploadPath + "/" + relativePath; - - // 创建目录 - File destFile = new File(fullPath); - log.info("准备保存文件, 目标路径: {}", fullPath); - - try { - FileUtil.mkParentDirs(destFile); - log.info("父目录创建成功: {}", destFile.getParentFile().getAbsolutePath()); - } catch (Exception e) { - log.error("创建父目录失败, 路径: {}", destFile.getParentFile().getAbsolutePath(), e); - throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED, "创建上传目录失败"); - } - - // 保存文件 - try { - file.transferTo(destFile); - log.info("文件上传成功: {}", fullPath); - } catch (IOException e) { - log.error("文件上传失败, 目标路径: {}, 原因: {}", fullPath, e.getMessage(), e); - throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED, "文件保存失败: " + e.getMessage()); - } - - // 返回文件信息 - FileDTO dto = new FileDTO(); - dto.setOriginalName(originalName); - dto.setStorageName(storageName); - dto.setUrl("/uploads/" + relativePath); - dto.setSize(file.getSize()); - dto.setType(suffix); - - return dto; - } - - @Override - public boolean delete(String fileUrl) { - if (StrUtil.isBlank(fileUrl)) { - return false; - } - - // 将URL转换为文件路径 - String relativePath = fileUrl.replace("/uploads/", ""); - String fullPath = uploadPath + "/" + relativePath; - - File file = new File(fullPath); - if (file.exists()) { - boolean deleted = FileUtil.del(file); - log.info("删除文件: {}, 结果: {}", fullPath, deleted); - return deleted; - } - - return false; - } - - @Override - public String getAccessUrl(String relativePath) { - if (StrUtil.isBlank(relativePath)) { - return null; - } - if (relativePath.startsWith("/uploads/")) { - return relativePath; - } - return "/uploads/" + relativePath; - } - - private String formatFileSize(long size) { - if (size < 1024) { - return size + "B"; - } else if (size < 1024 * 1024) { - return String.format("%.2fKB", size / 1024.0); - } else if (size < 1024 * 1024 * 1024) { - return String.format("%.2fMB", size / (1024.0 * 1024)); - } else { - return String.format("%.2fGB", size / (1024.0 * 1024 * 1024)); - } - } -} diff --git a/training-system/src/main/java/com/sino/training/common/utils/JwtUtils.java b/training-system/src/main/java/com/sino/training/common/utils/JwtUtils.java deleted file mode 100644 index 01ca7ad..0000000 --- a/training-system/src/main/java/com/sino/training/common/utils/JwtUtils.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.sino.training.common.utils; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.interfaces.DecodedJWT; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.Date; - -/** - * JWT 工具类 - * - * @author training-system - */ -@Slf4j -@Component -public class JwtUtils { - - @Value("${training.jwt.secret}") - private String secret; - - @Value("${training.jwt.expire}") - private long expire; - - /** - * 生成 JWT Token - * - * @param userId 用户ID - * @param username 用户名 - * @param role 用户角色 - * @param departmentId 部门ID - * @return JWT Token - */ - public String generateToken(Long userId, String username, String role, Long departmentId) { - Date now = new Date(); - Date expireDate = new Date(now.getTime() + expire); - - return JWT.create() - .withSubject(String.valueOf(userId)) - .withClaim("username", username) - .withClaim("role", role) - .withClaim("departmentId", departmentId) - .withIssuedAt(now) - .withExpiresAt(expireDate) - .sign(Algorithm.HMAC256(secret)); - } - - /** - * 验证 Token 并返回解码后的信息 - * - * @param token JWT Token - * @return 解码后的JWT,验证失败返回null - */ - public DecodedJWT verifyToken(String token) { - try { - JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build(); - return verifier.verify(token); - } catch (JWTVerificationException e) { - log.warn("Token验证失败: {}", e.getMessage()); - return null; - } - } - - /** - * 从 Token 中获取用户ID - * - * @param token JWT Token - * @return 用户ID,失败返回null - */ - public Long getUserId(String token) { - DecodedJWT jwt = verifyToken(token); - if (jwt != null) { - return Long.parseLong(jwt.getSubject()); - } - return null; - } - - /** - * 从 Token 中获取用户名 - * - * @param token JWT Token - * @return 用户名,失败返回null - */ - public String getUsername(String token) { - DecodedJWT jwt = verifyToken(token); - if (jwt != null) { - return jwt.getClaim("username").asString(); - } - return null; - } - - /** - * 从 Token 中获取用户角色 - * - * @param token JWT Token - * @return 用户角色,失败返回null - */ - public String getRole(String token) { - DecodedJWT jwt = verifyToken(token); - if (jwt != null) { - return jwt.getClaim("role").asString(); - } - return null; - } - - /** - * 判断 Token 是否过期 - * - * @param token JWT Token - * @return true-已过期,false-未过期 - */ - public boolean isTokenExpired(String token) { - DecodedJWT jwt = verifyToken(token); - if (jwt != null) { - return jwt.getExpiresAt().before(new Date()); - } - return true; - } -} diff --git a/training-system/src/main/java/com/sino/training/common/utils/SecurityUtils.java b/training-system/src/main/java/com/sino/training/common/utils/SecurityUtils.java deleted file mode 100644 index 2bcee24..0000000 --- a/training-system/src/main/java/com/sino/training/common/utils/SecurityUtils.java +++ /dev/null @@ -1,107 +0,0 @@ - -package com.sino.training.common.utils; - -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -/** - * 安全工具类 - * - * @author training-system - */ -public class SecurityUtils { - - private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); - - private SecurityUtils() { - } - - /** - * 密码加密 - * - * @param rawPassword 原始密码 - * @return 加密后的密码 - */ - public static String encryptPassword(String rawPassword) { - return PASSWORD_ENCODER.encode(rawPassword); - } - - /** - * 密码匹配 - * - * @param rawPassword 原始密码 - * @param encodedPassword 加密后的密码 - * @return 是否匹配 - */ - public static boolean matchPassword(String rawPassword, String encodedPassword) { - return PASSWORD_ENCODER.matches(rawPassword, encodedPassword); - } - - /** - * 获取当前登录用户ID - * - * @return 用户ID - */ - public static Long getCurrentUserId() { - UserContext context = UserContextHolder.getContext(); - return context != null ? context.getUserId() : null; - } - - /** - * 获取当前登录用户名 - * - * @return 用户名 - */ - public static String getCurrentUsername() { - UserContext context = UserContextHolder.getContext(); - return context != null ? context.getUsername() : null; - } - - /** - * 获取当前登录用户角色 - * - * @return 用户角色 - */ - public static String getCurrentRole() { - UserContext context = UserContextHolder.getContext(); - return context != null ? context.getRole() : null; - } - - /** - * 获取当前登录用户部门ID - * - * @return 部门ID - */ - public static Long getCurrentDepartmentId() { - UserContext context = UserContextHolder.getContext(); - return context != null ? context.getDepartmentId() : null; - } - - /** - * 判断当前用户是否为管理员 - * - * @return 是否为管理员 - */ - public static boolean isAdmin() { - return "ADMIN".equals(getCurrentRole()); - } - - /** - * 判断当前用户是否为讲师 - * - * @return 是否为讲师 - */ - public static boolean isLecturer() { - return "LECTURER".equals(getCurrentRole()); - } - - /** - * 判断当前用户是否为学员 - * - * @return 是否为学员 - */ - public static boolean isStudent() { - return "STUDENT".equals(getCurrentRole()); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/controller/AuthController.java b/training-system/src/main/java/com/sino/training/module/auth/controller/AuthController.java deleted file mode 100644 index 891e891..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/controller/AuthController.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sino.training.module.auth.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.auth.dto.LoginRequest; -import com.sino.training.module.auth.dto.LoginResponse; -import com.sino.training.module.auth.dto.UserInfoResponse; -import com.sino.training.module.auth.dto.WechatLoginRequest; -import com.sino.training.module.auth.service.AuthService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -/** - * 认证控制器 - * - * @author training-system - */ -@Tag(name = "认证管理", description = "登录、登出、用户信息等接口") -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class AuthController { - - private final AuthService authService; - - @Operation(summary = "账号密码登录") - @PostMapping("/login") - public Result login(@Valid @RequestBody LoginRequest request) { - return Result.success(authService.login(request)); - } - - @Operation(summary = "企业微信登录") - @PostMapping("/wechat/login") - public Result wechatLogin(@Valid @RequestBody WechatLoginRequest request) { - return Result.success(authService.wechatLogin(request)); - } - - @Operation(summary = "获取当前用户信息") - @GetMapping("/userinfo") - public Result getUserInfo() { - return Result.success(authService.getCurrentUserInfo()); - } - - @Operation(summary = "退出登录") - @PostMapping("/logout") - public Result logout() { - authService.logout(); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/dto/LoginRequest.java b/training-system/src/main/java/com/sino/training/module/auth/dto/LoginRequest.java deleted file mode 100644 index 1c048d9..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/dto/LoginRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sino.training.module.auth.dto; - -import jakarta.validation.constraints.NotBlank; -import lombok.Data; - -import java.io.Serializable; - -/** - * 登录请求DTO - * - * @author training-system - */ -@Data -public class LoginRequest implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户名 - */ - @NotBlank(message = "用户名不能为空") - private String username; - - /** - * 密码 - */ - @NotBlank(message = "密码不能为空") - private String password; -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/dto/LoginResponse.java b/training-system/src/main/java/com/sino/training/module/auth/dto/LoginResponse.java deleted file mode 100644 index 91c2a62..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/dto/LoginResponse.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.sino.training.module.auth.dto; - -import lombok.Data; - -import java.io.Serializable; - -/** - * 登录响应DTO - * - * @author training-system - */ -@Data -public class LoginResponse implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * JWT Token - */ - private String token; - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 真实姓名 - */ - private String realName; - - /** - * 用户角色 - */ - private String role; - - /** - * 头像URL - */ - private String avatar; - - /** - * 部门ID - */ - private Long departmentId; - - /** - * 部门名称 - */ - private String departmentName; -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/dto/UserInfoResponse.java b/training-system/src/main/java/com/sino/training/module/auth/dto/UserInfoResponse.java deleted file mode 100644 index 829f5d7..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/dto/UserInfoResponse.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.sino.training.module.auth.dto; - -import lombok.Data; - -import java.io.Serializable; - -/** - * 用户信息响应DTO - * - * @author training-system - */ -@Data -public class UserInfoResponse implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 真实姓名 - */ - private String realName; - - /** - * 手机号 - */ - private String phone; - - /** - * 头像URL - */ - private String avatar; - - /** - * 用户角色 - */ - private String role; - - /** - * 部门ID - */ - private Long departmentId; - - /** - * 部门名称 - */ - private String departmentName; - - /** - * 小组ID - */ - private Long groupId; - - /** - * 小组名称 - */ - private String groupName; -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/dto/WechatLoginRequest.java b/training-system/src/main/java/com/sino/training/module/auth/dto/WechatLoginRequest.java deleted file mode 100644 index f0ec0ba..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/dto/WechatLoginRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sino.training.module.auth.dto; - -import jakarta.validation.constraints.NotBlank; -import lombok.Data; - -import java.io.Serializable; - -/** - * 企业微信登录请求DTO - * - * @author training-system - */ -@Data -public class WechatLoginRequest implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 企业微信授权码 - */ - @NotBlank(message = "授权码不能为空") - private String code; -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/service/AuthService.java b/training-system/src/main/java/com/sino/training/module/auth/service/AuthService.java deleted file mode 100644 index 6957220..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/service/AuthService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sino.training.module.auth.service; - -import com.sino.training.module.auth.dto.LoginRequest; -import com.sino.training.module.auth.dto.LoginResponse; -import com.sino.training.module.auth.dto.UserInfoResponse; -import com.sino.training.module.auth.dto.WechatLoginRequest; - -/** - * 认证服务接口 - * - * @author training-system - */ -public interface AuthService { - - /** - * 账号密码登录 - * - * @param request 登录请求 - * @return 登录响应 - */ - LoginResponse login(LoginRequest request); - - /** - * 企业微信登录 - * - * @param request 企业微信登录请求 - * @return 登录响应 - */ - LoginResponse wechatLogin(WechatLoginRequest request); - - /** - * 获取当前用户信息 - * - * @return 用户信息 - */ - UserInfoResponse getCurrentUserInfo(); - - /** - * 退出登录 - */ - void logout(); -} diff --git a/training-system/src/main/java/com/sino/training/module/auth/service/impl/AuthServiceImpl.java b/training-system/src/main/java/com/sino/training/module/auth/service/impl/AuthServiceImpl.java deleted file mode 100644 index 2a5fd45..0000000 --- a/training-system/src/main/java/com/sino/training/module/auth/service/impl/AuthServiceImpl.java +++ /dev/null @@ -1,163 +0,0 @@ - -package com.sino.training.module.auth.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.UserStatus; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.common.utils.JwtUtils; -import com.sino.training.module.auth.dto.LoginRequest; -import com.sino.training.module.auth.dto.LoginResponse; -import com.sino.training.module.auth.dto.UserInfoResponse; -import com.sino.training.module.auth.dto.WechatLoginRequest; -import com.sino.training.module.auth.service.AuthService; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -/** - * 认证服务实现类 - * - * @author training-system - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class AuthServiceImpl implements AuthService { - - private final UserMapper userMapper; - private final DepartmentMapper departmentMapper; - private final GroupMapper groupMapper; - private final JwtUtils jwtUtils; - private final PasswordEncoder passwordEncoder; - - @Override - public LoginResponse login(LoginRequest request) { - // 查询用户 - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getUsername, request.getUsername()); - User user = userMapper.selectOne(queryWrapper); - - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - // 验证密码 - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - throw new BusinessException(ResultCode.PASSWORD_ERROR); - } - - // 检查用户状态 - if (user.getStatus() == UserStatus.DISABLED) { - throw new BusinessException(ResultCode.USER_DISABLED); - } - - return buildLoginResponse(user); - } - - @Override - public LoginResponse wechatLogin(WechatLoginRequest request) { - // TODO: 集成企业微信SDK,通过code换取用户信息 - // 这里先预留接口,后续实现企业微信OAuth流程 - log.info("企业微信登录,code: {}", request.getCode()); - throw new BusinessException(ResultCode.WECHAT_AUTH_FAILED, "企业微信登录功能暂未开放"); - } - - @Override - public UserInfoResponse getCurrentUserInfo() { - UserContext context = UserContextHolder.getContext(); - if (context == null || context.getUserId() == null) { - throw new BusinessException(ResultCode.UNAUTHORIZED); - } - - User user = userMapper.selectById(context.getUserId()); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - return buildUserInfoResponse(user); - } - - @Override - public void logout() { - // 清除用户上下文 - UserContextHolder.clearContext(); - // JWT是无状态的,客户端删除token即可 - log.info("用户退出登录"); - } - - /** - * 构建登录响应 - */ - private LoginResponse buildLoginResponse(User user) { - LoginResponse response = new LoginResponse(); - - // 生成Token(包含部门ID用于数据隔离) - String token = jwtUtils.generateToken( - user.getId(), - user.getUsername(), - user.getRole().name(), - user.getDepartmentId() - ); - response.setToken(token); - - response.setUserId(user.getId()); - response.setUsername(user.getUsername()); - response.setRealName(user.getRealName()); - response.setRole(user.getRole().name()); - response.setAvatar(user.getAvatar()); - response.setDepartmentId(user.getDepartmentId()); - - // 获取部门名称 - if (user.getDepartmentId() != null) { - Department department = departmentMapper.selectById(user.getDepartmentId()); - if (department != null) { - response.setDepartmentName(department.getName()); - } - } - - return response; - } - - /** - * 构建用户信息响应 - */ - private UserInfoResponse buildUserInfoResponse(User user) { - UserInfoResponse response = new UserInfoResponse(); - response.setUserId(user.getId()); - response.setUsername(user.getUsername()); - response.setRealName(user.getRealName()); - response.setPhone(user.getPhone()); - response.setAvatar(user.getAvatar()); - response.setRole(user.getRole().name()); - response.setDepartmentId(user.getDepartmentId()); - response.setGroupId(user.getGroupId()); - - // 获取部门名称 - if (user.getDepartmentId() != null) { - Department department = departmentMapper.selectById(user.getDepartmentId()); - if (department != null) { - response.setDepartmentName(department.getName()); - } - } - - // 获取小组名称 - if (user.getGroupId() != null) { - Group group = groupMapper.selectById(user.getGroupId()); - if (group != null) { - response.setGroupName(group.getName()); - } - } - - return response; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/controller/ExamController.java b/training-system/src/main/java/com/sino/training/module/exam/controller/ExamController.java deleted file mode 100644 index 71d3a49..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/controller/ExamController.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.sino.training.module.exam.controller; - -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.exam.dto.ExamDTO; -import com.sino.training.module.exam.dto.ExamQueryDTO; -import com.sino.training.module.exam.dto.SubmitExamDTO; -import com.sino.training.module.exam.service.ExamRecordService; -import com.sino.training.module.exam.service.ExamService; -import com.sino.training.module.exam.vo.ExamPaperVO; -import com.sino.training.module.exam.vo.ExamRecordVO; -import com.sino.training.module.exam.vo.ExamVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 考试管理控制器 - * - * @author training-system - */ -@Tag(name = "考试管理", description = "考试CRUD和在线答题接口") -@RestController -@RequestMapping("/api/exam") -@RequiredArgsConstructor -public class ExamController { - - private final ExamService examService; - private final ExamRecordService examRecordService; - - @Operation(summary = "分页查询考试列表") - @GetMapping("/page") - public Result> page(ExamQueryDTO queryDTO) { - return Result.success(examService.pageExams(queryDTO)); - } - - @Operation(summary = "获取考试详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(examService.getExamDetail(id)); - } - - @Operation(summary = "创建考试") - @PostMapping - public Result create(@Valid @RequestBody ExamDTO dto) { - return Result.success(examService.createExam(dto)); - } - - @Operation(summary = "更新考试") - @PutMapping - public Result update(@Valid @RequestBody ExamDTO dto) { - examService.updateExam(dto); - return Result.success(); - } - - @Operation(summary = "删除考试") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - examService.deleteExam(id); - return Result.success(); - } - - @Operation(summary = "获取我的考试列表(学员)") - @GetMapping("/my") - public Result> myExams() { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(examService.listMyExams(userId)); - } - - @Operation(summary = "开始考试") - @PostMapping("/{id}/start") - public Result startExam(@PathVariable Long id) { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(examRecordService.startExam(id, userId)); - } - - @Operation(summary = "获取考试试卷(答题中)") - @GetMapping("/record/{recordId}/paper") - public Result getExamPaper(@PathVariable Long recordId) { - return Result.success(examRecordService.getExamPaper(recordId)); - } - - @Operation(summary = "保存答案(自动保存)") - @PostMapping("/record/save") - public Result saveAnswers(@RequestBody SubmitExamDTO dto) { - examRecordService.saveAnswers(dto); - return Result.success(); - } - - @Operation(summary = "提交考试") - @PostMapping("/record/submit") - public Result submitExam(@Valid @RequestBody SubmitExamDTO dto) { - return Result.success(examRecordService.submitExam(dto)); - } - - @Operation(summary = "获取考试记录详情(含答案解析)") - @GetMapping("/record/{recordId}") - public Result getRecordDetail(@PathVariable Long recordId) { - return Result.success(examRecordService.getRecordDetail(recordId)); - } - - @Operation(summary = "获取我的考试记录") - @GetMapping("/{examId}/my-records") - public Result> myRecords(@PathVariable Long examId) { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(examRecordService.listUserRecords(examId, userId)); - } - - @Operation(summary = "获取考试的所有记录(管理员/讲师)") - @GetMapping("/{examId}/records") - public Result> examRecords(@PathVariable Long examId) { - return Result.success(examRecordService.listExamRecords(examId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/controller/PaperController.java b/training-system/src/main/java/com/sino/training/module/exam/controller/PaperController.java deleted file mode 100644 index bfe9c8c..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/controller/PaperController.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.sino.training.module.exam.controller; - -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.exam.dto.AutoPaperDTO; -import com.sino.training.module.exam.dto.PaperDTO; -import com.sino.training.module.exam.dto.PaperQueryDTO; -import com.sino.training.module.exam.service.PaperService; -import com.sino.training.module.exam.vo.PaperVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 试卷管理控制器 - * - * @author training-system - */ -@Tag(name = "试卷管理", description = "试卷CRUD接口") -@RestController -@RequestMapping("/api/exam/paper") -@RequiredArgsConstructor -public class PaperController { - - private final PaperService paperService; - - @Operation(summary = "分页查询试卷列表") - @GetMapping("/page") - public Result> page(PaperQueryDTO queryDTO) { - return Result.success(paperService.pagePapers(queryDTO)); - } - - @Operation(summary = "获取已发布试卷列表(当前用户部门)") - @GetMapping("/list") - public Result> list() { - return Result.success(paperService.listPublishedPapers()); - } - - @Operation(summary = "获取试卷详情(包含题目)") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(paperService.getPaperDetail(id)); - } - - @Operation(summary = "创建试卷(手动组卷)") - @PostMapping - public Result create(@Valid @RequestBody PaperDTO dto) { - return Result.success(paperService.createPaper(dto)); - } - - @Operation(summary = "创建试卷(自动组卷)") - @PostMapping("/auto") - public Result createAuto(@Valid @RequestBody AutoPaperDTO dto) { - return Result.success(paperService.createAutoPaper(dto)); - } - - @Operation(summary = "更新试卷") - @PutMapping - public Result update(@Valid @RequestBody PaperDTO dto) { - paperService.updatePaper(dto); - return Result.success(); - } - - @Operation(summary = "删除试卷") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - paperService.deletePaper(id); - return Result.success(); - } - - @Operation(summary = "发布试卷") - @PutMapping("/{id}/publish") - public Result publish(@PathVariable Long id) { - paperService.publishPaper(id); - return Result.success(); - } - - @Operation(summary = "下架试卷") - @PutMapping("/{id}/offline") - public Result offline(@PathVariable Long id) { - paperService.offlinePaper(id); - return Result.success(); - } - - @Operation(summary = "根据部门获取已发布试卷") - @GetMapping("/published/department/{departmentId}") - public Result> listByDepartment(@PathVariable Long departmentId) { - return Result.success(paperService.listPublishedByDepartment(departmentId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionCategoryController.java b/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionCategoryController.java deleted file mode 100644 index 2f3e604..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionCategoryController.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sino.training.module.exam.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.exam.dto.QuestionCategoryDTO; -import com.sino.training.module.exam.service.QuestionCategoryService; -import com.sino.training.module.exam.vo.QuestionCategoryVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 题目分类控制器 - * - * @author training-system - */ -@Tag(name = "题目分类", description = "题目分类CRUD接口") -@RestController -@RequestMapping("/api/exam/question-category") -@RequiredArgsConstructor -public class QuestionCategoryController { - - private final QuestionCategoryService questionCategoryService; - - @Operation(summary = "获取分类树") - @GetMapping("/tree") - public Result> tree( - @Parameter(description = "部门ID") @RequestParam(required = false) Long departmentId) { - return Result.success(questionCategoryService.getCategoryTree(departmentId)); - } - - @Operation(summary = "获取分类列表") - @GetMapping("/list") - public Result> list( - @Parameter(description = "部门ID") @RequestParam(required = false) Long departmentId) { - return Result.success(questionCategoryService.listCategories(departmentId)); - } - - @Operation(summary = "获取分类详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(questionCategoryService.getCategoryDetail(id)); - } - - @Operation(summary = "创建分类") - @PostMapping - public Result create(@Valid @RequestBody QuestionCategoryDTO dto) { - return Result.success(questionCategoryService.createCategory(dto)); - } - - @Operation(summary = "更新分类") - @PutMapping - public Result update(@Valid @RequestBody QuestionCategoryDTO dto) { - questionCategoryService.updateCategory(dto); - return Result.success(); - } - - @Operation(summary = "删除分类") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - questionCategoryService.deleteCategory(id); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionController.java b/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionController.java deleted file mode 100644 index 779f73b..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/controller/QuestionController.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.sino.training.module.exam.controller; - -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.exam.dto.QuestionDTO; -import com.sino.training.module.exam.dto.QuestionQueryDTO; -import com.sino.training.module.exam.service.QuestionService; -import com.sino.training.module.exam.vo.QuestionVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 题目管理控制器 - * - * @author training-system - */ -@Tag(name = "题目管理", description = "题目CRUD接口") -@RestController -@RequestMapping("/api/exam/question") -@RequiredArgsConstructor -public class QuestionController { - - private final QuestionService questionService; - - @Operation(summary = "分页查询题目列表") - @GetMapping("/page") - public Result> page(QuestionQueryDTO queryDTO) { - return Result.success(questionService.pageQuestions(queryDTO)); - } - - @Operation(summary = "获取题目详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(questionService.getQuestionDetail(id)); - } - - @Operation(summary = "创建题目") - @PostMapping - public Result create(@Valid @RequestBody QuestionDTO dto) { - return Result.success(questionService.createQuestion(dto)); - } - - @Operation(summary = "更新题目") - @PutMapping - public Result update(@Valid @RequestBody QuestionDTO dto) { - questionService.updateQuestion(dto); - return Result.success(); - } - - @Operation(summary = "删除题目") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - questionService.deleteQuestion(id); - return Result.success(); - } - - @Operation(summary = "发布题目") - @PutMapping("/{id}/publish") - public Result publish(@PathVariable Long id) { - questionService.publishQuestion(id); - return Result.success(); - } - - @Operation(summary = "下架题目") - @PutMapping("/{id}/offline") - public Result offline(@PathVariable Long id) { - questionService.offlineQuestion(id); - return Result.success(); - } - - @Operation(summary = "根据分类获取已发布题目") - @GetMapping("/published/category/{categoryId}") - public Result> listByCategory(@PathVariable Long categoryId) { - return Result.success(questionService.listByCategory(categoryId)); - } - - @Operation(summary = "根据部门获取已发布题目") - @GetMapping("/published/department/{departmentId}") - public Result> listByDepartment( - @PathVariable Long departmentId, - @Parameter(description = "题目类型") @RequestParam(required = false) String type) { - return Result.success(questionService.listPublishedByDepartment(departmentId, type)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/AutoPaperDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/AutoPaperDTO.java deleted file mode 100644 index b14abb6..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/AutoPaperDTO.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sino.training.module.exam.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 自动组卷DTO - * - * @author training-system - */ -@Data -public class AutoPaperDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - @NotBlank(message = "试卷标题不能为空") - private String title; - - private String description; - - @NotNull(message = "考试时长不能为空") - private Integer duration; - - @NotNull(message = "及格分数不能为空") - private Integer passScore; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - /** - * 单选题数量 - */ - private Integer singleCount = 0; - - /** - * 单选题分值 - */ - private Integer singleScore = 0; - - /** - * 多选题数量 - */ - private Integer multipleCount = 0; - - /** - * 多选题分值 - */ - private Integer multipleScore = 0; - - /** - * 判断题数量 - */ - private Integer judgeCount = 0; - - /** - * 判断题分值 - */ - private Integer judgeScore = 0; - - /** - * 分类ID(从指定分类抽题,可选) - */ - private Long categoryId; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/ExamDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/ExamDTO.java deleted file mode 100644 index cae0b76..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/ExamDTO.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.sino.training.module.exam.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 考试DTO - * - * @author training-system - */ -@Data -public class ExamDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - - @NotBlank(message = "考试标题不能为空") - private String title; - - private String description; - - @NotNull(message = "关联试卷不能为空") - private Long paperId; - - @NotNull(message = "开始时间不能为空") - private LocalDateTime startTime; - - @NotNull(message = "结束时间不能为空") - private LocalDateTime endTime; - - /** - * 允许补考次数(默认为0,表示只能考1次) - */ - private Integer retryTimes; - - /** - * 允许迟到分钟数 - */ - private Integer lateMinutes; - - /** - * 交卷后显示答案 - */ - private Boolean showAnswer; - - /** - * 题目顺序随机 - */ - private Boolean shuffleQuestion; - - /** - * 选项顺序随机 - */ - private Boolean shuffleOption; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - /** - * 参与人员ID列表(前端传入) - */ - private List participantIds; - - /** - * 考试对象列表(兼容旧格式) - */ - private List targets; - - @Data - public static class ExamTargetDTO implements Serializable { - /** - * DEPARTMENT/GROUP/USER - */ - private String targetType; - private Long targetId; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/ExamQueryDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/ExamQueryDTO.java deleted file mode 100644 index d9fab05..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/ExamQueryDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sino.training.module.exam.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 考试查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class ExamQueryDTO extends PageQuery { - - private String keyword; - private Long departmentId; - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/PaperDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/PaperDTO.java deleted file mode 100644 index b4516e1..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/PaperDTO.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sino.training.module.exam.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; -import java.util.List; - -/** - * 试卷DTO - * - * @author training-system - */ -@Data -public class PaperDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - - @NotBlank(message = "试卷标题不能为空") - private String title; - - private String description; - - @NotNull(message = "总分不能为空") - private Integer totalScore; - - @NotNull(message = "考试时长不能为空") - private Integer duration; - - @NotNull(message = "及格分数不能为空") - private Integer passScore; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - /** - * 试卷题目列表 - */ - private List questions; - - @Data - public static class PaperQuestionDTO implements Serializable { - private Long questionId; - private Integer score; - private Integer sortOrder; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/PaperQueryDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/PaperQueryDTO.java deleted file mode 100644 index ba4766e..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/PaperQueryDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sino.training.module.exam.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 试卷查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class PaperQueryDTO extends PageQuery { - - private String keyword; - private Long departmentId; - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionCategoryDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionCategoryDTO.java deleted file mode 100644 index d036ec4..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionCategoryDTO.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.sino.training.module.exam.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 题目分类DTO - * - * @author training-system - */ -@Data -public class QuestionCategoryDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - - @NotBlank(message = "分类名称不能为空") - private String name; - - private Long parentId; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionDTO.java deleted file mode 100644 index 6806344..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionDTO.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.sino.training.module.exam.dto; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.IOException; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - -/** - * 题目DTO - * - * @author training-system - */ -@Data -public class QuestionDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - - @NotBlank(message = "题目类型不能为空") - private String type; - - @NotBlank(message = "题干不能为空") - private String content; - - /** - * 选项列表(支持数组或JSON字符串格式) - */ - @JsonDeserialize(using = OptionListDeserializer.class) - private List options; - - @NotBlank(message = "正确答案不能为空") - private String answer; - - @NotBlank(message = "答案解析不能为空") - private String analysis; - - @NotNull(message = "所属分类不能为空") - private Long categoryId; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - @Data - public static class OptionDTO implements Serializable { - private static final long serialVersionUID = 1L; - private String key; - private String value; - } - - /** - * 选项列表反序列化器 - * 支持三种格式: - * 1. 对象数组: [{"key":"A","value":"选项1"}] - * 2. 字符串数组: ["选项1","选项2"] - 自动生成key为A,B,C,D... - * 3. JSON字符串: "[...]" - 先解析字符串再按上述规则处理 - */ - public static class OptionListDeserializer extends JsonDeserializer> { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @Override - public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - JsonNode node; - if (p.currentToken() == JsonToken.VALUE_STRING) { - // 如果是字符串,先解析为JsonNode - String jsonString = p.getValueAsString(); - node = MAPPER.readTree(jsonString); - } else { - node = p.getCodec().readTree(p); - } - - if (node == null || !node.isArray()) { - return null; - } - - List options = new ArrayList<>(); - int index = 0; - for (JsonNode item : node) { - OptionDTO option = new OptionDTO(); - if (item.isObject()) { - // 对象格式: {"key":"A","value":"选项"} - option.setKey(item.has("key") ? item.get("key").asText() : generateKey(index)); - option.setValue(item.has("value") ? item.get("value").asText() : ""); - } else if (item.isTextual()) { - // 字符串格式: "选项内容" - 自动生成key - option.setKey(generateKey(index)); - option.setValue(item.asText()); - } - options.add(option); - index++; - } - return options; - } - - /** - * 根据索引生成选项key (A, B, C, D, ...) - */ - private String generateKey(int index) { - return String.valueOf((char) ('A' + index)); - } - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionQueryDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionQueryDTO.java deleted file mode 100644 index d7d2fd6..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/QuestionQueryDTO.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sino.training.module.exam.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 题目查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class QuestionQueryDTO extends PageQuery { - - private String keyword; - private Long categoryId; - private Long departmentId; - private String type; - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/dto/SubmitExamDTO.java b/training-system/src/main/java/com/sino/training/module/exam/dto/SubmitExamDTO.java deleted file mode 100644 index 5e1a656..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/dto/SubmitExamDTO.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sino.training.module.exam.dto; - -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; -import java.util.List; - -/** - * 提交考试DTO - * - * @author training-system - */ -@Data -public class SubmitExamDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - @NotNull(message = "考试记录ID不能为空") - private Long recordId; - - /** - * 答案列表 - */ - private List answers; - - @Data - public static class AnswerDTO implements Serializable { - private Long questionId; - private String answer; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/Exam.java b/training-system/src/main/java/com/sino/training/module/exam/entity/Exam.java deleted file mode 100644 index a3a65fb..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/Exam.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.ExamStatus; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 考试实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_exam") -public class Exam extends BaseEntity { - - /** - * 考试标题 - */ - private String title; - - /** - * 考试描述 - */ - private String description; - - /** - * 关联试卷ID - */ - private Long paperId; - - /** - * 开始时间 - */ - private LocalDateTime startTime; - - /** - * 结束时间 - */ - private LocalDateTime endTime; - - /** - * 最大考试次数 - */ - private Integer maxAttempts; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 考试状态 - */ - private ExamStatus status; - - /** - * 创建人ID - */ - private Long creatorId; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/ExamRecord.java b/training-system/src/main/java/com/sino/training/module/exam/entity/ExamRecord.java deleted file mode 100644 index d5140ae..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/ExamRecord.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 考试记录实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_exam_record") -public class ExamRecord extends BaseEntity { - - /** - * 考试ID - */ - private Long examId; - - /** - * 用户ID - */ - private Long userId; - - /** - * 第几次考试 - */ - private Integer attemptNo; - - /** - * 得分 - */ - private Integer score; - - /** - * 是否通过 - */ - private Boolean passed; - - /** - * 开始时间 - */ - private LocalDateTime startTime; - - /** - * 提交时间 - */ - private LocalDateTime submitTime; - - /** - * 答题详情(JSON格式) - * 格式: [{"questionId":1,"answer":"A","correct":true,"score":5},...] - */ - private String answers; - - /** - * 考试状态:0-进行中,1-已提交 - */ - private Integer status; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/ExamTarget.java b/training-system/src/main/java/com/sino/training/module/exam/entity/ExamTarget.java deleted file mode 100644 index eea9467..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/ExamTarget.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.TargetType; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 考试对象实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_exam_target") -public class ExamTarget extends BaseEntity { - - /** - * 考试ID - */ - private Long examId; - - /** - * 目标类型 - */ - private TargetType targetType; - - /** - * 目标ID(部门ID/小组ID/用户ID) - */ - private Long targetId; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java b/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java deleted file mode 100644 index 837b9f4..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.ContentStatus; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 试卷实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_paper") -public class Paper extends BaseEntity { - - /** - * 试卷标题 - */ - private String title; - - /** - * 试卷描述 - */ - private String description; - - /** - * 总分 - */ - private Integer totalScore; - - /** - * 考试时长(分钟) - */ - private Integer duration; - - /** - * 及格分数 - */ - private Integer passScore; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 状态 - */ - private ContentStatus status; - - /** - * 创建人ID - */ - private Long creatorId; - - /** - * 发布时间 - */ - private LocalDateTime publishTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/PaperQuestion.java b/training-system/src/main/java/com/sino/training/module/exam/entity/PaperQuestion.java deleted file mode 100644 index 8125705..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/PaperQuestion.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 试卷题目关联实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_paper_question") -public class PaperQuestion extends BaseEntity { - - /** - * 试卷ID - */ - private Long paperId; - - /** - * 题目ID - */ - private Long questionId; - - /** - * 该题分值 - */ - private Integer score; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java b/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java deleted file mode 100644 index 43f4509..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.ContentStatus; -import com.sino.training.common.enums.QuestionType; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 题目实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_question") -public class Question extends BaseEntity { - - /** - * 题目类型 - */ - private QuestionType type; - - /** - * 题干内容 - */ - private String content; - - /** - * 选项(JSON格式,判断题为空) - * 格式: [{"key":"A","value":"选项内容"},...] - */ - private String options; - - /** - * 正确答案 - * 单选题: "A" - * 多选题: "A,B,C" - * 判断题: "TRUE" 或 "FALSE" - */ - private String answer; - - /** - * 答案解析 - */ - private String analysis; - - /** - * 所属分类ID - */ - private Long categoryId; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 状态 - */ - private ContentStatus status; - - /** - * 创建人ID - */ - private Long creatorId; - - /** - * 发布时间 - */ - private LocalDateTime publishTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/entity/QuestionCategory.java b/training-system/src/main/java/com/sino/training/module/exam/entity/QuestionCategory.java deleted file mode 100644 index 3e0b5d7..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/entity/QuestionCategory.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.exam.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 题目分类实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("ex_question_category") -public class QuestionCategory extends BaseEntity { - - /** - * 分类名称 - */ - private String name; - - /** - * 父分类ID(0表示顶级分类) - */ - private Long parentId; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamMapper.java deleted file mode 100644 index c78f09c..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.Exam; -import org.apache.ibatis.annotations.Mapper; - -/** - * 考试 Mapper - * - * @author training-system - */ -@Mapper -public interface ExamMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamRecordMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamRecordMapper.java deleted file mode 100644 index 71a707c..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamRecordMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.ExamRecord; -import org.apache.ibatis.annotations.Mapper; - -/** - * 考试记录 Mapper - * - * @author training-system - */ -@Mapper -public interface ExamRecordMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamTargetMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamTargetMapper.java deleted file mode 100644 index 06c5f6a..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/ExamTargetMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.ExamTarget; -import org.apache.ibatis.annotations.Mapper; - -/** - * 考试对象 Mapper - * - * @author training-system - */ -@Mapper -public interface ExamTargetMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperMapper.java deleted file mode 100644 index 5b1c424..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.Paper; -import org.apache.ibatis.annotations.Mapper; - -/** - * 试卷 Mapper - * - * @author training-system - */ -@Mapper -public interface PaperMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperQuestionMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperQuestionMapper.java deleted file mode 100644 index ef011f6..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/PaperQuestionMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.PaperQuestion; -import org.apache.ibatis.annotations.Mapper; - -/** - * 试卷题目关联 Mapper - * - * @author training-system - */ -@Mapper -public interface PaperQuestionMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionCategoryMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionCategoryMapper.java deleted file mode 100644 index d7bec4f..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionCategoryMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.QuestionCategory; -import org.apache.ibatis.annotations.Mapper; - -/** - * 题目分类 Mapper - * - * @author training-system - */ -@Mapper -public interface QuestionCategoryMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionMapper.java b/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionMapper.java deleted file mode 100644 index ccca6b0..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/mapper/QuestionMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.exam.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.exam.entity.Question; -import org.apache.ibatis.annotations.Mapper; - -/** - * 题目 Mapper - * - * @author training-system - */ -@Mapper -public interface QuestionMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/ExamRecordService.java b/training-system/src/main/java/com/sino/training/module/exam/service/ExamRecordService.java deleted file mode 100644 index ec3f22b..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/ExamRecordService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.exam.dto.SubmitExamDTO; -import com.sino.training.module.exam.entity.ExamRecord; -import com.sino.training.module.exam.vo.ExamPaperVO; -import com.sino.training.module.exam.vo.ExamRecordVO; - -import java.util.List; - -/** - * 考试记录服务接口 - * - * @author training-system - */ -public interface ExamRecordService extends IService { - - /** - * 开始考试 - */ - ExamPaperVO startExam(Long examId, Long userId); - - /** - * 获取考试试卷(答题中) - */ - ExamPaperVO getExamPaper(Long recordId); - - /** - * 保存答案(自动保存) - */ - void saveAnswers(SubmitExamDTO dto); - - /** - * 提交考试 - */ - ExamRecordVO submitExam(SubmitExamDTO dto); - - /** - * 获取考试记录详情(含答案解析) - */ - ExamRecordVO getRecordDetail(Long recordId); - - /** - * 获取用户的考试记录列表 - */ - List listUserRecords(Long examId, Long userId); - - /** - * 获取考试的所有记录(讲师/管理员查看) - */ - List listExamRecords(Long examId); - - /** - * 获取用户的考试次数 - */ - int getUserAttemptCount(Long examId, Long userId); - - /** - * 获取用户的最高分 - */ - Integer getUserHighestScore(Long examId, Long userId); -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/ExamService.java b/training-system/src/main/java/com/sino/training/module/exam/service/ExamService.java deleted file mode 100644 index 514815e..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/ExamService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.common.result.PageResult; -import com.sino.training.module.exam.dto.ExamDTO; -import com.sino.training.module.exam.dto.ExamQueryDTO; -import com.sino.training.module.exam.entity.Exam; -import com.sino.training.module.exam.vo.ExamVO; - -import java.util.List; - -/** - * 考试服务接口 - * - * @author training-system - */ -public interface ExamService extends IService { - - PageResult pageExams(ExamQueryDTO queryDTO); - - ExamVO getExamDetail(Long id); - - Long createExam(ExamDTO dto); - - void updateExam(ExamDTO dto); - - void deleteExam(Long id); - - /** - * 获取用户可参加的考试列表 - */ - List listMyExams(Long userId); - - /** - * 检查用户是否可以参加考试 - */ - boolean canTakeExam(Long examId, Long userId); -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/PaperService.java b/training-system/src/main/java/com/sino/training/module/exam/service/PaperService.java deleted file mode 100644 index 5e4e59e..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/PaperService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.common.result.PageResult; -import com.sino.training.module.exam.dto.AutoPaperDTO; -import com.sino.training.module.exam.dto.PaperDTO; -import com.sino.training.module.exam.dto.PaperQueryDTO; -import com.sino.training.module.exam.entity.Paper; -import com.sino.training.module.exam.vo.PaperVO; - -import java.util.List; - -/** - * 试卷服务接口 - * - * @author training-system - */ -public interface PaperService extends IService { - - PageResult pagePapers(PaperQueryDTO queryDTO); - - PaperVO getPaperDetail(Long id); - - Long createPaper(PaperDTO dto); - - Long createAutoPaper(AutoPaperDTO dto); - - void updatePaper(PaperDTO dto); - - void deletePaper(Long id); - - void publishPaper(Long id); - - void offlinePaper(Long id); - - List listPublishedByDepartment(Long departmentId); - - /** - * 获取当前用户部门的已发布试卷列表 - * - * @return 试卷列表 - */ - List listPublishedPapers(); -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/QuestionCategoryService.java b/training-system/src/main/java/com/sino/training/module/exam/service/QuestionCategoryService.java deleted file mode 100644 index 0b0e0d6..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/QuestionCategoryService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.exam.dto.QuestionCategoryDTO; -import com.sino.training.module.exam.entity.QuestionCategory; -import com.sino.training.module.exam.vo.QuestionCategoryVO; - -import java.util.List; - -/** - * 题目分类服务接口 - * - * @author training-system - */ -public interface QuestionCategoryService extends IService { - - List getCategoryTree(Long departmentId); - - List listCategories(Long departmentId); - - QuestionCategoryVO getCategoryDetail(Long id); - - Long createCategory(QuestionCategoryDTO dto); - - void updateCategory(QuestionCategoryDTO dto); - - void deleteCategory(Long id); -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/QuestionService.java b/training-system/src/main/java/com/sino/training/module/exam/service/QuestionService.java deleted file mode 100644 index 289f509..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/QuestionService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.common.result.PageResult; -import com.sino.training.module.exam.dto.QuestionDTO; -import com.sino.training.module.exam.dto.QuestionQueryDTO; -import com.sino.training.module.exam.entity.Question; -import com.sino.training.module.exam.vo.QuestionVO; - -import java.util.List; - -/** - * 题目服务接口 - * - * @author training-system - */ -public interface QuestionService extends IService { - - PageResult pageQuestions(QuestionQueryDTO queryDTO); - - QuestionVO getQuestionDetail(Long id); - - Long createQuestion(QuestionDTO dto); - - void updateQuestion(QuestionDTO dto); - - void deleteQuestion(Long id); - - void publishQuestion(Long id); - - void offlineQuestion(Long id); - - List listByCategory(Long categoryId); - - List listPublishedByDepartment(Long departmentId, String type); -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.java b/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.java deleted file mode 100644 index 5d1cb7b..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.java +++ /dev/null @@ -1,424 +0,0 @@ -package com.sino.training.module.exam.service.impl; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.enums.ExamStatus; -import com.sino.training.common.enums.QuestionType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.SubmitExamDTO; -import com.sino.training.module.exam.entity.*; -import com.sino.training.module.exam.mapper.*; -import com.sino.training.module.exam.service.ExamRecordService; -import com.sino.training.module.exam.service.ExamService; -import com.sino.training.module.exam.vo.ExamPaperVO; -import com.sino.training.module.exam.vo.ExamRecordVO; -import com.sino.training.module.exam.vo.QuestionVO; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.UserMapper; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.io.Serializable; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * 考试记录服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class ExamRecordServiceImpl extends ServiceImpl implements ExamRecordService { - - private final ExamMapper examMapper; - private final ExamService examService; - private final PaperMapper paperMapper; - private final PaperQuestionMapper paperQuestionMapper; - private final QuestionMapper questionMapper; - private final UserMapper userMapper; - - @Override - @Transactional(rollbackFor = Exception.class) - public ExamPaperVO startExam(Long examId, Long userId) { - // 检查是否可以参加考试 - if (!examService.canTakeExam(examId, userId)) { - Exam exam = examMapper.selectById(examId); - if (exam == null) { - throw new BusinessException(ResultCode.EXAM_NOT_FOUND); - } - if (exam.getStatus() == ExamStatus.NOT_STARTED) { - throw new BusinessException(ResultCode.EXAM_NOT_STARTED); - } - if (exam.getStatus() == ExamStatus.ENDED) { - throw new BusinessException(ResultCode.EXAM_ENDED); - } - throw new BusinessException(ResultCode.EXAM_ATTEMPTS_EXCEEDED); - } - - // 检查是否有未提交的考试记录 - LambdaQueryWrapper existQuery = new LambdaQueryWrapper<>(); - existQuery.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 0); // 进行中 - ExamRecord existRecord = getOne(existQuery); - - if (existRecord != null) { - // 返回已存在的考试 - return buildExamPaper(existRecord); - } - - // 创建新的考试记录 - int attemptNo = getUserAttemptCount(examId, userId) + 1; - - ExamRecord record = new ExamRecord(); - record.setExamId(examId); - record.setUserId(userId); - record.setAttemptNo(attemptNo); - record.setStartTime(LocalDateTime.now()); - record.setStatus(0); // 进行中 - - save(record); - - return buildExamPaper(record); - } - - @Override - public ExamPaperVO getExamPaper(Long recordId) { - ExamRecord record = getById(recordId); - if (record == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "考试记录不存在"); - } - - if (record.getStatus() == 1) { - throw new BusinessException(ResultCode.BAD_REQUEST, "考试已提交"); - } - - return buildExamPaper(record); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void saveAnswers(SubmitExamDTO dto) { - ExamRecord record = getById(dto.getRecordId()); - if (record == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "考试记录不存在"); - } - - if (record.getStatus() == 1) { - throw new BusinessException(ResultCode.BAD_REQUEST, "考试已提交,无法保存"); - } - - // 保存答案(不判分) - if (dto.getAnswers() != null) { - record.setAnswers(JSONUtil.toJsonStr(dto.getAnswers())); - updateById(record); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public ExamRecordVO submitExam(SubmitExamDTO dto) { - ExamRecord record = getById(dto.getRecordId()); - if (record == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "考试记录不存在"); - } - - if (record.getStatus() == 1) { - throw new BusinessException(ResultCode.BAD_REQUEST, "考试已提交"); - } - - Exam exam = examMapper.selectById(record.getExamId()); - Paper paper = paperMapper.selectById(exam.getPaperId()); - - // 获取试卷题目 - LambdaQueryWrapper pqQuery = new LambdaQueryWrapper<>(); - pqQuery.eq(PaperQuestion::getPaperId, paper.getId()) - .orderByAsc(PaperQuestion::getSortOrder); - List paperQuestions = paperQuestionMapper.selectList(pqQuery); - - // 构建答案Map - Map answerMap = new HashMap<>(); - if (dto.getAnswers() != null) { - for (SubmitExamDTO.AnswerDTO answer : dto.getAnswers()) { - answerMap.put(answer.getQuestionId(), answer.getAnswer()); - } - } - - // 判分 - int totalScore = 0; - List answerDetails = new ArrayList<>(); - - for (PaperQuestion pq : paperQuestions) { - Question question = questionMapper.selectById(pq.getQuestionId()); - String userAnswer = answerMap.get(pq.getQuestionId()); - boolean correct = checkAnswer(question, userAnswer); - int earnedScore = correct ? pq.getScore() : 0; - totalScore += earnedScore; - - AnswerDetail detail = new AnswerDetail(); - detail.setQuestionId(pq.getQuestionId()); - detail.setAnswer(userAnswer); - detail.setCorrect(correct); - detail.setScore(earnedScore); - answerDetails.add(detail); - } - - // 更新记录 - record.setScore(totalScore); - record.setPassed(totalScore >= paper.getPassScore()); - record.setSubmitTime(LocalDateTime.now()); - record.setAnswers(JSONUtil.toJsonStr(answerDetails)); - record.setStatus(1); // 已提交 - - updateById(record); - - return getRecordDetail(record.getId()); - } - - @Override - public ExamRecordVO getRecordDetail(Long recordId) { - ExamRecord record = getById(recordId); - if (record == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "考试记录不存在"); - } - - return convertToVO(record, true); - } - - @Override - public List listUserRecords(Long examId, Long userId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1) - .orderByDesc(ExamRecord::getSubmitTime); - List records = list(query); - - List voList = new ArrayList<>(); - for (ExamRecord record : records) { - voList.add(convertToVO(record, false)); - } - return voList; - } - - @Override - public List listExamRecords(Long examId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getStatus, 1) - .orderByDesc(ExamRecord::getSubmitTime); - List records = list(query); - - List voList = new ArrayList<>(); - for (ExamRecord record : records) { - voList.add(convertToVO(record, false)); - } - return voList; - } - - @Override - public int getUserAttemptCount(Long examId, Long userId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1); - return (int) count(query); - } - - @Override - public Integer getUserHighestScore(Long examId, Long userId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1) - .orderByDesc(ExamRecord::getScore) - .last("LIMIT 1"); - ExamRecord record = getOne(query); - return record != null ? record.getScore() : null; - } - - private ExamPaperVO buildExamPaper(ExamRecord record) { - Exam exam = examMapper.selectById(record.getExamId()); - Paper paper = paperMapper.selectById(exam.getPaperId()); - - ExamPaperVO vo = new ExamPaperVO(); - vo.setRecordId(record.getId()); - vo.setExamId(exam.getId()); - vo.setExamTitle(exam.getTitle()); - vo.setDuration(paper.getDuration()); - vo.setTotalScore(paper.getTotalScore()); - - // 计算剩余时间 - LocalDateTime deadline = record.getStartTime().plusMinutes(paper.getDuration()); - long remainingSeconds = Duration.between(LocalDateTime.now(), deadline).getSeconds(); - vo.setRemainingSeconds(Math.max(0, remainingSeconds)); - - // 获取题目列表 - LambdaQueryWrapper pqQuery = new LambdaQueryWrapper<>(); - pqQuery.eq(PaperQuestion::getPaperId, paper.getId()) - .orderByAsc(PaperQuestion::getSortOrder); - List paperQuestions = paperQuestionMapper.selectList(pqQuery); - - // 解析已保存的答案 - Map savedAnswers = new HashMap<>(); - if (StrUtil.isNotBlank(record.getAnswers())) { - try { - List details = JSONUtil.toList(record.getAnswers(), AnswerDetail.class); - for (AnswerDetail detail : details) { - savedAnswers.put(detail.getQuestionId(), detail.getAnswer()); - } - } catch (Exception ignored) { - } - } - - List questions = new ArrayList<>(); - for (PaperQuestion pq : paperQuestions) { - Question question = questionMapper.selectById(pq.getQuestionId()); - - ExamPaperVO.ExamQuestionVO qVO = new ExamPaperVO.ExamQuestionVO(); - qVO.setQuestionId(question.getId()); - qVO.setSortOrder(pq.getSortOrder()); - qVO.setType(question.getType().name()); - qVO.setTypeName(getTypeName(question.getType())); - qVO.setContent(question.getContent()); - qVO.setScore(pq.getScore()); - qVO.setUserAnswer(savedAnswers.get(question.getId())); - - // 解析选项(不包含答案) - if (StrUtil.isNotBlank(question.getOptions())) { - List options = JSONUtil.toList(question.getOptions(), QuestionVO.OptionVO.class); - qVO.setOptions(options); - } - - questions.add(qVO); - } - - vo.setQuestions(questions); - return vo; - } - - private boolean checkAnswer(Question question, String userAnswer) { - if (StrUtil.isBlank(userAnswer)) { - return false; - } - - String correctAnswer = question.getAnswer(); - if (StrUtil.isBlank(correctAnswer)) { - return false; - } - - // 多选题需要比较答案集合 - if (question.getType() == QuestionType.MULTIPLE) { - String[] userParts = userAnswer.split(","); - String[] correctParts = correctAnswer.split(","); - - if (userParts.length != correctParts.length) { - return false; - } - - java.util.Set userSet = new java.util.HashSet<>(); - java.util.Set correctSet = new java.util.HashSet<>(); - - for (String s : userParts) { - userSet.add(s.trim().toUpperCase()); - } - for (String s : correctParts) { - correctSet.add(s.trim().toUpperCase()); - } - - return userSet.equals(correctSet); - } - - // 单选题和判断题直接比较 - return userAnswer.trim().equalsIgnoreCase(correctAnswer.trim()); - } - - private ExamRecordVO convertToVO(ExamRecord record, boolean includeAnswerDetails) { - ExamRecordVO vo = new ExamRecordVO(); - vo.setId(record.getId()); - vo.setExamId(record.getExamId()); - vo.setUserId(record.getUserId()); - vo.setAttemptNo(record.getAttemptNo()); - vo.setScore(record.getScore()); - vo.setPassed(record.getPassed()); - vo.setStartTime(record.getStartTime()); - vo.setSubmitTime(record.getSubmitTime()); - vo.setStatus(record.getStatus()); - vo.setStatusName(record.getStatus() == 0 ? "进行中" : "已提交"); - - // 获取考试信息 - Exam exam = examMapper.selectById(record.getExamId()); - if (exam != null) { - vo.setExamTitle(exam.getTitle()); - Paper paper = paperMapper.selectById(exam.getPaperId()); - if (paper != null) { - vo.setTotalScore(paper.getTotalScore()); - vo.setPassScore(paper.getPassScore()); - } - } - - // 获取用户姓名 - User user = userMapper.selectById(record.getUserId()); - if (user != null) { - vo.setUserName(user.getRealName()); - } - - // 答题详情 - if (includeAnswerDetails && StrUtil.isNotBlank(record.getAnswers()) && record.getStatus() == 1) { - List answerDetails = JSONUtil.toList(record.getAnswers(), AnswerDetail.class); - - List detailVOs = new ArrayList<>(); - for (AnswerDetail detail : answerDetails) { - Question question = questionMapper.selectById(detail.getQuestionId()); - if (question == null) continue; - - ExamRecordVO.AnswerDetailVO dVO = new ExamRecordVO.AnswerDetailVO(); - dVO.setQuestionId(question.getId()); - dVO.setQuestionContent(question.getContent()); - dVO.setQuestionType(question.getType().name()); - dVO.setUserAnswer(detail.getAnswer()); - dVO.setCorrectAnswer(question.getAnswer()); - dVO.setCorrect(detail.getCorrect()); - dVO.setScore(detail.getScore()); - dVO.setEarnedScore(detail.getCorrect() ? detail.getScore() : 0); - dVO.setAnalysis(question.getAnalysis()); - - if (StrUtil.isNotBlank(question.getOptions())) { - List options = JSONUtil.toList(question.getOptions(), QuestionVO.OptionVO.class); - dVO.setOptions(options); - } - - detailVOs.add(dVO); - } - vo.setAnswerDetails(detailVOs); - } - - return vo; - } - - private String getTypeName(QuestionType type) { - return switch (type) { - case SINGLE -> "单选题"; - case MULTIPLE -> "多选题"; - case JUDGE -> "判断题"; - }; - } - - @Data - private static class AnswerDetail implements Serializable { - private Long questionId; - private String answer; - private Boolean correct; - private Integer score; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamServiceImpl.java b/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamServiceImpl.java deleted file mode 100644 index fd2c88f..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/impl/ExamServiceImpl.java +++ /dev/null @@ -1,462 +0,0 @@ -package com.sino.training.module.exam.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.ExamStatus; -import com.sino.training.common.enums.TargetType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.ExamDTO; -import com.sino.training.module.exam.dto.ExamQueryDTO; -import com.sino.training.module.exam.entity.Exam; -import com.sino.training.module.exam.entity.ExamRecord; -import com.sino.training.module.exam.entity.ExamTarget; -import com.sino.training.module.exam.entity.Paper; -import com.sino.training.module.exam.mapper.ExamMapper; -import com.sino.training.module.exam.mapper.ExamRecordMapper; -import com.sino.training.module.exam.mapper.ExamTargetMapper; -import com.sino.training.module.exam.mapper.PaperMapper; -import com.sino.training.module.exam.service.ExamService; -import com.sino.training.module.exam.vo.ExamVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 考试服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class ExamServiceImpl extends ServiceImpl implements ExamService { - - private final ExamTargetMapper examTargetMapper; - private final ExamRecordMapper examRecordMapper; - private final PaperMapper paperMapper; - private final DepartmentMapper departmentMapper; - private final GroupMapper groupMapper; - private final UserMapper userMapper; - - @Override - public PageResult pageExams(ExamQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.like(Exam::getTitle, queryDTO.getKeyword()); - } - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(Exam::getDepartmentId, queryDTO.getDepartmentId()); - } - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(Exam::getStatus, ExamStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(Exam::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (Exam exam : result.getRecords()) { - voList.add(convertToVO(exam, true)); - } - - return PageResult.of(result, voList); - } - - @Override - public ExamVO getExamDetail(Long id) { - Exam exam = getById(id); - if (exam == null) { - throw new BusinessException(ResultCode.EXAM_NOT_FOUND); - } - return convertToVO(exam, true); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createExam(ExamDTO dto) { - Paper paper = paperMapper.selectById(dto.getPaperId()); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - - // 确定部门ID:管理员使用前端传值,其他用户强制使用自己的部门 - UserContext userContext = UserContextHolder.getContext(); - Long departmentId; - if ("ADMIN".equals(userContext.getRole())) { - departmentId = dto.getDepartmentId(); - if (departmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "管理员必须指定所属部门"); - } - } else { - departmentId = userContext.getDepartmentId(); - } - - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - if (dto.getStartTime().isAfter(dto.getEndTime())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "开始时间不能晚于结束时间"); - } - - // retryTimes 表示补考次数,maxAttempts = retryTimes + 1 - int maxAttempts = (dto.getRetryTimes() != null ? dto.getRetryTimes() : 0) + 1; - - Exam exam = new Exam(); - exam.setTitle(dto.getTitle()); - exam.setDescription(dto.getDescription()); - exam.setPaperId(dto.getPaperId()); - exam.setStartTime(dto.getStartTime()); - exam.setEndTime(dto.getEndTime()); - exam.setMaxAttempts(maxAttempts); - exam.setDepartmentId(departmentId); - exam.setCreatorId(userContext.getUserId()); - exam.setStatus(calculateExamStatus(dto.getStartTime(), dto.getEndTime())); - - save(exam); - - // 保存考试对象:优先使用 participantIds,兼容 targets - if (dto.getParticipantIds() != null && !dto.getParticipantIds().isEmpty()) { - for (Long userId : dto.getParticipantIds()) { - ExamTarget target = new ExamTarget(); - target.setExamId(exam.getId()); - target.setTargetType(TargetType.USER); - target.setTargetId(userId); - examTargetMapper.insert(target); - } - } else if (dto.getTargets() != null && !dto.getTargets().isEmpty()) { - for (ExamDTO.ExamTargetDTO targetDTO : dto.getTargets()) { - ExamTarget target = new ExamTarget(); - target.setExamId(exam.getId()); - target.setTargetType(TargetType.valueOf(targetDTO.getTargetType())); - target.setTargetId(targetDTO.getTargetId()); - examTargetMapper.insert(target); - } - } - - return exam.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateExam(ExamDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "考试ID不能为空"); - } - - Exam exam = getById(dto.getId()); - if (exam == null) { - throw new BusinessException(ResultCode.EXAM_NOT_FOUND); - } - - if (dto.getStartTime().isAfter(dto.getEndTime())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "开始时间不能晚于结束时间"); - } - - // retryTimes 表示补考次数,maxAttempts = retryTimes + 1 - int maxAttempts = (dto.getRetryTimes() != null ? dto.getRetryTimes() : 0) + 1; - - exam.setTitle(dto.getTitle()); - exam.setDescription(dto.getDescription()); - exam.setPaperId(dto.getPaperId()); - exam.setStartTime(dto.getStartTime()); - exam.setEndTime(dto.getEndTime()); - exam.setMaxAttempts(maxAttempts); - exam.setStatus(calculateExamStatus(dto.getStartTime(), dto.getEndTime())); - - updateById(exam); - - // 更新考试对象:优先使用 participantIds,兼容 targets - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.eq(ExamTarget::getExamId, exam.getId()); - examTargetMapper.delete(deleteWrapper); - - if (dto.getParticipantIds() != null && !dto.getParticipantIds().isEmpty()) { - for (Long userId : dto.getParticipantIds()) { - ExamTarget target = new ExamTarget(); - target.setExamId(exam.getId()); - target.setTargetType(TargetType.USER); - target.setTargetId(userId); - examTargetMapper.insert(target); - } - } else if (dto.getTargets() != null && !dto.getTargets().isEmpty()) { - for (ExamDTO.ExamTargetDTO targetDTO : dto.getTargets()) { - ExamTarget target = new ExamTarget(); - target.setExamId(exam.getId()); - target.setTargetType(TargetType.valueOf(targetDTO.getTargetType())); - target.setTargetId(targetDTO.getTargetId()); - examTargetMapper.insert(target); - } - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteExam(Long id) { - Exam exam = getById(id); - if (exam == null) { - throw new BusinessException(ResultCode.EXAM_NOT_FOUND); - } - - // 检查是否有考试记录 - LambdaQueryWrapper recordQuery = new LambdaQueryWrapper<>(); - recordQuery.eq(ExamRecord::getExamId, id); - if (examRecordMapper.selectCount(recordQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该考试已有考试记录,无法删除"); - } - - // 删除考试对象 - LambdaQueryWrapper targetQuery = new LambdaQueryWrapper<>(); - targetQuery.eq(ExamTarget::getExamId, id); - examTargetMapper.delete(targetQuery); - - removeById(id); - } - - @Override - public List listMyExams(Long userId) { - User user = userMapper.selectById(userId); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - // 查询所有进行中的考试 - LambdaQueryWrapper examQuery = new LambdaQueryWrapper<>(); - examQuery.eq(Exam::getStatus, ExamStatus.IN_PROGRESS); - List allExams = list(examQuery); - - List result = new ArrayList<>(); - for (Exam exam : allExams) { - if (isUserTargeted(exam.getId(), user)) { - ExamVO vo = convertToVO(exam, false); - // 填充用户的考试次数和最高分 - vo.setCurrentAttempts(getUserAttemptCount(exam.getId(), userId)); - vo.setHighestScore(getUserHighestScore(exam.getId(), userId)); - result.add(vo); - } - } - - return result; - } - - @Override - public boolean canTakeExam(Long examId, Long userId) { - Exam exam = getById(examId); - if (exam == null) { - return false; - } - - // 检查考试状态 - if (exam.getStatus() != ExamStatus.IN_PROGRESS) { - return false; - } - - // 检查时间窗口 - LocalDateTime now = LocalDateTime.now(); - if (now.isBefore(exam.getStartTime()) || now.isAfter(exam.getEndTime())) { - return false; - } - - // 检查用户是否在考试对象中 - User user = userMapper.selectById(userId); - if (user == null || !isUserTargeted(examId, user)) { - return false; - } - - // 检查考试次数 - int attempts = getUserAttemptCount(examId, userId); - return attempts < exam.getMaxAttempts(); - } - - private boolean isUserTargeted(Long examId, User user) { - LambdaQueryWrapper targetQuery = new LambdaQueryWrapper<>(); - targetQuery.eq(ExamTarget::getExamId, examId); - List targets = examTargetMapper.selectList(targetQuery); - - for (ExamTarget target : targets) { - switch (target.getTargetType()) { - case DEPARTMENT: - if (target.getTargetId().equals(user.getDepartmentId())) { - return true; - } - break; - case GROUP: - if (target.getTargetId().equals(user.getGroupId())) { - return true; - } - break; - case USER: - if (target.getTargetId().equals(user.getId())) { - return true; - } - break; - } - } - - return false; - } - - private int getUserAttemptCount(Long examId, Long userId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1); // 已提交的 - return examRecordMapper.selectCount(query).intValue(); - } - - private Integer getUserHighestScore(Long examId, Long userId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1) - .orderByDesc(ExamRecord::getScore) - .last("LIMIT 1"); - ExamRecord record = examRecordMapper.selectOne(query); - return record != null ? record.getScore() : null; - } - - private ExamStatus calculateExamStatus(LocalDateTime startTime, LocalDateTime endTime) { - LocalDateTime now = LocalDateTime.now(); - if (now.isBefore(startTime)) { - return ExamStatus.NOT_STARTED; - } else if (now.isAfter(endTime)) { - return ExamStatus.ENDED; - } else { - return ExamStatus.IN_PROGRESS; - } - } - - private ExamVO convertToVO(Exam exam, boolean includeTargets) { - ExamVO vo = new ExamVO(); - vo.setId(exam.getId()); - vo.setTitle(exam.getTitle()); - vo.setDescription(exam.getDescription()); - vo.setPaperId(exam.getPaperId()); - vo.setStartTime(exam.getStartTime()); - vo.setEndTime(exam.getEndTime()); - vo.setMaxAttempts(exam.getMaxAttempts()); - vo.setDepartmentId(exam.getDepartmentId()); - vo.setStatus(exam.getStatus().name()); - vo.setStatusName(getStatusName(exam.getStatus())); - vo.setCreatorId(exam.getCreatorId()); - vo.setCreateTime(exam.getCreateTime()); - - // 获取试卷信息 - if (exam.getPaperId() != null) { - Paper paper = paperMapper.selectById(exam.getPaperId()); - if (paper != null) { - vo.setPaperTitle(paper.getTitle()); - vo.setPaperTotalScore(paper.getTotalScore()); - vo.setPaperDuration(paper.getDuration()); - vo.setPaperPassScore(paper.getPassScore()); - } - } - - // 获取部门名称 - if (exam.getDepartmentId() != null) { - Department department = departmentMapper.selectById(exam.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取创建人姓名 - if (exam.getCreatorId() != null) { - User user = userMapper.selectById(exam.getCreatorId()); - if (user != null) { - vo.setCreatorName(user.getRealName()); - } - } - - // 获取考试对象 - if (includeTargets) { - LambdaQueryWrapper targetQuery = new LambdaQueryWrapper<>(); - targetQuery.eq(ExamTarget::getExamId, exam.getId()); - List targets = examTargetMapper.selectList(targetQuery); - - List targetVOList = new ArrayList<>(); - for (ExamTarget target : targets) { - ExamVO.ExamTargetVO targetVO = new ExamVO.ExamTargetVO(); - targetVO.setId(target.getId()); - targetVO.setTargetType(target.getTargetType().name()); - targetVO.setTargetTypeName(getTargetTypeName(target.getTargetType())); - targetVO.setTargetId(target.getTargetId()); - targetVO.setTargetName(getTargetName(target.getTargetType(), target.getTargetId())); - targetVOList.add(targetVO); - } - vo.setTargets(targetVOList); - - // 设置应考人数(targets数量) - vo.setTotalCount(targets.size()); - } - - // 统计已交卷人数和及格人数 - LambdaQueryWrapper submitQuery = new LambdaQueryWrapper<>(); - submitQuery.eq(ExamRecord::getExamId, exam.getId()) - .eq(ExamRecord::getStatus, 1); // 已提交 - long submitCount = examRecordMapper.selectCount(submitQuery); - vo.setSubmitCount((int) submitCount); - - LambdaQueryWrapper passQuery = new LambdaQueryWrapper<>(); - passQuery.eq(ExamRecord::getExamId, exam.getId()) - .eq(ExamRecord::getStatus, 1) - .ge(ExamRecord::getScore, vo.getPaperPassScore() != null ? vo.getPaperPassScore() : 0); - long passCount = examRecordMapper.selectCount(passQuery); - vo.setPassCount((int) passCount); - - return vo; - } - - private String getStatusName(ExamStatus status) { - return switch (status) { - case NOT_STARTED -> "未开始"; - case IN_PROGRESS -> "进行中"; - case ENDED -> "已结束"; - }; - } - - private String getTargetTypeName(TargetType type) { - return switch (type) { - case DEPARTMENT -> "部门"; - case GROUP -> "小组"; - case USER -> "个人"; - }; - } - - private String getTargetName(TargetType type, Long targetId) { - return switch (type) { - case DEPARTMENT -> { - Department dept = departmentMapper.selectById(targetId); - yield dept != null ? dept.getName() : ""; - } - case GROUP -> { - Group group = groupMapper.selectById(targetId); - yield group != null ? group.getName() : ""; - } - case USER -> { - User user = userMapper.selectById(targetId); - yield user != null ? user.getRealName() : ""; - } - }; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/impl/PaperServiceImpl.java b/training-system/src/main/java/com/sino/training/module/exam/service/impl/PaperServiceImpl.java deleted file mode 100644 index 2736767..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/impl/PaperServiceImpl.java +++ /dev/null @@ -1,432 +0,0 @@ -package com.sino.training.module.exam.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.ContentStatus; -import com.sino.training.common.enums.QuestionType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.AutoPaperDTO; -import com.sino.training.module.exam.dto.PaperDTO; -import com.sino.training.module.exam.dto.PaperQueryDTO; -import com.sino.training.module.exam.entity.Exam; -import com.sino.training.module.exam.entity.Paper; -import com.sino.training.module.exam.entity.PaperQuestion; -import com.sino.training.module.exam.entity.Question; -import com.sino.training.module.exam.mapper.ExamMapper; -import com.sino.training.module.exam.mapper.PaperMapper; -import com.sino.training.module.exam.mapper.PaperQuestionMapper; -import com.sino.training.module.exam.mapper.QuestionMapper; -import com.sino.training.module.exam.service.PaperService; -import com.sino.training.module.exam.service.QuestionService; -import com.sino.training.module.exam.vo.PaperVO; -import com.sino.training.module.exam.vo.QuestionVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.UserMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * 试卷服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class PaperServiceImpl extends ServiceImpl implements PaperService { - - private final PaperQuestionMapper paperQuestionMapper; - private final QuestionMapper questionMapper; - private final QuestionService questionService; - private final DepartmentMapper departmentMapper; - private final UserMapper userMapper; - private final ExamMapper examMapper; - - @Override - public PageResult pagePapers(PaperQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.like(Paper::getTitle, queryDTO.getKeyword()); - } - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(Paper::getDepartmentId, queryDTO.getDepartmentId()); - } - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(Paper::getStatus, ContentStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(Paper::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (Paper paper : result.getRecords()) { - voList.add(convertToVO(paper, false)); - } - - return PageResult.of(result, voList); - } - - @Override - public PaperVO getPaperDetail(Long id) { - Paper paper = getById(id); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - return convertToVO(paper, true); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createPaper(PaperDTO dto) { - // 获取当前用户上下文 - UserContext userContext = UserContextHolder.getContext(); - String currentRole = userContext.getRole(); - - // 确定部门ID:管理员使用前端传值,其他用户强制使用自己的部门 - Long departmentId; - if ("ADMIN".equals(currentRole)) { - departmentId = dto.getDepartmentId(); - if (departmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "管理员必须指定所属部门"); - } - } else { - departmentId = userContext.getDepartmentId(); - } - - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - Paper paper = new Paper(); - paper.setTitle(dto.getTitle()); - paper.setDescription(dto.getDescription()); - paper.setTotalScore(dto.getTotalScore()); - paper.setDuration(dto.getDuration()); - paper.setPassScore(dto.getPassScore()); - paper.setDepartmentId(departmentId); - paper.setCreatorId(userContext.getUserId()); - paper.setStatus(ContentStatus.DRAFT); - - save(paper); - - // 保存试卷题目关联 - if (dto.getQuestions() != null && !dto.getQuestions().isEmpty()) { - int sortOrder = 1; - for (PaperDTO.PaperQuestionDTO questionDTO : dto.getQuestions()) { - PaperQuestion pq = new PaperQuestion(); - pq.setPaperId(paper.getId()); - pq.setQuestionId(questionDTO.getQuestionId()); - pq.setScore(questionDTO.getScore()); - pq.setSortOrder(questionDTO.getSortOrder() != null ? questionDTO.getSortOrder() : sortOrder++); - paperQuestionMapper.insert(pq); - } - } - - return paper.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createAutoPaper(AutoPaperDTO dto) { - Department department = departmentMapper.selectById(dto.getDepartmentId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 计算总分 - int totalScore = dto.getSingleCount() * dto.getSingleScore() - + dto.getMultipleCount() * dto.getMultipleScore() - + dto.getJudgeCount() * dto.getJudgeScore(); - - Paper paper = new Paper(); - paper.setTitle(dto.getTitle()); - paper.setDescription(dto.getDescription()); - paper.setTotalScore(totalScore); - paper.setDuration(dto.getDuration()); - paper.setPassScore(dto.getPassScore()); - paper.setDepartmentId(dto.getDepartmentId()); - paper.setStatus(ContentStatus.DRAFT); - - save(paper); - - int sortOrder = 1; - - // 随机抽取单选题 - if (dto.getSingleCount() > 0) { - List singleQuestions = getRandomQuestions(dto.getDepartmentId(), dto.getCategoryId(), - QuestionType.SINGLE, dto.getSingleCount()); - for (Question question : singleQuestions) { - PaperQuestion pq = new PaperQuestion(); - pq.setPaperId(paper.getId()); - pq.setQuestionId(question.getId()); - pq.setScore(dto.getSingleScore()); - pq.setSortOrder(sortOrder++); - paperQuestionMapper.insert(pq); - } - } - - // 随机抽取多选题 - if (dto.getMultipleCount() > 0) { - List multipleQuestions = getRandomQuestions(dto.getDepartmentId(), dto.getCategoryId(), - QuestionType.MULTIPLE, dto.getMultipleCount()); - for (Question question : multipleQuestions) { - PaperQuestion pq = new PaperQuestion(); - pq.setPaperId(paper.getId()); - pq.setQuestionId(question.getId()); - pq.setScore(dto.getMultipleScore()); - pq.setSortOrder(sortOrder++); - paperQuestionMapper.insert(pq); - } - } - - // 随机抽取判断题 - if (dto.getJudgeCount() > 0) { - List judgeQuestions = getRandomQuestions(dto.getDepartmentId(), dto.getCategoryId(), - QuestionType.JUDGE, dto.getJudgeCount()); - for (Question question : judgeQuestions) { - PaperQuestion pq = new PaperQuestion(); - pq.setPaperId(paper.getId()); - pq.setQuestionId(question.getId()); - pq.setScore(dto.getJudgeScore()); - pq.setSortOrder(sortOrder++); - paperQuestionMapper.insert(pq); - } - } - - return paper.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updatePaper(PaperDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "试卷ID不能为空"); - } - - Paper paper = getById(dto.getId()); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - - paper.setTitle(dto.getTitle()); - paper.setDescription(dto.getDescription()); - paper.setTotalScore(dto.getTotalScore()); - paper.setDuration(dto.getDuration()); - paper.setPassScore(dto.getPassScore()); - - updateById(paper); - - // 更新试卷题目关联 - if (dto.getQuestions() != null) { - // 删除原有关联 - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.eq(PaperQuestion::getPaperId, paper.getId()); - paperQuestionMapper.delete(deleteWrapper); - - // 重新添加 - int sortOrder = 1; - for (PaperDTO.PaperQuestionDTO questionDTO : dto.getQuestions()) { - PaperQuestion pq = new PaperQuestion(); - pq.setPaperId(paper.getId()); - pq.setQuestionId(questionDTO.getQuestionId()); - pq.setScore(questionDTO.getScore()); - pq.setSortOrder(questionDTO.getSortOrder() != null ? questionDTO.getSortOrder() : sortOrder++); - paperQuestionMapper.insert(pq); - } - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deletePaper(Long id) { - Paper paper = getById(id); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - - // 检查是否被考试引用 - LambdaQueryWrapper examQuery = new LambdaQueryWrapper<>(); - examQuery.eq(Exam::getPaperId, id); - if (examMapper.selectCount(examQuery) > 0) { - throw new BusinessException(ResultCode.PAPER_REFERENCED); - } - - // 删除试卷题目关联 - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.eq(PaperQuestion::getPaperId, id); - paperQuestionMapper.delete(deleteWrapper); - - removeById(id); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void publishPaper(Long id) { - Paper paper = getById(id); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - - // 检查是否有题目 - LambdaQueryWrapper countWrapper = new LambdaQueryWrapper<>(); - countWrapper.eq(PaperQuestion::getPaperId, id); - if (paperQuestionMapper.selectCount(countWrapper) == 0) { - throw new BusinessException(ResultCode.BAD_REQUEST, "试卷没有题目,无法发布"); - } - - paper.setStatus(ContentStatus.PUBLISHED); - paper.setPublishTime(LocalDateTime.now()); - updateById(paper); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void offlinePaper(Long id) { - Paper paper = getById(id); - if (paper == null) { - throw new BusinessException(ResultCode.PAPER_NOT_FOUND); - } - - paper.setStatus(ContentStatus.OFFLINE); - updateById(paper); - } - - @Override - public List listPublishedByDepartment(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Paper::getDepartmentId, departmentId) - .eq(Paper::getStatus, ContentStatus.PUBLISHED) - .orderByDesc(Paper::getPublishTime); - List list = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Paper paper : list) { - voList.add(convertToVO(paper, false)); - } - return voList; - } - - @Override - public List listPublishedPapers() { - UserContext userContext = UserContextHolder.getContext(); - Long departmentId = userContext.getDepartmentId(); - return listPublishedByDepartment(departmentId); - } - - private List getRandomQuestions(Long departmentId, Long categoryId, QuestionType type, int count) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Question::getDepartmentId, departmentId) - .eq(Question::getType, type) - .eq(Question::getStatus, ContentStatus.PUBLISHED); - if (categoryId != null) { - queryWrapper.eq(Question::getCategoryId, categoryId); - } - - List allQuestions = questionMapper.selectList(queryWrapper); - - if (allQuestions.size() < count) { - throw new BusinessException(ResultCode.BAD_REQUEST, - "题库中" + getTypeName(type) + "数量不足,需要" + count + "题,实际" + allQuestions.size() + "题"); - } - - // 随机打乱并取前count个 - Collections.shuffle(allQuestions); - return allQuestions.subList(0, count); - } - - private PaperVO convertToVO(Paper paper, boolean includeQuestions) { - PaperVO vo = new PaperVO(); - vo.setId(paper.getId()); - vo.setTitle(paper.getTitle()); - vo.setDescription(paper.getDescription()); - vo.setTotalScore(paper.getTotalScore()); - vo.setDuration(paper.getDuration()); - vo.setPassScore(paper.getPassScore()); - vo.setDepartmentId(paper.getDepartmentId()); - vo.setStatus(paper.getStatus().name()); - vo.setStatusName(getStatusName(paper.getStatus())); - vo.setCreatorId(paper.getCreatorId()); - vo.setCreateTime(paper.getCreateTime()); - vo.setPublishTime(paper.getPublishTime()); - - // 获取部门名称 - if (paper.getDepartmentId() != null) { - Department department = departmentMapper.selectById(paper.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取创建人姓名 - if (paper.getCreatorId() != null) { - User user = userMapper.selectById(paper.getCreatorId()); - if (user != null) { - vo.setCreatorName(user.getRealName()); - } - } - - // 获取题目数量 - LambdaQueryWrapper countWrapper = new LambdaQueryWrapper<>(); - countWrapper.eq(PaperQuestion::getPaperId, paper.getId()); - vo.setQuestionCount(paperQuestionMapper.selectCount(countWrapper).intValue()); - - // 获取题目列表 - if (includeQuestions) { - LambdaQueryWrapper pqWrapper = new LambdaQueryWrapper<>(); - pqWrapper.eq(PaperQuestion::getPaperId, paper.getId()) - .orderByAsc(PaperQuestion::getSortOrder); - List paperQuestions = paperQuestionMapper.selectList(pqWrapper); - - List questionVOList = new ArrayList<>(); - for (PaperQuestion pq : paperQuestions) { - PaperVO.PaperQuestionVO pqVO = new PaperVO.PaperQuestionVO(); - pqVO.setId(pq.getId()); - pqVO.setQuestionId(pq.getQuestionId()); - pqVO.setScore(pq.getScore()); - pqVO.setSortOrder(pq.getSortOrder()); - - QuestionVO questionVO = questionService.getQuestionDetail(pq.getQuestionId()); - pqVO.setQuestion(questionVO); - - questionVOList.add(pqVO); - } - vo.setQuestions(questionVOList); - } - - return vo; - } - - private String getTypeName(QuestionType type) { - return switch (type) { - case SINGLE -> "单选题"; - case MULTIPLE -> "多选题"; - case JUDGE -> "判断题"; - }; - } - - private String getStatusName(ContentStatus status) { - return switch (status) { - case DRAFT -> "草稿"; - case PUBLISHED -> "已发布"; - case OFFLINE -> "已下架"; - }; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.java b/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.java deleted file mode 100644 index 23a0c1d..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.java +++ /dev/null @@ -1,296 +0,0 @@ -package com.sino.training.module.exam.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.QuestionCategoryDTO; -import com.sino.training.module.exam.entity.Question; -import com.sino.training.module.exam.entity.QuestionCategory; -import com.sino.training.module.exam.mapper.QuestionCategoryMapper; -import com.sino.training.module.exam.mapper.QuestionMapper; -import com.sino.training.module.exam.service.QuestionCategoryService; -import com.sino.training.module.exam.vo.QuestionCategoryVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.common.utils.SecurityUtils; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * 题目分类服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class QuestionCategoryServiceImpl extends ServiceImpl implements QuestionCategoryService { - - private final DepartmentMapper departmentMapper; - private final QuestionMapper questionMapper; - - @Override - public List getCategoryTree(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(QuestionCategory::getDepartmentId, departmentId); - } - queryWrapper.orderByAsc(QuestionCategory::getSortOrder, QuestionCategory::getId); - List categories = list(queryWrapper); - - Map questionCountMap = getQuestionCountMap(departmentId); - - List voList = categories.stream() - .map(c -> convertToVO(c, questionCountMap)) - .collect(Collectors.toList()); - - return buildTree(voList); - } - - @Override - public List listCategories(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(QuestionCategory::getDepartmentId, departmentId); - } - queryWrapper.orderByAsc(QuestionCategory::getSortOrder, QuestionCategory::getId); - List categories = list(queryWrapper); - - Map questionCountMap = getQuestionCountMap(departmentId); - - List voList = new ArrayList<>(); - for (QuestionCategory category : categories) { - voList.add(convertToVO(category, questionCountMap)); - } - return voList; - } - - @Override - public QuestionCategoryVO getCategoryDetail(Long id) { - QuestionCategory category = getById(id); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - return convertToVO(category, null); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createCategory(QuestionCategoryDTO dto) { - // 部门权限校验:非管理员只能操作本部门 - Long departmentId = checkDepartmentPermission(dto.getDepartmentId()); - - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - if (dto.getParentId() != null && dto.getParentId() > 0) { - QuestionCategory parent = getById(dto.getParentId()); - if (parent == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND, "父分类不存在"); - } - // 检查父分类是否属于同一部门 - if (!parent.getDepartmentId().equals(departmentId)) { - throw new BusinessException(ResultCode.BAD_REQUEST, "父分类不属于该部门"); - } - } - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(QuestionCategory::getDepartmentId, departmentId) - .eq(QuestionCategory::getParentId, dto.getParentId() != null ? dto.getParentId() : 0L) - .eq(QuestionCategory::getName, dto.getName()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该级别下已存在同名分类"); - } - - QuestionCategory category = new QuestionCategory(); - category.setName(dto.getName()); - category.setParentId(dto.getParentId() != null ? dto.getParentId() : 0L); - category.setDepartmentId(departmentId); - category.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); - save(category); - return category.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateCategory(QuestionCategoryDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "分类ID不能为空"); - } - - QuestionCategory category = getById(dto.getId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 部门权限校验:非管理员只能操作本部门的分类 - if (!SecurityUtils.isAdmin()) { - Long currentDepartmentId = SecurityUtils.getCurrentDepartmentId(); - if (currentDepartmentId == null || !currentDepartmentId.equals(category.getDepartmentId())) { - throw new BusinessException(ResultCode.FORBIDDEN, "无权操作其他部门的题目分类"); - } - } - - Long parentId = dto.getParentId() != null ? dto.getParentId() : 0L; - if (parentId.equals(dto.getId())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "不能将自己设为父分类"); - } - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(QuestionCategory::getDepartmentId, category.getDepartmentId()) - .eq(QuestionCategory::getParentId, parentId) - .eq(QuestionCategory::getName, dto.getName()) - .ne(QuestionCategory::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该级别下已存在同名分类"); - } - - category.setName(dto.getName()); - category.setParentId(parentId); - if (dto.getSortOrder() != null) { - category.setSortOrder(dto.getSortOrder()); - } - updateById(category); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteCategory(Long id) { - QuestionCategory category = getById(id); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 部门权限校验:非管理员只能操作本部门的分类 - if (!SecurityUtils.isAdmin()) { - Long currentDepartmentId = SecurityUtils.getCurrentDepartmentId(); - if (currentDepartmentId == null || !currentDepartmentId.equals(category.getDepartmentId())) { - throw new BusinessException(ResultCode.FORBIDDEN, "无权操作其他部门的题目分类"); - } - } - - LambdaQueryWrapper childQuery = new LambdaQueryWrapper<>(); - childQuery.eq(QuestionCategory::getParentId, id); - if (count(childQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在子分类,无法删除"); - } - - LambdaQueryWrapper questionQuery = new LambdaQueryWrapper<>(); - questionQuery.eq(Question::getCategoryId, id); - if (questionMapper.selectCount(questionQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在题目,无法删除"); - } - - removeById(id); - } - - private QuestionCategoryVO convertToVO(QuestionCategory category, Map questionCountMap) { - QuestionCategoryVO vo = new QuestionCategoryVO(); - vo.setId(category.getId()); - vo.setName(category.getName()); - vo.setParentId(category.getParentId()); - vo.setDepartmentId(category.getDepartmentId()); - vo.setSortOrder(category.getSortOrder()); - vo.setCreateTime(category.getCreateTime()); - - if (category.getParentId() != null && category.getParentId() > 0) { - QuestionCategory parent = getById(category.getParentId()); - if (parent != null) { - vo.setParentName(parent.getName()); - } - } - - if (category.getDepartmentId() != null) { - Department department = departmentMapper.selectById(category.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - if (questionCountMap != null) { - vo.setQuestionCount(questionCountMap.getOrDefault(category.getId(), 0)); - } - - return vo; - } - - private List buildTree(List allCategories) { - List roots = allCategories.stream() - .filter(c -> c.getParentId() == null || c.getParentId() == 0) - .collect(Collectors.toList()); - - for (QuestionCategoryVO root : roots) { - setChildren(root, allCategories); - } - - return roots; - } - - private void setChildren(QuestionCategoryVO parent, List allCategories) { - List children = allCategories.stream() - .filter(c -> parent.getId().equals(c.getParentId())) - .collect(Collectors.toList()); - - if (!children.isEmpty()) { - parent.setChildren(children); - for (QuestionCategoryVO child : children) { - setChildren(child, allCategories); - } - } else { - parent.setChildren(new ArrayList<>()); - } - } - - private Map getQuestionCountMap(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(Question::getDepartmentId, departmentId); - } - List questionList = questionMapper.selectList(queryWrapper); - - return questionList.stream() - .filter(q -> q.getCategoryId() != null) - .collect(Collectors.groupingBy( - Question::getCategoryId, - Collectors.collectingAndThen(Collectors.counting(), Long::intValue) - )); - } - - /** - * 检查部门权限 - * 管理员必须指定部门ID,讲师只能操作本部门 - * - * @param targetDepartmentId 目标部门ID - * @return 实际使用的部门ID - */ - private Long checkDepartmentPermission(Long targetDepartmentId) { - // 管理员必须指定部门 - if (SecurityUtils.isAdmin()) { - if (targetDepartmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "请选择所属部门"); - } - return targetDepartmentId; - } - - // 非管理员(讲师)只能操作本部门 - Long currentDepartmentId = SecurityUtils.getCurrentDepartmentId(); - if (currentDepartmentId == null) { - throw new BusinessException(ResultCode.FORBIDDEN, "无法获取当前用户部门信息"); - } - - // 如果指定了部门ID,必须是本部门 - if (targetDepartmentId != null && !targetDepartmentId.equals(currentDepartmentId)) { - throw new BusinessException(ResultCode.FORBIDDEN, "无权操作其他部门的题目分类"); - } - - return currentDepartmentId; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionServiceImpl.java b/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionServiceImpl.java deleted file mode 100644 index 13d2408..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/service/impl/QuestionServiceImpl.java +++ /dev/null @@ -1,306 +0,0 @@ -package com.sino.training.module.exam.service.impl; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.ContentStatus; -import com.sino.training.common.enums.QuestionType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.QuestionDTO; -import com.sino.training.module.exam.dto.QuestionQueryDTO; -import com.sino.training.module.exam.entity.PaperQuestion; -import com.sino.training.module.exam.entity.Question; -import com.sino.training.module.exam.entity.QuestionCategory; -import com.sino.training.module.exam.mapper.PaperQuestionMapper; -import com.sino.training.module.exam.mapper.QuestionCategoryMapper; -import com.sino.training.module.exam.mapper.QuestionMapper; -import com.sino.training.module.exam.service.QuestionService; -import com.sino.training.module.exam.vo.QuestionVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.UserMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 题目服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class QuestionServiceImpl extends ServiceImpl implements QuestionService { - - private final QuestionCategoryMapper questionCategoryMapper; - private final DepartmentMapper departmentMapper; - private final UserMapper userMapper; - private final PaperQuestionMapper paperQuestionMapper; - - @Override - public PageResult pageQuestions(QuestionQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.like(Question::getContent, queryDTO.getKeyword()); - } - if (queryDTO.getCategoryId() != null) { - queryWrapper.eq(Question::getCategoryId, queryDTO.getCategoryId()); - } - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(Question::getDepartmentId, queryDTO.getDepartmentId()); - } - if (StrUtil.isNotBlank(queryDTO.getType())) { - queryWrapper.eq(Question::getType, QuestionType.valueOf(queryDTO.getType())); - } - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(Question::getStatus, ContentStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(Question::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (Question question : result.getRecords()) { - voList.add(convertToVO(question)); - } - - return PageResult.of(result, voList); - } - - @Override - public QuestionVO getQuestionDetail(Long id) { - Question question = getById(id); - if (question == null) { - throw new BusinessException(ResultCode.QUESTION_NOT_FOUND); - } - return convertToVO(question); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createQuestion(QuestionDTO dto) { - QuestionCategory category = questionCategoryMapper.selectById(dto.getCategoryId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 获取当前用户上下文 - UserContext userContext = UserContextHolder.getContext(); - String currentRole = userContext.getRole(); - - // 确定部门ID:管理员使用前端传值,其他用户强制使用自己的部门 - Long departmentId; - if ("ADMIN".equals(currentRole)) { - departmentId = dto.getDepartmentId(); - if (departmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "管理员必须指定所属部门"); - } - } else { - departmentId = userContext.getDepartmentId(); - } - - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - Question question = new Question(); - question.setType(QuestionType.valueOf(dto.getType())); - question.setContent(dto.getContent()); - question.setOptions(dto.getOptions() != null ? JSONUtil.toJsonStr(dto.getOptions()) : null); - question.setAnswer(dto.getAnswer()); - question.setAnalysis(dto.getAnalysis()); - question.setCategoryId(dto.getCategoryId()); - question.setDepartmentId(departmentId); - question.setCreatorId(userContext.getUserId()); - question.setStatus(ContentStatus.DRAFT); - - save(question); - return question.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateQuestion(QuestionDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "题目ID不能为空"); - } - - Question question = getById(dto.getId()); - if (question == null) { - throw new BusinessException(ResultCode.QUESTION_NOT_FOUND); - } - - if (dto.getCategoryId() != null) { - QuestionCategory category = questionCategoryMapper.selectById(dto.getCategoryId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - question.setCategoryId(dto.getCategoryId()); - } - - question.setType(QuestionType.valueOf(dto.getType())); - question.setContent(dto.getContent()); - question.setOptions(dto.getOptions() != null ? JSONUtil.toJsonStr(dto.getOptions()) : null); - question.setAnswer(dto.getAnswer()); - question.setAnalysis(dto.getAnalysis()); - - updateById(question); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteQuestion(Long id) { - Question question = getById(id); - if (question == null) { - throw new BusinessException(ResultCode.QUESTION_NOT_FOUND); - } - - // 检查是否被试卷引用 - LambdaQueryWrapper paperQuery = new LambdaQueryWrapper<>(); - paperQuery.eq(PaperQuestion::getQuestionId, id); - if (paperQuestionMapper.selectCount(paperQuery) > 0) { - throw new BusinessException(ResultCode.QUESTION_REFERENCED); - } - - removeById(id); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void publishQuestion(Long id) { - Question question = getById(id); - if (question == null) { - throw new BusinessException(ResultCode.QUESTION_NOT_FOUND); - } - - question.setStatus(ContentStatus.PUBLISHED); - question.setPublishTime(LocalDateTime.now()); - updateById(question); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void offlineQuestion(Long id) { - Question question = getById(id); - if (question == null) { - throw new BusinessException(ResultCode.QUESTION_NOT_FOUND); - } - - question.setStatus(ContentStatus.OFFLINE); - updateById(question); - } - - @Override - public List listByCategory(Long categoryId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Question::getCategoryId, categoryId) - .eq(Question::getStatus, ContentStatus.PUBLISHED) - .orderByDesc(Question::getCreateTime); - List list = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Question question : list) { - voList.add(convertToVO(question)); - } - return voList; - } - - @Override - public List listPublishedByDepartment(Long departmentId, String type) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Question::getDepartmentId, departmentId) - .eq(Question::getStatus, ContentStatus.PUBLISHED); - if (StrUtil.isNotBlank(type)) { - queryWrapper.eq(Question::getType, QuestionType.valueOf(type)); - } - queryWrapper.orderByDesc(Question::getCreateTime); - List list = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Question question : list) { - voList.add(convertToVO(question)); - } - return voList; - } - - private QuestionVO convertToVO(Question question) { - QuestionVO vo = new QuestionVO(); - vo.setId(question.getId()); - vo.setType(question.getType().name()); - vo.setTypeName(getTypeName(question.getType())); - vo.setContent(question.getContent()); - vo.setAnswer(question.getAnswer()); - vo.setAnalysis(question.getAnalysis()); - vo.setCategoryId(question.getCategoryId()); - vo.setDepartmentId(question.getDepartmentId()); - vo.setStatus(question.getStatus().name()); - vo.setStatusName(getStatusName(question.getStatus())); - vo.setCreatorId(question.getCreatorId()); - vo.setCreateTime(question.getCreateTime()); - vo.setPublishTime(question.getPublishTime()); - - // 解析选项 - if (StrUtil.isNotBlank(question.getOptions())) { - List options = JSONUtil.toList(question.getOptions(), QuestionVO.OptionVO.class); - vo.setOptions(options); - } - - // 获取分类名称 - if (question.getCategoryId() != null) { - QuestionCategory category = questionCategoryMapper.selectById(question.getCategoryId()); - if (category != null) { - vo.setCategoryName(category.getName()); - } - } - - // 获取部门名称 - if (question.getDepartmentId() != null) { - Department department = departmentMapper.selectById(question.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取创建人姓名 - if (question.getCreatorId() != null) { - User user = userMapper.selectById(question.getCreatorId()); - if (user != null) { - vo.setCreatorName(user.getRealName()); - } - } - - return vo; - } - - private String getTypeName(QuestionType type) { - return switch (type) { - case SINGLE -> "单选题"; - case MULTIPLE -> "多选题"; - case JUDGE -> "判断题"; - }; - } - - private String getStatusName(ContentStatus status) { - return switch (status) { - case DRAFT -> "草稿"; - case PUBLISHED -> "已发布"; - case OFFLINE -> "已下架"; - }; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamPaperVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/ExamPaperVO.java deleted file mode 100644 index 7d1f3e4..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamPaperVO.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.util.List; - -/** - * 考试试卷VO(学员答题时使用,不包含答案) - * - * @author training-system - */ -@Data -public class ExamPaperVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long recordId; - private Long examId; - private String examTitle; - private Integer duration; - private Integer totalScore; - private Long remainingSeconds; - private List questions; - - @Data - public static class ExamQuestionVO implements Serializable { - private Long questionId; - private Integer sortOrder; - private String type; - private String typeName; - private String content; - private List options; - private Integer score; - /** - * 用户已选答案(用于回显) - */ - private String userAnswer; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamRecordVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/ExamRecordVO.java deleted file mode 100644 index 3b25e6a..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamRecordVO.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 考试记录VO - * - * @author training-system - */ -@Data -public class ExamRecordVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private Long examId; - private String examTitle; - private Long userId; - private String userName; - private Integer attemptNo; - private Integer score; - private Integer totalScore; - private Integer passScore; - private Boolean passed; - private LocalDateTime startTime; - private LocalDateTime submitTime; - private Integer status; - private String statusName; - - /** - * 剩余时间(秒) - */ - private Long remainingSeconds; - - /** - * 答题详情 - */ - private List answerDetails; - - @Data - public static class AnswerDetailVO implements Serializable { - private Long questionId; - private String questionContent; - private String questionType; - private List options; - private String userAnswer; - private String correctAnswer; - private Boolean correct; - private Integer score; - private Integer earnedScore; - private String analysis; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/ExamVO.java deleted file mode 100644 index d2f3484..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/ExamVO.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 考试VO - * - * @author training-system - */ -@Data -public class ExamVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private String title; - private String description; - private Long paperId; - private String paperTitle; - private Integer paperTotalScore; - private Integer paperDuration; - private Integer paperPassScore; - private LocalDateTime startTime; - private LocalDateTime endTime; - private Integer maxAttempts; - private Long departmentId; - private String departmentName; - private String status; - private String statusName; - private Long creatorId; - private String creatorName; - private LocalDateTime createTime; - - /** - * 考试对象列表 - */ - private List targets; - - /** - * 应考人数(参与人数总计) - */ - private Integer totalCount; - - /** - * 已交卷人数 - */ - private Integer submitCount; - - /** - * 及格人数 - */ - private Integer passCount; - - /** - * 当前用户的考试次数(学员查看时) - */ - private Integer currentAttempts; - - /** - * 当前用户的最高分(学员查看时) - */ - private Integer highestScore; - - @Data - public static class ExamTargetVO implements Serializable { - private Long id; - private String targetType; - private String targetTypeName; - private Long targetId; - private String targetName; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/PaperVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/PaperVO.java deleted file mode 100644 index b6abeb0..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/PaperVO.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 试卷VO - * - * @author training-system - */ -@Data -public class PaperVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private String title; - private String description; - private Integer totalScore; - private Integer duration; - private Integer passScore; - private Long departmentId; - private String departmentName; - private String status; - private String statusName; - private Long creatorId; - private String creatorName; - private LocalDateTime createTime; - private LocalDateTime publishTime; - - /** - * 题目数量 - */ - private Integer questionCount; - - /** - * 试卷题目列表 - */ - private List questions; - - @Data - public static class PaperQuestionVO implements Serializable { - private Long id; - private Long questionId; - private Integer score; - private Integer sortOrder; - private QuestionVO question; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionCategoryVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionCategoryVO.java deleted file mode 100644 index 4bbbd9e..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionCategoryVO.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 题目分类VO - * - * @author training-system - */ -@Data -public class QuestionCategoryVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private String name; - private Long parentId; - private String parentName; - private Long departmentId; - private String departmentName; - private Integer sortOrder; - private LocalDateTime createTime; - private List children; - private Integer questionCount; -} diff --git a/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionVO.java b/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionVO.java deleted file mode 100644 index 3326b0f..0000000 --- a/training-system/src/main/java/com/sino/training/module/exam/vo/QuestionVO.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sino.training.module.exam.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 题目VO - * - * @author training-system - */ -@Data -public class QuestionVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private String type; - private String typeName; - private String content; - private List options; - private String answer; - private String analysis; - private Long categoryId; - private String categoryName; - private Long departmentId; - private String departmentName; - private String status; - private String statusName; - private Long creatorId; - private String creatorName; - private LocalDateTime createTime; - private LocalDateTime publishTime; - - @Data - public static class OptionVO implements Serializable { - private String key; - private String value; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/controller/CategoryController.java b/training-system/src/main/java/com/sino/training/module/knowledge/controller/CategoryController.java deleted file mode 100644 index 563bf63..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/controller/CategoryController.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sino.training.module.knowledge.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.knowledge.dto.CategoryDTO; -import com.sino.training.module.knowledge.service.CategoryService; -import com.sino.training.module.knowledge.vo.CategoryVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 知识分类控制器 - * - * @author training-system - */ -@Tag(name = "知识分类", description = "知识分类CRUD接口") -@RestController -@RequestMapping("/api/knowledge/category") -@RequiredArgsConstructor -public class CategoryController { - - private final CategoryService categoryService; - - @Operation(summary = "获取分类树") - @GetMapping("/tree") - public Result> tree( - @Parameter(description = "部门ID(可选)") @RequestParam(required = false) Long departmentId) { - return Result.success(categoryService.getCategoryTree(departmentId)); - } - - @Operation(summary = "获取分类列表(平铺)") - @GetMapping("/list") - public Result> list( - @Parameter(description = "部门ID(可选)") @RequestParam(required = false) Long departmentId) { - return Result.success(categoryService.listCategories(departmentId)); - } - - @Operation(summary = "获取分类详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(categoryService.getCategoryDetail(id)); - } - - @Operation(summary = "创建分类") - @PostMapping - public Result create(@Valid @RequestBody CategoryDTO dto) { - return Result.success(categoryService.createCategory(dto)); - } - - @Operation(summary = "更新分类") - @PutMapping - public Result update(@Valid @RequestBody CategoryDTO dto) { - categoryService.updateCategory(dto); - return Result.success(); - } - - @Operation(summary = "删除分类") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - categoryService.deleteCategory(id); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/controller/KnowledgeController.java b/training-system/src/main/java/com/sino/training/module/knowledge/controller/KnowledgeController.java deleted file mode 100644 index 6da6c5f..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/controller/KnowledgeController.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.sino.training.module.knowledge.controller; - -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.knowledge.dto.KnowledgeDTO; -import com.sino.training.module.knowledge.dto.KnowledgeQueryDTO; -import com.sino.training.module.knowledge.service.KnowledgeService; -import com.sino.training.module.knowledge.vo.KnowledgeVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 知识管理控制器 - * - * @author training-system - */ -@Tag(name = "知识管理", description = "知识CRUD接口") -@RestController -@RequestMapping("/api/knowledge") -@RequiredArgsConstructor -public class KnowledgeController { - - private final KnowledgeService knowledgeService; - - @Operation(summary = "分页查询知识列表") - @GetMapping("/page") - public Result> page(KnowledgeQueryDTO queryDTO) { - return Result.success(knowledgeService.pageKnowledge(queryDTO)); - } - - @Operation(summary = "获取知识详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(knowledgeService.getKnowledgeDetail(id)); - } - - @Operation(summary = "创建知识") - @PostMapping - public Result create(@Valid @RequestBody KnowledgeDTO dto) { - return Result.success(knowledgeService.createKnowledge(dto)); - } - - @Operation(summary = "更新知识") - @PutMapping - public Result update(@Valid @RequestBody KnowledgeDTO dto) { - knowledgeService.updateKnowledge(dto); - return Result.success(); - } - - @Operation(summary = "删除知识") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - knowledgeService.deleteKnowledge(id); - return Result.success(); - } - - @Operation(summary = "发布知识") - @PutMapping("/{id}/publish") - public Result publish(@PathVariable Long id) { - knowledgeService.publishKnowledge(id); - return Result.success(); - } - - @Operation(summary = "下架知识") - @PutMapping("/{id}/offline") - public Result offline(@PathVariable Long id) { - knowledgeService.offlineKnowledge(id); - return Result.success(); - } - - @Operation(summary = "查看知识(增加浏览次数)") - @PutMapping("/{id}/view") - public Result view(@PathVariable Long id) { - knowledgeService.increaseViewCount(id); - return Result.success(knowledgeService.getKnowledgeDetail(id)); - } - - @Operation(summary = "根据分类获取已发布知识") - @GetMapping("/published/category/{categoryId}") - public Result> listByCategory(@PathVariable Long categoryId) { - return Result.success(knowledgeService.listPublishedByCategory(categoryId)); - } - - @Operation(summary = "根据部门获取已发布知识") - @GetMapping("/published/department/{departmentId}") - public Result> listByDepartment(@PathVariable Long departmentId) { - return Result.success(knowledgeService.listPublishedByDepartment(departmentId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/dto/CategoryDTO.java b/training-system/src/main/java/com/sino/training/module/knowledge/dto/CategoryDTO.java deleted file mode 100644 index 8100c47..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/dto/CategoryDTO.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.sino.training.module.knowledge.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 知识分类DTO - * - * @author training-system - */ -@Data -public class CategoryDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 分类ID(编辑时使用) - */ - private Long id; - - /** - * 分类名称 - */ - @NotBlank(message = "分类名称不能为空") - private String name; - - /** - * 父分类ID(0表示顶级分类) - */ - private Long parentId; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeDTO.java b/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeDTO.java deleted file mode 100644 index decdde2..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeDTO.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.sino.training.module.knowledge.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 知识DTO - * - * @author training-system - */ -@Data -public class KnowledgeDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 知识ID(编辑时使用) - */ - private Long id; - - /** - * 标题 - */ - @NotBlank(message = "标题不能为空") - private String title; - - /** - * 描述/摘要 - */ - private String description; - - /** - * 所属分类ID - */ - @NotNull(message = "所属分类不能为空") - private Long categoryId; - - /** - * 知识类型:DOCUMENT/VIDEO - */ - @NotBlank(message = "知识类型不能为空") - private String type; - - /** - * 文件名 - */ - private String fileName; - - /** - * 文件URL - */ - private String fileUrl; - - /** - * 文件大小 - */ - private Long fileSize; - - /** - * 文件类型 - */ - private String fileType; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.java b/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.java deleted file mode 100644 index a391c7f..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sino.training.module.knowledge.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 知识查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class KnowledgeQueryDTO extends PageQuery { - - /** - * 关键字(标题) - */ - private String keyword; - - /** - * 分类ID - */ - private Long categoryId; - - /** - * 部门ID - */ - private Long departmentId; - - /** - * 知识类型 - */ - private String type; - - /** - * 状态 - */ - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/entity/Category.java b/training-system/src/main/java/com/sino/training/module/knowledge/entity/Category.java deleted file mode 100644 index beb07ee..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/entity/Category.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.knowledge.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 知识分类实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("km_category") -public class Category extends BaseEntity { - - /** - * 分类名称 - */ - private String name; - - /** - * 父分类ID(0表示顶级分类) - */ - private Long parentId; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/entity/Knowledge.java b/training-system/src/main/java/com/sino/training/module/knowledge/entity/Knowledge.java deleted file mode 100644 index 41ca599..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/entity/Knowledge.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.sino.training.module.knowledge.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.ContentStatus; -import com.sino.training.common.enums.KnowledgeType; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 知识实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("km_knowledge") -public class Knowledge extends BaseEntity { - - /** - * 标题 - */ - private String title; - - /** - * 描述/摘要 - */ - private String description; - - /** - * 所属分类ID - */ - private Long categoryId; - - /** - * 知识类型 - */ - private KnowledgeType type; - - /** - * 文件名 - */ - private String fileName; - - /** - * 文件URL - */ - private String fileUrl; - - /** - * 文件大小(字节) - */ - private Long fileSize; - - /** - * 文件类型/后缀 - */ - private String fileType; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 状态 - */ - private ContentStatus status; - - /** - * 创建人ID - */ - private Long creatorId; - - /** - * 发布时间 - */ - private LocalDateTime publishTime; - - /** - * 浏览次数 - */ - private Integer viewCount; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/mapper/CategoryMapper.java b/training-system/src/main/java/com/sino/training/module/knowledge/mapper/CategoryMapper.java deleted file mode 100644 index 82be11b..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/mapper/CategoryMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.knowledge.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.knowledge.entity.Category; -import org.apache.ibatis.annotations.Mapper; - -/** - * 知识分类 Mapper - * - * @author training-system - */ -@Mapper -public interface CategoryMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/mapper/KnowledgeMapper.java b/training-system/src/main/java/com/sino/training/module/knowledge/mapper/KnowledgeMapper.java deleted file mode 100644 index 6d3f143..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/mapper/KnowledgeMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.knowledge.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.knowledge.entity.Knowledge; -import org.apache.ibatis.annotations.Mapper; - -/** - * 知识 Mapper - * - * @author training-system - */ -@Mapper -public interface KnowledgeMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/service/CategoryService.java b/training-system/src/main/java/com/sino/training/module/knowledge/service/CategoryService.java deleted file mode 100644 index 99678de..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/service/CategoryService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.sino.training.module.knowledge.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.knowledge.dto.CategoryDTO; -import com.sino.training.module.knowledge.entity.Category; -import com.sino.training.module.knowledge.vo.CategoryVO; - -import java.util.List; - -/** - * 知识分类服务接口 - * - * @author training-system - */ -public interface CategoryService extends IService { - - /** - * 获取分类树 - * - * @param departmentId 部门ID(可选) - * @return 分类树 - */ - List getCategoryTree(Long departmentId); - - /** - * 获取分类列表(平铺) - * - * @param departmentId 部门ID(可选) - * @return 分类列表 - */ - List listCategories(Long departmentId); - - /** - * 获取分类详情 - * - * @param id 分类ID - * @return 分类详情 - */ - CategoryVO getCategoryDetail(Long id); - - /** - * 创建分类 - * - * @param dto 分类信息 - * @return 分类ID - */ - Long createCategory(CategoryDTO dto); - - /** - * 更新分类 - * - * @param dto 分类信息 - */ - void updateCategory(CategoryDTO dto); - - /** - * 删除分类 - * - * @param id 分类ID - */ - void deleteCategory(Long id); -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/service/KnowledgeService.java b/training-system/src/main/java/com/sino/training/module/knowledge/service/KnowledgeService.java deleted file mode 100644 index 37e593e..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/service/KnowledgeService.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.sino.training.module.knowledge.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.common.result.PageResult; -import com.sino.training.module.knowledge.dto.KnowledgeDTO; -import com.sino.training.module.knowledge.dto.KnowledgeQueryDTO; -import com.sino.training.module.knowledge.entity.Knowledge; -import com.sino.training.module.knowledge.vo.KnowledgeVO; - -import java.util.List; - -/** - * 知识服务接口 - * - * @author training-system - */ -public interface KnowledgeService extends IService { - - /** - * 分页查询知识列表 - * - * @param queryDTO 查询条件 - * @return 知识分页数据 - */ - PageResult pageKnowledge(KnowledgeQueryDTO queryDTO); - - /** - * 获取知识详情 - * - * @param id 知识ID - * @return 知识详情 - */ - KnowledgeVO getKnowledgeDetail(Long id); - - /** - * 创建知识 - * - * @param dto 知识信息 - * @return 知识ID - */ - Long createKnowledge(KnowledgeDTO dto); - - /** - * 更新知识 - * - * @param dto 知识信息 - */ - void updateKnowledge(KnowledgeDTO dto); - - /** - * 删除知识 - * - * @param id 知识ID - */ - void deleteKnowledge(Long id); - - /** - * 发布知识 - * - * @param id 知识ID - */ - void publishKnowledge(Long id); - - /** - * 下架知识 - * - * @param id 知识ID - */ - void offlineKnowledge(Long id); - - /** - * 增加浏览次数 - * - * @param id 知识ID - */ - void increaseViewCount(Long id); - - /** - * 根据分类获取已发布的知识列表 - * - * @param categoryId 分类ID - * @return 知识列表 - */ - List listPublishedByCategory(Long categoryId); - - /** - * 根据部门获取已发布的知识列表 - * - * @param departmentId 部门ID - * @return 知识列表 - */ - List listPublishedByDepartment(Long departmentId); -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.java b/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.java deleted file mode 100644 index 2b5d1f3..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.java +++ /dev/null @@ -1,308 +0,0 @@ -package com.sino.training.module.knowledge.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.knowledge.dto.CategoryDTO; -import com.sino.training.module.knowledge.entity.Category; -import com.sino.training.module.knowledge.entity.Knowledge; -import com.sino.training.module.knowledge.mapper.CategoryMapper; -import com.sino.training.module.knowledge.mapper.KnowledgeMapper; -import com.sino.training.module.knowledge.service.CategoryService; -import com.sino.training.module.knowledge.vo.CategoryVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.common.utils.SecurityUtils; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * 知识分类服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class CategoryServiceImpl extends ServiceImpl implements CategoryService { - - private final DepartmentMapper departmentMapper; - private final KnowledgeMapper knowledgeMapper; - - @Override - public List getCategoryTree(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(Category::getDepartmentId, departmentId); - } - queryWrapper.orderByAsc(Category::getSortOrder, Category::getId); - List categories = list(queryWrapper); - - // 统计每个分类下的知识数量 - Map knowledgeCountMap = getKnowledgeCountMap(departmentId); - - // 转换为VO - List voList = categories.stream() - .map(c -> convertToVO(c, knowledgeCountMap)) - .collect(Collectors.toList()); - - // 构建树形结构 - return buildTree(voList); - } - - @Override - public List listCategories(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(Category::getDepartmentId, departmentId); - } - queryWrapper.orderByAsc(Category::getSortOrder, Category::getId); - List categories = list(queryWrapper); - - Map knowledgeCountMap = getKnowledgeCountMap(departmentId); - - List voList = new ArrayList<>(); - for (Category category : categories) { - voList.add(convertToVO(category, knowledgeCountMap)); - } - return voList; - } - - @Override - public CategoryVO getCategoryDetail(Long id) { - Category category = getById(id); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - return convertToVO(category, null); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createCategory(CategoryDTO dto) { - // 部门权限校验:非管理员只能操作本部门 - Long departmentId = checkDepartmentPermission(dto.getDepartmentId()); - - // 检查部门是否存在 - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 检查父分类是否存在(如果有) - if (dto.getParentId() != null && dto.getParentId() > 0) { - Category parent = getById(dto.getParentId()); - if (parent == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND, "父分类不存在"); - } - // 检查父分类是否属于同一部门 - if (!parent.getDepartmentId().equals(departmentId)) { - throw new BusinessException(ResultCode.BAD_REQUEST, "父分类不属于该部门"); - } - } - - // 检查同一父级下名称是否重复 - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Category::getDepartmentId, departmentId) - .eq(Category::getParentId, dto.getParentId() != null ? dto.getParentId() : 0L) - .eq(Category::getName, dto.getName()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该级别下已存在同名分类"); - } - - Category category = new Category(); - category.setName(dto.getName()); - category.setParentId(dto.getParentId() != null ? dto.getParentId() : 0L); - category.setDepartmentId(departmentId); - category.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); - save(category); - return category.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateCategory(CategoryDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "分类ID不能为空"); - } - - Category category = getById(dto.getId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 部门权限校验:非管理员只能操作本部门分类 - checkDepartmentPermission(category.getDepartmentId()); - - // 检查父分类是否存在(如果有) - Long parentId = dto.getParentId() != null ? dto.getParentId() : 0L; - if (parentId > 0) { - // 不能将自己设为父分类 - if (parentId.equals(dto.getId())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "不能将自己设为父分类"); - } - Category parent = getById(parentId); - if (parent == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND, "父分类不存在"); - } - } - - // 检查同一父级下名称是否重复(排除自己) - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Category::getDepartmentId, category.getDepartmentId()) - .eq(Category::getParentId, parentId) - .eq(Category::getName, dto.getName()) - .ne(Category::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该级别下已存在同名分类"); - } - - category.setName(dto.getName()); - category.setParentId(parentId); - if (dto.getSortOrder() != null) { - category.setSortOrder(dto.getSortOrder()); - } - updateById(category); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteCategory(Long id) { - Category category = getById(id); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 部门权限校验:非管理员只能操作本部门分类 - checkDepartmentPermission(category.getDepartmentId()); - - // 检查是否有子分类 - LambdaQueryWrapper childQuery = new LambdaQueryWrapper<>(); - childQuery.eq(Category::getParentId, id); - if (count(childQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在子分类,无法删除"); - } - - // 检查是否有知识 - LambdaQueryWrapper knowledgeQuery = new LambdaQueryWrapper<>(); - knowledgeQuery.eq(Knowledge::getCategoryId, id); - if (knowledgeMapper.selectCount(knowledgeQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在知识,无法删除"); - } - - removeById(id); - } - - private CategoryVO convertToVO(Category category, Map knowledgeCountMap) { - CategoryVO vo = new CategoryVO(); - vo.setId(category.getId()); - vo.setName(category.getName()); - vo.setParentId(category.getParentId()); - vo.setDepartmentId(category.getDepartmentId()); - vo.setSortOrder(category.getSortOrder()); - vo.setCreateTime(category.getCreateTime()); - - // 获取父分类名称 - if (category.getParentId() != null && category.getParentId() > 0) { - Category parent = getById(category.getParentId()); - if (parent != null) { - vo.setParentName(parent.getName()); - } - } - - // 获取部门名称 - if (category.getDepartmentId() != null) { - Department department = departmentMapper.selectById(category.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 知识数量 - if (knowledgeCountMap != null) { - vo.setKnowledgeCount(knowledgeCountMap.getOrDefault(category.getId(), 0)); - } - - return vo; - } - - private List buildTree(List allCategories) { - // 找出所有顶级分类 - List roots = allCategories.stream() - .filter(c -> c.getParentId() == null || c.getParentId() == 0) - .collect(Collectors.toList()); - - // 递归设置子分类 - for (CategoryVO root : roots) { - setChildren(root, allCategories); - } - - return roots; - } - - private void setChildren(CategoryVO parent, List allCategories) { - List children = allCategories.stream() - .filter(c -> parent.getId().equals(c.getParentId())) - .collect(Collectors.toList()); - - if (!children.isEmpty()) { - parent.setChildren(children); - for (CategoryVO child : children) { - setChildren(child, allCategories); - } - } else { - parent.setChildren(new ArrayList<>()); - } - } - - private Map getKnowledgeCountMap(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(Knowledge::getDepartmentId, departmentId); - } - List knowledgeList = knowledgeMapper.selectList(queryWrapper); - - return knowledgeList.stream() - .filter(k -> k.getCategoryId() != null) - .collect(Collectors.groupingBy( - Knowledge::getCategoryId, - Collectors.collectingAndThen(Collectors.counting(), Long::intValue) - )); - } - - /** - * 部门权限校验 - * 管理员可以操作任意部门(必须指定部门),讲师只能操作本部门(自动使用当前部门) - * - * @param targetDepartmentId 目标部门ID - * @return 实际应使用的部门ID - */ - private Long checkDepartmentPermission(Long targetDepartmentId) { - // 管理员可以操作任意部门,但必须指定部门ID - if (SecurityUtils.isAdmin()) { - if (targetDepartmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "请选择所属部门"); - } - return targetDepartmentId; - } - - // 获取当前用户部门ID - Long currentDepartmentId = SecurityUtils.getCurrentDepartmentId(); - if (currentDepartmentId == null) { - throw new BusinessException(ResultCode.FORBIDDEN, "无法获取当前用户部门信息"); - } - - // 非管理员只能操作本部门 - if (targetDepartmentId != null && !targetDepartmentId.equals(currentDepartmentId)) { - throw new BusinessException(ResultCode.FORBIDDEN, "无权操作其他部门的分类"); - } - - return currentDepartmentId; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.java b/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.java deleted file mode 100644 index a5fd500..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.java +++ /dev/null @@ -1,346 +0,0 @@ -package com.sino.training.module.knowledge.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.enums.ContentStatus; -import com.sino.training.common.enums.KnowledgeType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.knowledge.dto.KnowledgeDTO; -import com.sino.training.module.knowledge.dto.KnowledgeQueryDTO; -import com.sino.training.module.knowledge.entity.Category; -import com.sino.training.module.knowledge.entity.Knowledge; -import com.sino.training.module.knowledge.mapper.CategoryMapper; -import com.sino.training.module.knowledge.mapper.KnowledgeMapper; -import com.sino.training.module.knowledge.service.KnowledgeService; -import com.sino.training.module.knowledge.vo.KnowledgeVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.UserMapper; -import com.sino.training.common.utils.SecurityUtils; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 知识服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class KnowledgeServiceImpl extends ServiceImpl implements KnowledgeService { - - private final CategoryMapper categoryMapper; - private final DepartmentMapper departmentMapper; - private final UserMapper userMapper; - - @Override - public PageResult pageKnowledge(KnowledgeQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - // 关键字搜索 - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.like(Knowledge::getTitle, queryDTO.getKeyword()); - } - - // 分类筛选 - if (queryDTO.getCategoryId() != null) { - queryWrapper.eq(Knowledge::getCategoryId, queryDTO.getCategoryId()); - } - - // 部门筛选 - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(Knowledge::getDepartmentId, queryDTO.getDepartmentId()); - } - - // 类型筛选 - if (StrUtil.isNotBlank(queryDTO.getType())) { - queryWrapper.eq(Knowledge::getType, KnowledgeType.valueOf(queryDTO.getType())); - } - - // 状态筛选 - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(Knowledge::getStatus, ContentStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(Knowledge::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (Knowledge knowledge : result.getRecords()) { - voList.add(convertToVO(knowledge)); - } - - return PageResult.of(result, voList); - } - - @Override - public KnowledgeVO getKnowledgeDetail(Long id) { - Knowledge knowledge = getById(id); - if (knowledge == null) { - throw new BusinessException(ResultCode.KNOWLEDGE_NOT_FOUND); - } - return convertToVO(knowledge); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createKnowledge(KnowledgeDTO dto) { - // 获取当前登录用户ID - Long currentUserId = SecurityUtils.getCurrentUserId(); - if (currentUserId == null) { - throw new BusinessException(ResultCode.UNAUTHORIZED, "请先登录"); - } - - // 获取当前用户部门ID - Long currentDepartmentId = SecurityUtils.getCurrentDepartmentId(); - - // 检查分类是否存在 - Category category = categoryMapper.selectById(dto.getCategoryId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - - // 确定知识所属部门:优先使用当前用户部门,管理员可指定其他部门 - Long departmentId = dto.getDepartmentId(); - if (!SecurityUtils.isAdmin()) { - // 非管理员只能创建本部门的知识 - departmentId = currentDepartmentId; - } - - // 检查部门是否存在 - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - Knowledge knowledge = new Knowledge(); - knowledge.setTitle(dto.getTitle()); - knowledge.setDescription(dto.getDescription()); - knowledge.setCategoryId(dto.getCategoryId()); - knowledge.setType(KnowledgeType.valueOf(dto.getType())); - knowledge.setFileName(dto.getFileName()); - knowledge.setFileUrl(dto.getFileUrl()); - knowledge.setFileSize(dto.getFileSize()); - knowledge.setFileType(dto.getFileType()); - knowledge.setDepartmentId(departmentId); - knowledge.setStatus(ContentStatus.DRAFT); - knowledge.setViewCount(0); - // 设置创建人ID - knowledge.setCreatorId(currentUserId); - - save(knowledge); - return knowledge.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateKnowledge(KnowledgeDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "知识ID不能为空"); - } - - Knowledge knowledge = getById(dto.getId()); - if (knowledge == null) { - throw new BusinessException(ResultCode.KNOWLEDGE_NOT_FOUND); - } - - // 检查分类是否存在 - if (dto.getCategoryId() != null) { - Category category = categoryMapper.selectById(dto.getCategoryId()); - if (category == null) { - throw new BusinessException(ResultCode.CATEGORY_NOT_FOUND); - } - knowledge.setCategoryId(dto.getCategoryId()); - } - - knowledge.setTitle(dto.getTitle()); - knowledge.setDescription(dto.getDescription()); - knowledge.setType(KnowledgeType.valueOf(dto.getType())); - - if (StrUtil.isNotBlank(dto.getFileName())) { - knowledge.setFileName(dto.getFileName()); - } - if (StrUtil.isNotBlank(dto.getFileUrl())) { - knowledge.setFileUrl(dto.getFileUrl()); - } - if (dto.getFileSize() != null) { - knowledge.setFileSize(dto.getFileSize()); - } - if (StrUtil.isNotBlank(dto.getFileType())) { - knowledge.setFileType(dto.getFileType()); - } - - updateById(knowledge); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteKnowledge(Long id) { - Knowledge knowledge = getById(id); - if (knowledge == null) { - throw new BusinessException(ResultCode.KNOWLEDGE_NOT_FOUND); - } - - // TODO: 检查是否被培训计划引用 - - removeById(id); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void publishKnowledge(Long id) { - Knowledge knowledge = getById(id); - if (knowledge == null) { - throw new BusinessException(ResultCode.KNOWLEDGE_NOT_FOUND); - } - - knowledge.setStatus(ContentStatus.PUBLISHED); - knowledge.setPublishTime(LocalDateTime.now()); - updateById(knowledge); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void offlineKnowledge(Long id) { - Knowledge knowledge = getById(id); - if (knowledge == null) { - throw new BusinessException(ResultCode.KNOWLEDGE_NOT_FOUND); - } - - // TODO: 检查是否被培训计划引用,给出警告 - - knowledge.setStatus(ContentStatus.OFFLINE); - updateById(knowledge); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void increaseViewCount(Long id) { - Knowledge knowledge = getById(id); - if (knowledge != null) { - knowledge.setViewCount(knowledge.getViewCount() + 1); - updateById(knowledge); - } - } - - @Override - public List listPublishedByCategory(Long categoryId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Knowledge::getCategoryId, categoryId) - .eq(Knowledge::getStatus, ContentStatus.PUBLISHED) - .orderByDesc(Knowledge::getPublishTime); - List list = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Knowledge knowledge : list) { - voList.add(convertToVO(knowledge)); - } - return voList; - } - - @Override - public List listPublishedByDepartment(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Knowledge::getDepartmentId, departmentId) - .eq(Knowledge::getStatus, ContentStatus.PUBLISHED) - .orderByDesc(Knowledge::getPublishTime); - List list = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Knowledge knowledge : list) { - voList.add(convertToVO(knowledge)); - } - return voList; - } - - private KnowledgeVO convertToVO(Knowledge knowledge) { - KnowledgeVO vo = new KnowledgeVO(); - vo.setId(knowledge.getId()); - vo.setTitle(knowledge.getTitle()); - vo.setDescription(knowledge.getDescription()); - vo.setCategoryId(knowledge.getCategoryId()); - vo.setType(knowledge.getType().name()); - vo.setTypeName(getTypeName(knowledge.getType())); - vo.setFileName(knowledge.getFileName()); - vo.setFileUrl(knowledge.getFileUrl()); - vo.setFileSize(knowledge.getFileSize()); - vo.setFileSizeFormat(formatFileSize(knowledge.getFileSize())); - vo.setFileType(knowledge.getFileType()); - vo.setDepartmentId(knowledge.getDepartmentId()); - vo.setStatus(knowledge.getStatus().name()); - vo.setStatusName(getStatusName(knowledge.getStatus())); - vo.setCreatorId(knowledge.getCreatorId()); - vo.setViewCount(knowledge.getViewCount()); - vo.setCreateTime(knowledge.getCreateTime()); - vo.setPublishTime(knowledge.getPublishTime()); - - // 获取分类名称 - if (knowledge.getCategoryId() != null) { - Category category = categoryMapper.selectById(knowledge.getCategoryId()); - if (category != null) { - vo.setCategoryName(category.getName()); - } - } - - // 获取部门名称 - if (knowledge.getDepartmentId() != null) { - Department department = departmentMapper.selectById(knowledge.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取创建人姓名 - if (knowledge.getCreatorId() != null) { - User user = userMapper.selectById(knowledge.getCreatorId()); - if (user != null) { - vo.setCreatorName(user.getRealName()); - } - } - - return vo; - } - - private String getTypeName(KnowledgeType type) { - return switch (type) { - case DOCUMENT -> "文档"; - case VIDEO -> "视频"; - }; - } - - private String getStatusName(ContentStatus status) { - return switch (status) { - case DRAFT -> "草稿"; - case PUBLISHED -> "已发布"; - case OFFLINE -> "已下架"; - }; - } - - private String formatFileSize(Long size) { - if (size == null) { - return ""; - } - if (size < 1024) { - return size + "B"; - } else if (size < 1024 * 1024) { - return String.format("%.2fKB", size / 1024.0); - } else if (size < 1024 * 1024 * 1024) { - return String.format("%.2fMB", size / (1024.0 * 1024)); - } else { - return String.format("%.2fGB", size / (1024.0 * 1024 * 1024)); - } - } -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/vo/CategoryVO.java b/training-system/src/main/java/com/sino/training/module/knowledge/vo/CategoryVO.java deleted file mode 100644 index 87ac4a4..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/vo/CategoryVO.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sino.training.module.knowledge.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 知识分类VO - * - * @author training-system - */ -@Data -public class CategoryVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 分类ID - */ - private Long id; - - /** - * 分类名称 - */ - private String name; - - /** - * 父分类ID - */ - private Long parentId; - - /** - * 父分类名称 - */ - private String parentName; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 所属部门名称 - */ - private String departmentName; - - /** - * 排序 - */ - private Integer sortOrder; - - /** - * 创建时间 - */ - private LocalDateTime createTime; - - /** - * 子分类列表 - */ - private List children; - - /** - * 该分类下的知识数量 - */ - private Integer knowledgeCount; -} diff --git a/training-system/src/main/java/com/sino/training/module/knowledge/vo/KnowledgeVO.java b/training-system/src/main/java/com/sino/training/module/knowledge/vo/KnowledgeVO.java deleted file mode 100644 index 1085c01..0000000 --- a/training-system/src/main/java/com/sino/training/module/knowledge/vo/KnowledgeVO.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.sino.training.module.knowledge.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; - -/** - * 知识VO - * - * @author training-system - */ -@Data -public class KnowledgeVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 知识ID - */ - private Long id; - - /** - * 标题 - */ - private String title; - - /** - * 描述/摘要 - */ - private String description; - - /** - * 所属分类ID - */ - private Long categoryId; - - /** - * 所属分类名称 - */ - private String categoryName; - - /** - * 知识类型 - */ - private String type; - - /** - * 知识类型名称 - */ - private String typeName; - - /** - * 文件名 - */ - private String fileName; - - /** - * 文件URL - */ - private String fileUrl; - - /** - * 文件大小 - */ - private Long fileSize; - - /** - * 文件大小(格式化) - */ - private String fileSizeFormat; - - /** - * 文件类型 - */ - private String fileType; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 所属部门名称 - */ - private String departmentName; - - /** - * 状态 - */ - private String status; - - /** - * 状态名称 - */ - private String statusName; - - /** - * 创建人ID - */ - private Long creatorId; - - /** - * 创建人姓名 - */ - private String creatorName; - - /** - * 浏览次数 - */ - private Integer viewCount; - - /** - * 创建时间 - */ - private LocalDateTime createTime; - - /** - * 发布时间 - */ - private LocalDateTime publishTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/controller/CenterController.java b/training-system/src/main/java/com/sino/training/module/system/controller/CenterController.java deleted file mode 100644 index efd5db5..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/controller/CenterController.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.sino.training.module.system.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.system.dto.CenterDTO; -import com.sino.training.module.system.service.CenterService; -import com.sino.training.module.system.vo.CenterVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 中心管理控制器 - * - * @author training-system - */ -@Tag(name = "中心管理", description = "中心CRUD接口") -@RestController -@RequestMapping("/api/system/center") -@RequiredArgsConstructor -public class CenterController { - - private final CenterService centerService; - - @Operation(summary = "获取中心列表") - @GetMapping("/list") - public Result> list() { - return Result.success(centerService.listCenters()); - } - - @Operation(summary = "获取中心详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(centerService.getCenterDetail(id)); - } - - @Operation(summary = "创建中心") - @PostMapping - public Result create(@Valid @RequestBody CenterDTO dto) { - return Result.success(centerService.createCenter(dto)); - } - - @Operation(summary = "更新中心") - @PutMapping - public Result update(@Valid @RequestBody CenterDTO dto) { - centerService.updateCenter(dto); - return Result.success(); - } - - @Operation(summary = "删除中心") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - centerService.deleteCenter(id); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/controller/DepartmentController.java b/training-system/src/main/java/com/sino/training/module/system/controller/DepartmentController.java deleted file mode 100644 index aedb268..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/controller/DepartmentController.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.sino.training.module.system.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.system.dto.DepartmentDTO; -import com.sino.training.module.system.service.DepartmentService; -import com.sino.training.module.system.vo.DepartmentVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 部门管理控制器 - * - * @author training-system - */ -@Tag(name = "部门管理", description = "部门CRUD接口") -@RestController -@RequestMapping("/api/system/department") -@RequiredArgsConstructor -public class DepartmentController { - - private final DepartmentService departmentService; - - @Operation(summary = "获取部门列表") - @GetMapping("/list") - public Result> list( - @Parameter(description = "中心ID(可选,不传则获取全部)") @RequestParam(required = false) Long centerId) { - return Result.success(departmentService.listDepartments(centerId)); - } - - @Operation(summary = "获取部门详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(departmentService.getDepartmentDetail(id)); - } - - @Operation(summary = "创建部门") - @PostMapping - public Result create(@Valid @RequestBody DepartmentDTO dto) { - return Result.success(departmentService.createDepartment(dto)); - } - - @Operation(summary = "更新部门") - @PutMapping - public Result update(@Valid @RequestBody DepartmentDTO dto) { - departmentService.updateDepartment(dto); - return Result.success(); - } - - @Operation(summary = "删除部门") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - departmentService.deleteDepartment(id); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/controller/GroupController.java b/training-system/src/main/java/com/sino/training/module/system/controller/GroupController.java deleted file mode 100644 index bf9da51..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/controller/GroupController.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.sino.training.module.system.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.system.dto.GroupDTO; -import com.sino.training.module.system.service.GroupService; -import com.sino.training.module.system.vo.GroupVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 小组管理控制器 - * - * @author training-system - */ -@Tag(name = "小组管理", description = "小组CRUD接口") -@RestController -@RequestMapping("/api/system/group") -@RequiredArgsConstructor -public class GroupController { - - private final GroupService groupService; - - @Operation(summary = "获取小组列表") - @GetMapping("/list") - public Result> list( - @Parameter(description = "部门ID(可选,不传则获取全部)") @RequestParam(required = false) Long departmentId) { - return Result.success(groupService.listGroups(departmentId)); - } - - @Operation(summary = "获取小组详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(groupService.getGroupDetail(id)); - } - - @Operation(summary = "创建小组") - @PostMapping - public Result create(@Valid @RequestBody GroupDTO dto) { - return Result.success(groupService.createGroup(dto)); - } - - @Operation(summary = "更新小组") - @PutMapping - public Result update(@Valid @RequestBody GroupDTO dto) { - groupService.updateGroup(dto); - return Result.success(); - } - - @Operation(summary = "删除小组") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - groupService.deleteGroup(id); - return Result.success(); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/controller/OrganizationController.java b/training-system/src/main/java/com/sino/training/module/system/controller/OrganizationController.java deleted file mode 100644 index ffb81da..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/controller/OrganizationController.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.system.controller; - -import com.sino.training.common.result.Result; -import com.sino.training.module.system.service.OrganizationService; -import com.sino.training.module.system.vo.OrgTreeVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 组织架构控制器 - * - * @author training-system - */ -@Tag(name = "组织架构", description = "组织架构树接口") -@RestController -@RequestMapping("/api/system/org") -@RequiredArgsConstructor -public class OrganizationController { - - private final OrganizationService organizationService; - - @Operation(summary = "获取完整组织架构树") - @GetMapping("/tree") - public Result> getOrgTree() { - return Result.success(organizationService.getOrgTree()); - } - - @Operation(summary = "获取指定中心的组织架构树") - @GetMapping("/tree/{centerId}") - public Result getOrgTreeByCenter(@PathVariable Long centerId) { - return Result.success(organizationService.getOrgTreeByCenter(centerId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/controller/UserController.java b/training-system/src/main/java/com/sino/training/module/system/controller/UserController.java deleted file mode 100644 index 0ffb940..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/controller/UserController.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.sino.training.module.system.controller; - -import com.sino.training.common.annotation.RequireRole; -import com.sino.training.common.enums.UserRole; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.system.dto.PasswordDTO; -import com.sino.training.module.system.dto.UserDTO; -import com.sino.training.module.system.dto.UserQueryDTO; -import com.sino.training.module.system.service.UserService; -import com.sino.training.module.system.vo.UserVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 用户管理控制器 - * - * @author training-system - */ -@Tag(name = "用户管理", description = "用户CRUD接口") -@RestController -@RequestMapping("/api/system/user") -@RequiredArgsConstructor -@RequireRole({UserRole.ADMIN, UserRole.LECTURER}) -public class UserController { - - private final UserService userService; - - @Operation(summary = "分页查询用户列表") - @GetMapping("/page") - public Result> page(UserQueryDTO queryDTO) { - return Result.success(userService.pageUsers(queryDTO)); - } - - @Operation(summary = "获取用户列表(当前用户部门)") - @GetMapping("/list") - public Result> list(@RequestParam(required = false) String role) { - return Result.success(userService.listUsers(role)); - } - - @Operation(summary = "获取用户详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(userService.getUserDetail(id)); - } - - @Operation(summary = "创建用户") - @PostMapping - public Result create(@Valid @RequestBody UserDTO dto) { - return Result.success(userService.createUser(dto)); - } - - @Operation(summary = "更新用户") - @PutMapping - public Result update(@Valid @RequestBody UserDTO dto) { - userService.updateUser(dto); - return Result.success(); - } - - @Operation(summary = "删除用户") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - userService.deleteUser(id); - return Result.success(); - } - - @Operation(summary = "启用用户") - @PutMapping("/{id}/enable") - public Result enable(@PathVariable Long id) { - userService.enableUser(id); - return Result.success(); - } - - @Operation(summary = "禁用用户") - @PutMapping("/{id}/disable") - public Result disable(@PathVariable Long id) { - userService.disableUser(id); - return Result.success(); - } - - @Operation(summary = "重置密码") - @PutMapping("/password/reset") - public Result resetPassword(@Valid @RequestBody PasswordDTO dto) { - userService.resetPassword(dto); - return Result.success(); - } - - @Operation(summary = "根据部门获取用户列表") - @GetMapping("/list/department/{departmentId}") - public Result> listByDepartment(@PathVariable Long departmentId) { - return Result.success(userService.listUsersByDepartment(departmentId)); - } - - @Operation(summary = "根据小组获取用户列表") - @GetMapping("/list/group/{groupId}") - public Result> listByGroup(@PathVariable Long groupId) { - return Result.success(userService.listUsersByGroup(groupId)); - } - - @Operation(summary = "根据角色获取用户列表") - @GetMapping("/list/role/{role}") - public Result> listByRole( - @PathVariable String role, - @Parameter(description = "部门ID(可选)") @RequestParam(required = false) Long departmentId) { - return Result.success(userService.listUsersByRole(role, departmentId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/CenterDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/CenterDTO.java deleted file mode 100644 index c2691a7..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/CenterDTO.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.sino.training.module.system.dto; - -import jakarta.validation.constraints.NotBlank; -import lombok.Data; - -import java.io.Serializable; - -/** - * 中心DTO - * - * @author training-system - */ -@Data -public class CenterDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 中心ID(编辑时使用) - */ - private Long id; - - /** - * 中心名称 - */ - @NotBlank(message = "中心名称不能为空") - private String name; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/DepartmentDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/DepartmentDTO.java deleted file mode 100644 index 8f958d5..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/DepartmentDTO.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sino.training.module.system.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 部门DTO - * - * @author training-system - */ -@Data -public class DepartmentDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 部门ID(编辑时使用) - */ - private Long id; - - /** - * 部门名称 - */ - @NotBlank(message = "部门名称不能为空") - private String name; - - /** - * 所属中心ID - */ - @NotNull(message = "所属中心不能为空") - private Long centerId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/GroupDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/GroupDTO.java deleted file mode 100644 index a452a34..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/GroupDTO.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sino.training.module.system.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 小组DTO - * - * @author training-system - */ -@Data -public class GroupDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 小组ID(编辑时使用) - */ - private Long id; - - /** - * 小组名称 - */ - @NotBlank(message = "小组名称不能为空") - private String name; - - /** - * 所属部门ID - */ - @NotNull(message = "所属部门不能为空") - private Long departmentId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/PasswordDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/PasswordDTO.java deleted file mode 100644 index e458303..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/PasswordDTO.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.sino.training.module.system.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 修改密码DTO - * - * @author training-system - */ -@Data -public class PasswordDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户ID - */ - @NotNull(message = "用户ID不能为空") - private Long userId; - - /** - * 新密码 - */ - @NotBlank(message = "新密码不能为空") - private String newPassword; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/UserDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/UserDTO.java deleted file mode 100644 index 317440d..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/UserDTO.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.sino.training.module.system.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; - -/** - * 用户DTO - * - * @author training-system - */ -@Data -public class UserDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户ID(编辑时使用) - */ - private Long id; - - /** - * 用户名 - */ - @NotBlank(message = "用户名不能为空") - private String username; - - /** - * 密码(创建时必填,编辑时可选) - */ - private String password; - - /** - * 真实姓名 - */ - @NotBlank(message = "真实姓名不能为空") - private String realName; - - /** - * 手机号 - */ - private String phone; - - /** - * 头像URL - */ - private String avatar; - - /** - * 用户角色 - */ - @NotNull(message = "用户角色不能为空") - private String role; - - /** - * 所属部门ID - */ - @NotNull(message = "所属部门不能为空") - private Long departmentId; - - /** - * 所属小组ID - */ - private Long groupId; - - /** - * 企业微信用户ID - */ - private String wxUserid; - - /** - * 用户状态(编辑时可选:ENABLED/DISABLED) - */ - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/dto/UserQueryDTO.java b/training-system/src/main/java/com/sino/training/module/system/dto/UserQueryDTO.java deleted file mode 100644 index 393626b..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/dto/UserQueryDTO.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sino.training.module.system.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 用户查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class UserQueryDTO extends PageQuery { - - /** - * 关键字(用户名/真实姓名/手机号) - */ - private String keyword; - - /** - * 部门ID - */ - private Long departmentId; - - /** - * 小组ID - */ - private Long groupId; - - /** - * 用户角色 - */ - private String role; - - /** - * 用户状态 - */ - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/entity/Center.java b/training-system/src/main/java/com/sino/training/module/system/entity/Center.java deleted file mode 100644 index da71b8d..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/entity/Center.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sino.training.module.system.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 中心实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_center") -public class Center extends BaseEntity { - - /** - * 中心名称 - */ - private String name; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/entity/Department.java b/training-system/src/main/java/com/sino/training/module/system/entity/Department.java deleted file mode 100644 index 8519291..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/entity/Department.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sino.training.module.system.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 部门实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_department") -public class Department extends BaseEntity { - - /** - * 部门名称 - */ - private String name; - - /** - * 所属中心ID - */ - private Long centerId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/entity/Group.java b/training-system/src/main/java/com/sino/training/module/system/entity/Group.java deleted file mode 100644 index 490e3cc..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/entity/Group.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sino.training.module.system.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 小组实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_group") -public class Group extends BaseEntity { - - /** - * 小组名称 - */ - private String name; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/entity/User.java b/training-system/src/main/java/com/sino/training/module/system/entity/User.java deleted file mode 100644 index 83df064..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/entity/User.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.sino.training.module.system.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.UserRole; -import com.sino.training.common.enums.UserStatus; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 用户实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_user") -public class User extends BaseEntity { - - /** - * 企业微信用户ID - */ - private String wxUserid; - - /** - * 用户名(登录账号) - */ - private String username; - - /** - * 密码(备用登录方式) - */ - private String password; - - /** - * 真实姓名 - */ - private String realName; - - /** - * 手机号 - */ - private String phone; - - /** - * 头像URL - */ - private String avatar; - - /** - * 用户角色 - */ - private UserRole role; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 所属小组ID(可为空) - */ - private Long groupId; - - /** - * 用户状态 - */ - private UserStatus status; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/mapper/CenterMapper.java b/training-system/src/main/java/com/sino/training/module/system/mapper/CenterMapper.java deleted file mode 100644 index 19f4704..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/mapper/CenterMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.system.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.system.entity.Center; -import org.apache.ibatis.annotations.Mapper; - -/** - * 中心 Mapper - * - * @author training-system - */ -@Mapper -public interface CenterMapper extends BaseMapper
{ -} diff --git a/training-system/src/main/java/com/sino/training/module/system/mapper/DepartmentMapper.java b/training-system/src/main/java/com/sino/training/module/system/mapper/DepartmentMapper.java deleted file mode 100644 index 8808023..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/mapper/DepartmentMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.system.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.system.entity.Department; -import org.apache.ibatis.annotations.Mapper; - -/** - * 部门 Mapper - * - * @author training-system - */ -@Mapper -public interface DepartmentMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/system/mapper/GroupMapper.java b/training-system/src/main/java/com/sino/training/module/system/mapper/GroupMapper.java deleted file mode 100644 index fce5aca..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/mapper/GroupMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.system.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.system.entity.Group; -import org.apache.ibatis.annotations.Mapper; - -/** - * 小组 Mapper - * - * @author training-system - */ -@Mapper -public interface GroupMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/system/mapper/UserMapper.java b/training-system/src/main/java/com/sino/training/module/system/mapper/UserMapper.java deleted file mode 100644 index 4d07024..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/mapper/UserMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.system.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.system.entity.User; -import org.apache.ibatis.annotations.Mapper; - -/** - * 用户 Mapper - * - * @author training-system - */ -@Mapper -public interface UserMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/CenterService.java b/training-system/src/main/java/com/sino/training/module/system/service/CenterService.java deleted file mode 100644 index cd4625d..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/CenterService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sino.training.module.system.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.system.dto.CenterDTO; -import com.sino.training.module.system.entity.Center; -import com.sino.training.module.system.vo.CenterVO; - -import java.util.List; - -/** - * 中心服务接口 - * - * @author training-system - */ -public interface CenterService extends IService
{ - - /** - * 获取中心列表 - * - * @return 中心列表 - */ - List listCenters(); - - /** - * 获取中心详情(包含下属部门) - * - * @param id 中心ID - * @return 中心详情 - */ - CenterVO getCenterDetail(Long id); - - /** - * 创建中心 - * - * @param dto 中心信息 - * @return 中心ID - */ - Long createCenter(CenterDTO dto); - - /** - * 更新中心 - * - * @param dto 中心信息 - */ - void updateCenter(CenterDTO dto); - - /** - * 删除中心 - * - * @param id 中心ID - */ - void deleteCenter(Long id); -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/DepartmentService.java b/training-system/src/main/java/com/sino/training/module/system/service/DepartmentService.java deleted file mode 100644 index 4e81fef..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/DepartmentService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.sino.training.module.system.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.system.dto.DepartmentDTO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.vo.DepartmentVO; - -import java.util.List; - -/** - * 部门服务接口 - * - * @author training-system - */ -public interface DepartmentService extends IService { - - /** - * 获取部门列表 - * - * @param centerId 中心ID(可选) - * @return 部门列表 - */ - List listDepartments(Long centerId); - - /** - * 获取部门详情(包含下属小组) - * - * @param id 部门ID - * @return 部门详情 - */ - DepartmentVO getDepartmentDetail(Long id); - - /** - * 创建部门 - * - * @param dto 部门信息 - * @return 部门ID - */ - Long createDepartment(DepartmentDTO dto); - - /** - * 更新部门 - * - * @param dto 部门信息 - */ - void updateDepartment(DepartmentDTO dto); - - /** - * 删除部门 - * - * @param id 部门ID - */ - void deleteDepartment(Long id); -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/GroupService.java b/training-system/src/main/java/com/sino/training/module/system/service/GroupService.java deleted file mode 100644 index 528c5cb..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/GroupService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.sino.training.module.system.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.module.system.dto.GroupDTO; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.vo.GroupVO; - -import java.util.List; - -/** - * 小组服务接口 - * - * @author training-system - */ -public interface GroupService extends IService { - - /** - * 获取小组列表 - * - * @param departmentId 部门ID(可选) - * @return 小组列表 - */ - List listGroups(Long departmentId); - - /** - * 获取小组详情 - * - * @param id 小组ID - * @return 小组详情 - */ - GroupVO getGroupDetail(Long id); - - /** - * 创建小组 - * - * @param dto 小组信息 - * @return 小组ID - */ - Long createGroup(GroupDTO dto); - - /** - * 更新小组 - * - * @param dto 小组信息 - */ - void updateGroup(GroupDTO dto); - - /** - * 删除小组 - * - * @param id 小组ID - */ - void deleteGroup(Long id); -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/OrganizationService.java b/training-system/src/main/java/com/sino/training/module/system/service/OrganizationService.java deleted file mode 100644 index 7c34ebd..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/OrganizationService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.sino.training.module.system.service; - -import com.sino.training.module.system.vo.OrgTreeVO; - -import java.util.List; - -/** - * 组织架构服务接口 - * - * @author training-system - */ -public interface OrganizationService { - - /** - * 获取完整组织架构树 - * - * @return 组织架构树 - */ - List getOrgTree(); - - /** - * 获取指定中心下的组织架构树 - * - * @param centerId 中心ID - * @return 组织架构树 - */ - OrgTreeVO getOrgTreeByCenter(Long centerId); -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/UserService.java b/training-system/src/main/java/com/sino/training/module/system/service/UserService.java deleted file mode 100644 index 089adf3..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/UserService.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.sino.training.module.system.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.sino.training.common.result.PageResult; -import com.sino.training.module.system.dto.PasswordDTO; -import com.sino.training.module.system.dto.UserDTO; -import com.sino.training.module.system.dto.UserQueryDTO; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.vo.UserVO; - -import java.util.List; - -/** - * 用户服务接口 - * - * @author training-system - */ -public interface UserService extends IService { - - /** - * 分页查询用户列表 - * - * @param queryDTO 查询条件 - * @return 用户分页数据 - */ - PageResult pageUsers(UserQueryDTO queryDTO); - - /** - * 获取用户详情 - * - * @param id 用户ID - * @return 用户详情 - */ - UserVO getUserDetail(Long id); - - /** - * 创建用户 - * - * @param dto 用户信息 - * @return 用户ID - */ - Long createUser(UserDTO dto); - - /** - * 更新用户 - * - * @param dto 用户信息 - */ - void updateUser(UserDTO dto); - - /** - * 删除用户 - * - * @param id 用户ID - */ - void deleteUser(Long id); - - /** - * 启用用户 - * - * @param id 用户ID - */ - void enableUser(Long id); - - /** - * 禁用用户 - * - * @param id 用户ID - */ - void disableUser(Long id); - - /** - * 重置密码 - * - * @param dto 密码信息 - */ - void resetPassword(PasswordDTO dto); - - /** - * 根据部门ID获取用户列表 - * - * @param departmentId 部门ID - * @return 用户列表 - */ - List listUsersByDepartment(Long departmentId); - - /** - * 根据小组ID获取用户列表 - * - * @param groupId 小组ID - * @return 用户列表 - */ - List listUsersByGroup(Long groupId); - - /** - * 根据角色获取用户列表 - * - * @param role 角色 - * @param departmentId 部门ID(可选) - * @return 用户列表 - */ - List listUsersByRole(String role, Long departmentId); - - /** - * 获取当前用户部门的用户列表 - * - * @param role 角色(可选) - * @return 用户列表 - */ - List listUsers(String role); -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/impl/CenterServiceImpl.java b/training-system/src/main/java/com/sino/training/module/system/service/impl/CenterServiceImpl.java deleted file mode 100644 index d79e0d5..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/impl/CenterServiceImpl.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.sino.training.module.system.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.system.dto.CenterDTO; -import com.sino.training.module.system.entity.Center; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.mapper.CenterMapper; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.service.CenterService; -import com.sino.training.module.system.service.DepartmentService; -import com.sino.training.module.system.vo.CenterVO; -import com.sino.training.module.system.vo.DepartmentVO; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 中心服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class CenterServiceImpl extends ServiceImpl implements CenterService { - - private final DepartmentMapper departmentMapper; - @Lazy - private final DepartmentService departmentService; - - @Override - public List listCenters() { - LambdaQueryWrapper
queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.orderByAsc(Center::getSortOrder, Center::getId); - List
centers = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Center center : centers) { - voList.add(convertToVO(center, false)); - } - return voList; - } - - @Override - public CenterVO getCenterDetail(Long id) { - Center center = getById(id); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "中心不存在"); - } - return convertToVO(center, true); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createCenter(CenterDTO dto) { - // 检查名称是否重复 - LambdaQueryWrapper
queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Center::getName, dto.getName()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "中心名称已存在"); - } - - Center center = new Center(); - center.setName(dto.getName()); - center.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); - save(center); - return center.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateCenter(CenterDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "中心ID不能为空"); - } - - Center center = getById(dto.getId()); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "中心不存在"); - } - - // 检查名称是否重复(排除自己) - LambdaQueryWrapper
queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Center::getName, dto.getName()) - .ne(Center::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "中心名称已存在"); - } - - center.setName(dto.getName()); - if (dto.getSortOrder() != null) { - center.setSortOrder(dto.getSortOrder()); - } - updateById(center); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteCenter(Long id) { - Center center = getById(id); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "中心不存在"); - } - - // 检查是否有下属部门 - LambdaQueryWrapper deptQuery = new LambdaQueryWrapper<>(); - deptQuery.eq(Department::getCenterId, id); - if (departmentMapper.selectCount(deptQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该中心下存在部门,无法删除"); - } - - removeById(id); - } - - private CenterVO convertToVO(Center center, boolean includeChildren) { - CenterVO vo = new CenterVO(); - vo.setId(center.getId()); - vo.setName(center.getName()); - vo.setSortOrder(center.getSortOrder()); - vo.setCreateTime(center.getCreateTime()); - - if (includeChildren) { - List departments = departmentService.listDepartments(center.getId()); - vo.setDepartments(departments); - } - - return vo; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/impl/DepartmentServiceImpl.java b/training-system/src/main/java/com/sino/training/module/system/service/impl/DepartmentServiceImpl.java deleted file mode 100644 index 84c6a4e..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/impl/DepartmentServiceImpl.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.sino.training.module.system.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.system.dto.DepartmentDTO; -import com.sino.training.module.system.entity.Center; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.CenterMapper; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import com.sino.training.module.system.service.DepartmentService; -import com.sino.training.module.system.service.GroupService; -import com.sino.training.module.system.vo.DepartmentVO; -import com.sino.training.module.system.vo.GroupVO; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 部门服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class DepartmentServiceImpl extends ServiceImpl implements DepartmentService { - - private final CenterMapper centerMapper; - private final GroupMapper groupMapper; - private final UserMapper userMapper; - @Lazy - private final GroupService groupService; - - @Override - public List listDepartments(Long centerId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (centerId != null) { - queryWrapper.eq(Department::getCenterId, centerId); - } - queryWrapper.orderByAsc(Department::getSortOrder, Department::getId); - List departments = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Department department : departments) { - voList.add(convertToVO(department, false)); - } - return voList; - } - - @Override - public DepartmentVO getDepartmentDetail(Long id) { - Department department = getById(id); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "部门不存在"); - } - return convertToVO(department, true); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createDepartment(DepartmentDTO dto) { - // 检查中心是否存在 - Center center = centerMapper.selectById(dto.getCenterId()); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属中心不存在"); - } - - // 检查同一中心下名称是否重复 - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Department::getCenterId, dto.getCenterId()) - .eq(Department::getName, dto.getName()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该中心下已存在同名部门"); - } - - // 检查部门数量限制(每个中心最多8个部门) - LambdaQueryWrapper countQuery = new LambdaQueryWrapper<>(); - countQuery.eq(Department::getCenterId, dto.getCenterId()); - if (count(countQuery) >= 8) { - throw new BusinessException(ResultCode.BAD_REQUEST, "每个中心最多只能创建8个部门"); - } - - Department department = new Department(); - department.setName(dto.getName()); - department.setCenterId(dto.getCenterId()); - department.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); - save(department); - return department.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateDepartment(DepartmentDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "部门ID不能为空"); - } - - Department department = getById(dto.getId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "部门不存在"); - } - - // 检查中心是否存在 - if (dto.getCenterId() != null) { - Center center = centerMapper.selectById(dto.getCenterId()); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属中心不存在"); - } - } - - // 检查同一中心下名称是否重复(排除自己) - Long centerId = dto.getCenterId() != null ? dto.getCenterId() : department.getCenterId(); - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Department::getCenterId, centerId) - .eq(Department::getName, dto.getName()) - .ne(Department::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该中心下已存在同名部门"); - } - - department.setName(dto.getName()); - if (dto.getCenterId() != null) { - department.setCenterId(dto.getCenterId()); - } - if (dto.getSortOrder() != null) { - department.setSortOrder(dto.getSortOrder()); - } - updateById(department); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteDepartment(Long id) { - Department department = getById(id); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "部门不存在"); - } - - // 检查是否有下属小组 - LambdaQueryWrapper groupQuery = new LambdaQueryWrapper<>(); - groupQuery.eq(Group::getDepartmentId, id); - if (groupMapper.selectCount(groupQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该部门下存在小组,无法删除"); - } - - // 检查是否有员工 - LambdaQueryWrapper userQuery = new LambdaQueryWrapper<>(); - userQuery.eq(User::getDepartmentId, id); - if (userMapper.selectCount(userQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该部门下存在员工,无法删除"); - } - - removeById(id); - } - - private DepartmentVO convertToVO(Department department, boolean includeChildren) { - DepartmentVO vo = new DepartmentVO(); - vo.setId(department.getId()); - vo.setName(department.getName()); - vo.setCenterId(department.getCenterId()); - vo.setSortOrder(department.getSortOrder()); - vo.setCreateTime(department.getCreateTime()); - - // 获取中心名称 - if (department.getCenterId() != null) { - Center center = centerMapper.selectById(department.getCenterId()); - if (center != null) { - vo.setCenterName(center.getName()); - } - } - - if (includeChildren) { - List groups = groupService.listGroups(department.getId()); - vo.setGroups(groups); - } - - return vo; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/impl/GroupServiceImpl.java b/training-system/src/main/java/com/sino/training/module/system/service/impl/GroupServiceImpl.java deleted file mode 100644 index 6ab8b7d..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/impl/GroupServiceImpl.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.sino.training.module.system.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.system.dto.GroupDTO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import com.sino.training.module.system.service.GroupService; -import com.sino.training.module.system.vo.GroupVO; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 小组服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class GroupServiceImpl extends ServiceImpl implements GroupService { - - private final DepartmentMapper departmentMapper; - private final UserMapper userMapper; - - @Override - public List listGroups(Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - if (departmentId != null) { - queryWrapper.eq(Group::getDepartmentId, departmentId); - } - queryWrapper.orderByAsc(Group::getSortOrder, Group::getId); - List groups = list(queryWrapper); - - List voList = new ArrayList<>(); - for (Group group : groups) { - voList.add(convertToVO(group)); - } - return voList; - } - - @Override - public GroupVO getGroupDetail(Long id) { - Group group = getById(id); - if (group == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "小组不存在"); - } - return convertToVO(group); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createGroup(GroupDTO dto) { - // 检查部门是否存在 - Department department = departmentMapper.selectById(dto.getDepartmentId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 检查同一部门下名称是否重复 - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Group::getDepartmentId, dto.getDepartmentId()) - .eq(Group::getName, dto.getName()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该部门下已存在同名小组"); - } - - // 检查小组数量限制(每个部门最多10个小组) - LambdaQueryWrapper countQuery = new LambdaQueryWrapper<>(); - countQuery.eq(Group::getDepartmentId, dto.getDepartmentId()); - if (count(countQuery) >= 10) { - throw new BusinessException(ResultCode.BAD_REQUEST, "每个部门最多只能创建10个小组"); - } - - Group group = new Group(); - group.setName(dto.getName()); - group.setDepartmentId(dto.getDepartmentId()); - group.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); - save(group); - return group.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateGroup(GroupDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "小组ID不能为空"); - } - - Group group = getById(dto.getId()); - if (group == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "小组不存在"); - } - - // 检查部门是否存在 - if (dto.getDepartmentId() != null) { - Department department = departmentMapper.selectById(dto.getDepartmentId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - } - - // 检查同一部门下名称是否重复(排除自己) - Long departmentId = dto.getDepartmentId() != null ? dto.getDepartmentId() : group.getDepartmentId(); - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(Group::getDepartmentId, departmentId) - .eq(Group::getName, dto.getName()) - .ne(Group::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.DATA_EXISTS, "该部门下已存在同名小组"); - } - - group.setName(dto.getName()); - if (dto.getDepartmentId() != null) { - group.setDepartmentId(dto.getDepartmentId()); - } - if (dto.getSortOrder() != null) { - group.setSortOrder(dto.getSortOrder()); - } - updateById(group); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteGroup(Long id) { - Group group = getById(id); - if (group == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "小组不存在"); - } - - // 检查是否有员工 - LambdaQueryWrapper userQuery = new LambdaQueryWrapper<>(); - userQuery.eq(User::getGroupId, id); - if (userMapper.selectCount(userQuery) > 0) { - throw new BusinessException(ResultCode.DATA_REFERENCED, "该小组下存在员工,无法删除"); - } - - removeById(id); - } - - private GroupVO convertToVO(Group group) { - GroupVO vo = new GroupVO(); - vo.setId(group.getId()); - vo.setName(group.getName()); - vo.setDepartmentId(group.getDepartmentId()); - vo.setSortOrder(group.getSortOrder()); - vo.setCreateTime(group.getCreateTime()); - - // 获取部门名称 - if (group.getDepartmentId() != null) { - Department department = departmentMapper.selectById(group.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - return vo; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/impl/OrganizationServiceImpl.java b/training-system/src/main/java/com/sino/training/module/system/service/impl/OrganizationServiceImpl.java deleted file mode 100644 index 6300401..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/impl/OrganizationServiceImpl.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.sino.training.module.system.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.system.entity.Center; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.mapper.CenterMapper; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.service.OrganizationService; -import com.sino.training.module.system.vo.OrgTreeVO; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -/** - * 组织架构服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class OrganizationServiceImpl implements OrganizationService { - - private final CenterMapper centerMapper; - private final DepartmentMapper departmentMapper; - private final GroupMapper groupMapper; - - @Override - public List getOrgTree() { - List tree = new ArrayList<>(); - - // 获取所有中心 - LambdaQueryWrapper
centerQuery = new LambdaQueryWrapper<>(); - centerQuery.orderByAsc(Center::getSortOrder, Center::getId); - List
centers = centerMapper.selectList(centerQuery); - - for (Center center : centers) { - OrgTreeVO centerNode = buildCenterNode(center); - tree.add(centerNode); - } - - return tree; - } - - @Override - public OrgTreeVO getOrgTreeByCenter(Long centerId) { - Center center = centerMapper.selectById(centerId); - if (center == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "中心不存在"); - } - return buildCenterNode(center); - } - - private OrgTreeVO buildCenterNode(Center center) { - OrgTreeVO centerNode = new OrgTreeVO(); - centerNode.setId(center.getId()); - centerNode.setName(center.getName()); - centerNode.setType("CENTER"); - centerNode.setParentId(null); - centerNode.setSortOrder(center.getSortOrder()); - - // 获取该中心下的所有部门 - LambdaQueryWrapper deptQuery = new LambdaQueryWrapper<>(); - deptQuery.eq(Department::getCenterId, center.getId()) - .orderByAsc(Department::getSortOrder, Department::getId); - List departments = departmentMapper.selectList(deptQuery); - - List deptNodes = new ArrayList<>(); - for (Department department : departments) { - OrgTreeVO deptNode = buildDepartmentNode(department, center.getId()); - deptNodes.add(deptNode); - } - centerNode.setChildren(deptNodes); - - return centerNode; - } - - private OrgTreeVO buildDepartmentNode(Department department, Long centerId) { - OrgTreeVO deptNode = new OrgTreeVO(); - deptNode.setId(department.getId()); - deptNode.setName(department.getName()); - deptNode.setType("DEPARTMENT"); - deptNode.setParentId(centerId); - deptNode.setSortOrder(department.getSortOrder()); - - // 获取该部门下的所有小组 - LambdaQueryWrapper groupQuery = new LambdaQueryWrapper<>(); - groupQuery.eq(Group::getDepartmentId, department.getId()) - .orderByAsc(Group::getSortOrder, Group::getId); - List groups = groupMapper.selectList(groupQuery); - - List groupNodes = new ArrayList<>(); - for (Group group : groups) { - OrgTreeVO groupNode = new OrgTreeVO(); - groupNode.setId(group.getId()); - groupNode.setName(group.getName()); - groupNode.setType("GROUP"); - groupNode.setParentId(department.getId()); - groupNode.setSortOrder(group.getSortOrder()); - groupNode.setChildren(new ArrayList<>()); - groupNodes.add(groupNode); - } - deptNode.setChildren(groupNodes); - - return deptNode; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/service/impl/UserServiceImpl.java b/training-system/src/main/java/com/sino/training/module/system/service/impl/UserServiceImpl.java deleted file mode 100644 index 713edd1..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/service/impl/UserServiceImpl.java +++ /dev/null @@ -1,449 +0,0 @@ -package com.sino.training.module.system.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.UserRole; -import com.sino.training.common.enums.UserStatus; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.system.dto.PasswordDTO; -import com.sino.training.module.system.dto.UserDTO; -import com.sino.training.module.system.dto.UserQueryDTO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import com.sino.training.module.system.service.UserService; -import com.sino.training.module.system.vo.UserVO; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 用户服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class UserServiceImpl extends ServiceImpl implements UserService { - - private final DepartmentMapper departmentMapper; - private final GroupMapper groupMapper; - private final PasswordEncoder passwordEncoder; - - @Override - public PageResult pageUsers(UserQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - // 部门数据隔离:讲师只能查看本部门数据 - applyDepartmentIsolation(queryWrapper); - - // 关键字搜索 - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.and(w -> w - .like(User::getUsername, queryDTO.getKeyword()) - .or() - .like(User::getRealName, queryDTO.getKeyword()) - .or() - .like(User::getPhone, queryDTO.getKeyword()) - ); - } - - // 部门筛选 - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(User::getDepartmentId, queryDTO.getDepartmentId()); - } - - // 小组筛选 - if (queryDTO.getGroupId() != null) { - queryWrapper.eq(User::getGroupId, queryDTO.getGroupId()); - } - - // 角色筛选 - if (StrUtil.isNotBlank(queryDTO.getRole())) { - queryWrapper.eq(User::getRole, UserRole.valueOf(queryDTO.getRole())); - } - - // 状态筛选 - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(User::getStatus, UserStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(User::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (User user : result.getRecords()) { - voList.add(convertToVO(user)); - } - - return PageResult.of(result, voList); - } - - @Override - public UserVO getUserDetail(Long id) { - User user = getById(id); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - // 部门数据隔离:讲师只能查看本部门用户 - checkDepartmentAccess(user.getDepartmentId()); - return convertToVO(user); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createUser(UserDTO dto) { - // 检查用户名是否已存在 - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getUsername, dto.getUsername()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.USER_EXISTS); - } - - // 检查手机号是否已存在 - if (StrUtil.isNotBlank(dto.getPhone())) { - LambdaQueryWrapper phoneQuery = new LambdaQueryWrapper<>(); - phoneQuery.eq(User::getPhone, dto.getPhone()); - if (count(phoneQuery) > 0) { - throw new BusinessException(ResultCode.PHONE_EXISTS); - } - } - - // 检查部门是否存在 - Department department = departmentMapper.selectById(dto.getDepartmentId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 检查小组是否存在(如果有) - if (dto.getGroupId() != null) { - Group group = groupMapper.selectById(dto.getGroupId()); - if (group == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属小组不存在"); - } - // 检查小组是否属于该部门 - if (!group.getDepartmentId().equals(dto.getDepartmentId())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "小组不属于该部门"); - } - } - - // 密码必填 - if (StrUtil.isBlank(dto.getPassword())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "密码不能为空"); - } - - User user = new User(); - user.setUsername(dto.getUsername()); - user.setPassword(passwordEncoder.encode(dto.getPassword())); - user.setRealName(dto.getRealName()); - user.setPhone(dto.getPhone()); - user.setAvatar(dto.getAvatar()); - user.setRole(UserRole.valueOf(dto.getRole())); - user.setDepartmentId(dto.getDepartmentId()); - user.setGroupId(dto.getGroupId()); - user.setWxUserid(dto.getWxUserid()); - user.setStatus(UserStatus.ENABLED); - - save(user); - return user.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateUser(UserDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "用户ID不能为空"); - } - - User user = getById(dto.getId()); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - // 检查用户名是否重复(排除自己) - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getUsername, dto.getUsername()) - .ne(User::getId, dto.getId()); - if (count(queryWrapper) > 0) { - throw new BusinessException(ResultCode.USER_EXISTS); - } - - // 检查手机号是否重复(排除自己) - if (StrUtil.isNotBlank(dto.getPhone())) { - LambdaQueryWrapper phoneQuery = new LambdaQueryWrapper<>(); - phoneQuery.eq(User::getPhone, dto.getPhone()) - .ne(User::getId, dto.getId()); - if (count(phoneQuery) > 0) { - throw new BusinessException(ResultCode.PHONE_EXISTS); - } - } - - // 检查部门是否存在 - Department department = departmentMapper.selectById(dto.getDepartmentId()); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 检查小组是否存在(如果有) - if (dto.getGroupId() != null) { - Group group = groupMapper.selectById(dto.getGroupId()); - if (group == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属小组不存在"); - } - if (!group.getDepartmentId().equals(dto.getDepartmentId())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "小组不属于该部门"); - } - } - - user.setUsername(dto.getUsername()); - user.setRealName(dto.getRealName()); - user.setPhone(dto.getPhone()); - user.setAvatar(dto.getAvatar()); - user.setRole(UserRole.valueOf(dto.getRole())); - user.setDepartmentId(dto.getDepartmentId()); - user.setGroupId(dto.getGroupId()); - user.setWxUserid(dto.getWxUserid()); - - // 如果提供了状态,则更新状态 - if (StrUtil.isNotBlank(dto.getStatus())) { - user.setStatus(UserStatus.valueOf(dto.getStatus())); - } - - // 如果提供了新密码,则更新密码 - if (StrUtil.isNotBlank(dto.getPassword())) { - user.setPassword(passwordEncoder.encode(dto.getPassword())); - } - - updateById(user); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteUser(Long id) { - User user = getById(id); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - removeById(id); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void enableUser(Long id) { - User user = getById(id); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - user.setStatus(UserStatus.ENABLED); - updateById(user); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void disableUser(Long id) { - User user = getById(id); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - user.setStatus(UserStatus.DISABLED); - updateById(user); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void resetPassword(PasswordDTO dto) { - User user = getById(dto.getUserId()); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - user.setPassword(passwordEncoder.encode(dto.getNewPassword())); - updateById(user); - } - - @Override - public List listUsersByDepartment(Long departmentId) { - // 部门数据隔离:讲师只能查询本部门 - checkDepartmentAccess(departmentId); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getDepartmentId, departmentId) - .eq(User::getStatus, UserStatus.ENABLED) - .orderByAsc(User::getRealName); - List users = list(queryWrapper); - - List voList = new ArrayList<>(); - for (User user : users) { - voList.add(convertToVO(user)); - } - return voList; - } - - @Override - public List listUsersByGroup(Long groupId) { - // 部门数据隔离:讲师只能查询本部门的小组 - Group group = groupMapper.selectById(groupId); - if (group != null) { - checkDepartmentAccess(group.getDepartmentId()); - } - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getGroupId, groupId) - .eq(User::getStatus, UserStatus.ENABLED) - .orderByAsc(User::getRealName); - List users = list(queryWrapper); - - List voList = new ArrayList<>(); - for (User user : users) { - voList.add(convertToVO(user)); - } - return voList; - } - - @Override - public List listUsersByRole(String role, Long departmentId) { - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getRole, UserRole.valueOf(role)) - .eq(User::getStatus, UserStatus.ENABLED); - - // 部门数据隔离:讲师强制限定为本部门 - UserContext currentUser = UserContextHolder.getContext(); - if (currentUser != null && UserRole.LECTURER.name().equals(currentUser.getRole())) { - // 讲师只能查询本部门数据,忽略传入的 departmentId 参数 - if (currentUser.getDepartmentId() != null) { - queryWrapper.eq(User::getDepartmentId, currentUser.getDepartmentId()); - } - } else if (departmentId != null) { - // 管理员可以按传入的部门筛选 - queryWrapper.eq(User::getDepartmentId, departmentId); - } - - queryWrapper.orderByAsc(User::getRealName); - List users = list(queryWrapper); - - List voList = new ArrayList<>(); - for (User user : users) { - voList.add(convertToVO(user)); - } - return voList; - } - - @Override - public List listUsers(String role) { - UserContext userContext = UserContextHolder.getContext(); - Long departmentId = userContext.getDepartmentId(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(User::getDepartmentId, departmentId) - .eq(User::getStatus, UserStatus.ENABLED); - - if (StrUtil.isNotBlank(role)) { - queryWrapper.eq(User::getRole, UserRole.valueOf(role)); - } - - queryWrapper.orderByAsc(User::getRealName); - List users = list(queryWrapper); - - List voList = new ArrayList<>(); - for (User user : users) { - voList.add(convertToVO(user)); - } - return voList; - } - - private UserVO convertToVO(User user) { - UserVO vo = new UserVO(); - vo.setId(user.getId()); - vo.setUsername(user.getUsername()); - vo.setRealName(user.getRealName()); - vo.setPhone(user.getPhone()); - vo.setAvatar(user.getAvatar()); - vo.setRole(user.getRole().name()); - vo.setRoleName(getRoleName(user.getRole())); - vo.setDepartmentId(user.getDepartmentId()); - vo.setGroupId(user.getGroupId()); - vo.setWxUserid(user.getWxUserid()); - vo.setStatus(user.getStatus().name()); - vo.setStatusName(getStatusName(user.getStatus())); - vo.setCreateTime(user.getCreateTime()); - - // 获取部门名称 - if (user.getDepartmentId() != null) { - Department department = departmentMapper.selectById(user.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取小组名称 - if (user.getGroupId() != null) { - Group group = groupMapper.selectById(user.getGroupId()); - if (group != null) { - vo.setGroupName(group.getName()); - } - } - - return vo; - } - - private String getRoleName(UserRole role) { - return switch (role) { - case ADMIN -> "管理员"; - case LECTURER -> "讲师"; - case STUDENT -> "学员"; - }; - } - - private String getStatusName(UserStatus status) { - return switch (status) { - case ENABLED -> "启用"; - case DISABLED -> "禁用"; - }; - } - - /** - * 应用部门数据隔离条件 - * 讲师只能查看本部门数据,管理员可查看所有数据 - * - * @param queryWrapper 查询条件 - */ - private void applyDepartmentIsolation(LambdaQueryWrapper queryWrapper) { - UserContext currentUser = UserContextHolder.getContext(); - if (currentUser != null && UserRole.LECTURER.name().equals(currentUser.getRole())) { - // 讲师只能查看本部门数据 - if (currentUser.getDepartmentId() != null) { - queryWrapper.eq(User::getDepartmentId, currentUser.getDepartmentId()); - } - } - // ADMIN 不加部门限制,可查看所有数据 - } - - /** - * 校验当前用户是否有权限访问指定部门的数据 - * - * @param departmentId 目标部门ID - */ - private void checkDepartmentAccess(Long departmentId) { - UserContext currentUser = UserContextHolder.getContext(); - if (currentUser != null && UserRole.LECTURER.name().equals(currentUser.getRole())) { - if (currentUser.getDepartmentId() != null && !currentUser.getDepartmentId().equals(departmentId)) { - throw new BusinessException(ResultCode.FORBIDDEN); - } - } - } -} diff --git a/training-system/src/main/java/com/sino/training/module/system/vo/CenterVO.java b/training-system/src/main/java/com/sino/training/module/system/vo/CenterVO.java deleted file mode 100644 index e237bcd..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/vo/CenterVO.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.sino.training.module.system.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 中心VO - * - * @author training-system - */ -@Data -public class CenterVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 中心ID - */ - private Long id; - - /** - * 中心名称 - */ - private String name; - - /** - * 排序 - */ - private Integer sortOrder; - - /** - * 创建时间 - */ - private LocalDateTime createTime; - - /** - * 下属部门列表 - */ - private List departments; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/vo/DepartmentVO.java b/training-system/src/main/java/com/sino/training/module/system/vo/DepartmentVO.java deleted file mode 100644 index 74410ff..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/vo/DepartmentVO.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sino.training.module.system.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 部门VO - * - * @author training-system - */ -@Data -public class DepartmentVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 部门ID - */ - private Long id; - - /** - * 部门名称 - */ - private String name; - - /** - * 所属中心ID - */ - private Long centerId; - - /** - * 所属中心名称 - */ - private String centerName; - - /** - * 排序 - */ - private Integer sortOrder; - - /** - * 创建时间 - */ - private LocalDateTime createTime; - - /** - * 下属小组列表 - */ - private List groups; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/vo/GroupVO.java b/training-system/src/main/java/com/sino/training/module/system/vo/GroupVO.java deleted file mode 100644 index de150a2..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/vo/GroupVO.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.sino.training.module.system.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; - -/** - * 小组VO - * - * @author training-system - */ -@Data -public class GroupVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 小组ID - */ - private Long id; - - /** - * 小组名称 - */ - private String name; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 所属部门名称 - */ - private String departmentName; - - /** - * 排序 - */ - private Integer sortOrder; - - /** - * 创建时间 - */ - private LocalDateTime createTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/vo/OrgTreeVO.java b/training-system/src/main/java/com/sino/training/module/system/vo/OrgTreeVO.java deleted file mode 100644 index f505fbe..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/vo/OrgTreeVO.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.sino.training.module.system.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.util.List; - -/** - * 组织架构树VO - * - * @author training-system - */ -@Data -public class OrgTreeVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 节点ID - */ - private Long id; - - /** - * 节点名称 - */ - private String name; - - /** - * 节点类型:CENTER/DEPARTMENT/GROUP - */ - private String type; - - /** - * 父节点ID - */ - private Long parentId; - - /** - * 排序 - */ - private Integer sortOrder; - - /** - * 子节点 - */ - private List children; -} diff --git a/training-system/src/main/java/com/sino/training/module/system/vo/UserVO.java b/training-system/src/main/java/com/sino/training/module/system/vo/UserVO.java deleted file mode 100644 index 815fa33..0000000 --- a/training-system/src/main/java/com/sino/training/module/system/vo/UserVO.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.sino.training.module.system.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDateTime; - -/** - * 用户VO - * - * @author training-system - */ -@Data -public class UserVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * 用户ID - */ - private Long id; - - /** - * 用户名 - */ - private String username; - - /** - * 真实姓名 - */ - private String realName; - - /** - * 手机号 - */ - private String phone; - - /** - * 头像URL - */ - private String avatar; - - /** - * 用户角色 - */ - private String role; - - /** - * 用户角色名称 - */ - private String roleName; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 所属部门名称 - */ - private String departmentName; - - /** - * 所属小组ID - */ - private Long groupId; - - /** - * 所属小组名称 - */ - private String groupName; - - /** - * 企业微信用户ID - */ - private String wxUserid; - - /** - * 用户状态 - */ - private String status; - - /** - * 用户状态名称 - */ - private String statusName; - - /** - * 创建时间 - */ - private LocalDateTime createTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/controller/TrainingPlanController.java b/training-system/src/main/java/com/sino/training/module/training/controller/TrainingPlanController.java deleted file mode 100644 index 1a335bb..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/controller/TrainingPlanController.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.sino.training.module.training.controller; - -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.Result; -import com.sino.training.module.training.dto.TrainingPlanDTO; -import com.sino.training.module.training.dto.TrainingPlanQueryDTO; -import com.sino.training.module.training.service.PlanProgressService; -import com.sino.training.module.training.service.TrainingPlanService; -import com.sino.training.module.training.vo.MyPlanVO; -import com.sino.training.module.training.vo.TrainingPlanVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 培训计划控制器 - * - * @author training-system - */ -@Tag(name = "培训计划管理", description = "培训计划CRUD和学员学习接口") -@RestController -@RequestMapping("/api/training/plan") -@RequiredArgsConstructor -public class TrainingPlanController { - - private final TrainingPlanService trainingPlanService; - private final PlanProgressService planProgressService; - - @Operation(summary = "分页查询培训计划") - @GetMapping("/page") - public Result> page(TrainingPlanQueryDTO queryDTO) { - return Result.success(trainingPlanService.pagePlans(queryDTO)); - } - - @Operation(summary = "获取培训计划详情") - @GetMapping("/{id}") - public Result detail(@PathVariable Long id) { - return Result.success(trainingPlanService.getPlanDetail(id)); - } - - @Operation(summary = "创建培训计划") - @PostMapping - public Result create(@Valid @RequestBody TrainingPlanDTO dto) { - return Result.success(trainingPlanService.createPlan(dto)); - } - - @Operation(summary = "更新培训计划") - @PutMapping - public Result update(@Valid @RequestBody TrainingPlanDTO dto) { - trainingPlanService.updatePlan(dto); - return Result.success(); - } - - @Operation(summary = "删除培训计划") - @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { - trainingPlanService.deletePlan(id); - return Result.success(); - } - - @Operation(summary = "发布培训计划") - @PostMapping("/{id}/publish") - public Result publish(@PathVariable Long id) { - trainingPlanService.publishPlan(id); - return Result.success(); - } - - @Operation(summary = "结束培训计划") - @PostMapping("/{id}/end") - public Result end(@PathVariable Long id) { - trainingPlanService.endPlan(id); - return Result.success(); - } - - @Operation(summary = "获取我的培训计划列表(学员)") - @GetMapping("/my") - public Result> myPlans() { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(trainingPlanService.listMyPlans(userId)); - } - - @Operation(summary = "获取我的培训计划详情(学员)") - @GetMapping("/my/{planId}") - public Result myPlanDetail(@PathVariable Long planId) { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(trainingPlanService.getMyPlanDetail(planId, userId)); - } - - @Operation(summary = "标记知识学习完成(学员)") - @PostMapping("/my/{planId}/knowledge/{knowledgeId}/complete") - public Result completeKnowledge(@PathVariable Long planId, @PathVariable Long knowledgeId) { - Long userId = UserContextHolder.getContext().getUserId(); - planProgressService.completeKnowledge(planId, knowledgeId, userId); - return Result.success(); - } - - @Operation(summary = "获取学习进度百分比(学员)") - @GetMapping("/my/{planId}/progress") - public Result getProgress(@PathVariable Long planId) { - Long userId = UserContextHolder.getContext().getUserId(); - return Result.success(planProgressService.getProgressPercent(planId, userId)); - } -} diff --git a/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanDTO.java b/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanDTO.java deleted file mode 100644 index 3ec4226..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanDTO.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.sino.training.module.training.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDate; -import java.util.List; - -/** - * 培训计划DTO - * - * @author training-system - */ -@Data -public class TrainingPlanDTO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - - @NotBlank(message = "计划标题不能为空") - private String title; - - private String description; - - @NotNull(message = "开始日期不能为空") - private LocalDate startDate; - - @NotNull(message = "结束日期不能为空") - private LocalDate endDate; - - /** - * 所属部门ID - * 管理员必传,讲师可不传(自动使用当前用户部门) - */ - private Long departmentId; - - /** - * 关联知识列表 - */ - private List knowledgeList; - - /** - * 关联考试列表 - */ - private List examList; - - /** - * 培训对象列表 - */ - private List targets; - - @Data - public static class PlanKnowledgeDTO implements Serializable { - private Long knowledgeId; - private Boolean required; - private Integer sortOrder; - } - - @Data - public static class PlanExamDTO implements Serializable { - private Long examId; - private Boolean required; - private Integer sortOrder; - } - - @Data - public static class PlanTargetDTO implements Serializable { - private String targetType; - private Long targetId; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanQueryDTO.java b/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanQueryDTO.java deleted file mode 100644 index 59e8ff1..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/dto/TrainingPlanQueryDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sino.training.module.training.dto; - -import com.sino.training.common.base.PageQuery; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 培训计划查询DTO - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class TrainingPlanQueryDTO extends PageQuery { - - private String keyword; - private Long departmentId; - private String status; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/entity/PlanExam.java b/training-system/src/main/java/com/sino/training/module/training/entity/PlanExam.java deleted file mode 100644 index 0b0a3d1..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/entity/PlanExam.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.training.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 培训计划-考试关联实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("tr_plan_exam") -public class PlanExam extends BaseEntity { - - /** - * 计划ID - */ - private Long planId; - - /** - * 考试ID - */ - private Long examId; - - /** - * 是否必考 - */ - private Boolean required; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/entity/PlanKnowledge.java b/training-system/src/main/java/com/sino/training/module/training/entity/PlanKnowledge.java deleted file mode 100644 index acbedb1..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/entity/PlanKnowledge.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sino.training.module.training.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 培训计划-知识关联实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("tr_plan_knowledge") -public class PlanKnowledge extends BaseEntity { - - /** - * 计划ID - */ - private Long planId; - - /** - * 知识ID - */ - private Long knowledgeId; - - /** - * 是否必修 - */ - private Boolean required; - - /** - * 排序 - */ - private Integer sortOrder; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/entity/PlanProgress.java b/training-system/src/main/java/com/sino/training/module/training/entity/PlanProgress.java deleted file mode 100644 index 84ad77a..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/entity/PlanProgress.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sino.training.module.training.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDateTime; - -/** - * 培训进度实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("tr_plan_progress") -public class PlanProgress extends BaseEntity { - - /** - * 计划ID - */ - private Long planId; - - /** - * 用户ID - */ - private Long userId; - - /** - * 知识ID - */ - private Long knowledgeId; - - /** - * 是否完成 - */ - private Boolean completed; - - /** - * 完成时间 - */ - private LocalDateTime completeTime; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/entity/PlanTarget.java b/training-system/src/main/java/com/sino/training/module/training/entity/PlanTarget.java deleted file mode 100644 index 8d061c5..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/entity/PlanTarget.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.sino.training.module.training.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.TargetType; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 培训计划-对象关联实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("tr_plan_target") -public class PlanTarget extends BaseEntity { - - /** - * 计划ID - */ - private Long planId; - - /** - * 目标类型 - */ - private TargetType targetType; - - /** - * 目标ID(部门ID/小组ID/用户ID) - */ - private Long targetId; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/entity/TrainingPlan.java b/training-system/src/main/java/com/sino/training/module/training/entity/TrainingPlan.java deleted file mode 100644 index 985fdaf..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/entity/TrainingPlan.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sino.training.module.training.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.sino.training.common.base.BaseEntity; -import com.sino.training.common.enums.PlanStatus; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.LocalDate; - -/** - * 培训计划实体 - * - * @author training-system - */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("tr_plan") -public class TrainingPlan extends BaseEntity { - - /** - * 计划标题 - */ - private String title; - - /** - * 计划描述 - */ - private String description; - - /** - * 开始日期 - */ - private LocalDate startDate; - - /** - * 结束日期 - */ - private LocalDate endDate; - - /** - * 所属部门ID - */ - private Long departmentId; - - /** - * 计划状态 - */ - private PlanStatus status; - - /** - * 创建人ID - */ - private Long creatorId; -} diff --git a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanExamMapper.java b/training-system/src/main/java/com/sino/training/module/training/mapper/PlanExamMapper.java deleted file mode 100644 index 3e428ed..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanExamMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.training.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.training.entity.PlanExam; -import org.apache.ibatis.annotations.Mapper; - -/** - * 培训计划-考试关联 Mapper - * - * @author training-system - */ -@Mapper -public interface PlanExamMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanKnowledgeMapper.java b/training-system/src/main/java/com/sino/training/module/training/mapper/PlanKnowledgeMapper.java deleted file mode 100644 index cb12018..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanKnowledgeMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.training.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.training.entity.PlanKnowledge; -import org.apache.ibatis.annotations.Mapper; - -/** - * 培训计划-知识关联 Mapper - * - * @author training-system - */ -@Mapper -public interface PlanKnowledgeMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanProgressMapper.java b/training-system/src/main/java/com/sino/training/module/training/mapper/PlanProgressMapper.java deleted file mode 100644 index c8f1502..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanProgressMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.training.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.training.entity.PlanProgress; -import org.apache.ibatis.annotations.Mapper; - -/** - * 培训进度 Mapper - * - * @author training-system - */ -@Mapper -public interface PlanProgressMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanTargetMapper.java b/training-system/src/main/java/com/sino/training/module/training/mapper/PlanTargetMapper.java deleted file mode 100644 index 666eb53..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/mapper/PlanTargetMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.training.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.training.entity.PlanTarget; -import org.apache.ibatis.annotations.Mapper; - -/** - * 培训计划-对象关联 Mapper - * - * @author training-system - */ -@Mapper -public interface PlanTargetMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/training/mapper/TrainingPlanMapper.java b/training-system/src/main/java/com/sino/training/module/training/mapper/TrainingPlanMapper.java deleted file mode 100644 index d5dc56a..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/mapper/TrainingPlanMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sino.training.module.training.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.sino.training.module.training.entity.TrainingPlan; -import org.apache.ibatis.annotations.Mapper; - -/** - * 培训计划 Mapper - * - * @author training-system - */ -@Mapper -public interface TrainingPlanMapper extends BaseMapper { -} diff --git a/training-system/src/main/java/com/sino/training/module/training/service/PlanProgressService.java b/training-system/src/main/java/com/sino/training/module/training/service/PlanProgressService.java deleted file mode 100644 index 1ead1a6..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/service/PlanProgressService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.sino.training.module.training.service; - -import com.sino.training.module.training.vo.MyPlanVO; - -/** - * 学习进度服务接口 - * - * @author training-system - */ -public interface PlanProgressService { - - /** - * 记录知识学习完成 - * - * @param planId 培训计划ID - * @param knowledgeId 知识ID - * @param userId 用户ID - */ - void completeKnowledge(Long planId, Long knowledgeId, Long userId); - - /** - * 获取用户在某计划的学习进度 - * - * @param planId 培训计划ID - * @param userId 用户ID - * @return 进度百分比 - */ - Integer getProgressPercent(Long planId, Long userId); - - /** - * 判断用户是否已学习某知识 - * - * @param planId 培训计划ID - * @param knowledgeId 知识ID - * @param userId 用户ID - * @return 是否已学习 - */ - boolean hasLearned(Long planId, Long knowledgeId, Long userId); - - /** - * 获取用户已学习的知识数量 - * - * @param planId 培训计划ID - * @param userId 用户ID - * @return 已学习数量 - */ - Integer getLearnedCount(Long planId, Long userId); -} diff --git a/training-system/src/main/java/com/sino/training/module/training/service/TrainingPlanService.java b/training-system/src/main/java/com/sino/training/module/training/service/TrainingPlanService.java deleted file mode 100644 index 347f6d1..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/service/TrainingPlanService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.sino.training.module.training.service; - -import com.sino.training.common.result.PageResult; -import com.sino.training.module.training.dto.TrainingPlanDTO; -import com.sino.training.module.training.dto.TrainingPlanQueryDTO; -import com.sino.training.module.training.vo.MyPlanVO; -import com.sino.training.module.training.vo.TrainingPlanVO; - -import java.util.List; - -/** - * 培训计划服务接口 - * - * @author training-system - */ -public interface TrainingPlanService { - - /** - * 分页查询培训计划 - */ - PageResult pagePlans(TrainingPlanQueryDTO queryDTO); - - /** - * 获取培训计划详情 - */ - TrainingPlanVO getPlanDetail(Long id); - - /** - * 创建培训计划 - */ - Long createPlan(TrainingPlanDTO dto); - - /** - * 更新培训计划 - */ - void updatePlan(TrainingPlanDTO dto); - - /** - * 删除培训计划 - */ - void deletePlan(Long id); - - /** - * 发布培训计划 - */ - void publishPlan(Long id); - - /** - * 结束培训计划 - */ - void endPlan(Long id); - - /** - * 获取我的培训计划列表(学员) - */ - List listMyPlans(Long userId); - - /** - * 获取我的培训计划详情(学员) - */ - MyPlanVO getMyPlanDetail(Long planId, Long userId); -} diff --git a/training-system/src/main/java/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.java b/training-system/src/main/java/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.java deleted file mode 100644 index 4f741b7..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.sino.training.module.training.service.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.sino.training.module.training.entity.PlanKnowledge; -import com.sino.training.module.training.entity.PlanProgress; -import com.sino.training.module.training.mapper.PlanKnowledgeMapper; -import com.sino.training.module.training.mapper.PlanProgressMapper; -import com.sino.training.module.training.service.PlanProgressService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -/** - * 学习进度服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class PlanProgressServiceImpl implements PlanProgressService { - - private final PlanProgressMapper planProgressMapper; - private final PlanKnowledgeMapper planKnowledgeMapper; - - @Override - @Transactional(rollbackFor = Exception.class) - public void completeKnowledge(Long planId, Long knowledgeId, Long userId) { - // 检查是否已存在进度记录 - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(PlanProgress::getPlanId, planId) - .eq(PlanProgress::getKnowledgeId, knowledgeId) - .eq(PlanProgress::getUserId, userId); - - PlanProgress progress = planProgressMapper.selectOne(wrapper); - - if (progress == null) { - // 创建新的进度记录 - progress = new PlanProgress(); - progress.setPlanId(planId); - progress.setKnowledgeId(knowledgeId); - progress.setUserId(userId); - progress.setCompleted(true); - progress.setCompleteTime(LocalDateTime.now()); - planProgressMapper.insert(progress); - } else if (!progress.getCompleted()) { - // 更新为已完成 - progress.setCompleted(true); - progress.setCompleteTime(LocalDateTime.now()); - planProgressMapper.updateById(progress); - } - } - - @Override - public Integer getProgressPercent(Long planId, Long userId) { - // 获取计划总知识数量 - Long totalCount = planKnowledgeMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, planId) - ); - - if (totalCount == null || totalCount == 0) { - return 0; - } - - // 获取已学习数量 - Integer learnedCount = getLearnedCount(planId, userId); - - return (int) Math.round(learnedCount * 100.0 / totalCount); - } - - @Override - public boolean hasLearned(Long planId, Long knowledgeId, Long userId) { - Long count = planProgressMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanProgress::getPlanId, planId) - .eq(PlanProgress::getKnowledgeId, knowledgeId) - .eq(PlanProgress::getUserId, userId) - .eq(PlanProgress::getCompleted, true) - ); - return count != null && count > 0; - } - - @Override - public Integer getLearnedCount(Long planId, Long userId) { - Long count = planProgressMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanProgress::getPlanId, planId) - .eq(PlanProgress::getUserId, userId) - .eq(PlanProgress::getCompleted, true) - ); - return count != null ? count.intValue() : 0; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.java b/training-system/src/main/java/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.java deleted file mode 100644 index beb7b89..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.java +++ /dev/null @@ -1,635 +0,0 @@ -package com.sino.training.module.training.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.PlanStatus; -import com.sino.training.common.enums.TargetType; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.PageResult; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.entity.Exam; -import com.sino.training.module.exam.entity.ExamRecord; -import com.sino.training.module.exam.mapper.ExamMapper; -import com.sino.training.module.exam.mapper.ExamRecordMapper; -import com.sino.training.module.knowledge.entity.Knowledge; -import com.sino.training.module.knowledge.mapper.KnowledgeMapper; -import com.sino.training.module.knowledge.vo.KnowledgeVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.Group; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import com.sino.training.module.training.dto.TrainingPlanDTO; -import com.sino.training.module.training.dto.TrainingPlanQueryDTO; -import com.sino.training.module.training.entity.PlanExam; -import com.sino.training.module.training.entity.PlanKnowledge; -import com.sino.training.module.training.entity.PlanTarget; -import com.sino.training.module.training.entity.TrainingPlan; -import com.sino.training.module.training.mapper.PlanExamMapper; -import com.sino.training.module.training.mapper.PlanKnowledgeMapper; -import com.sino.training.module.training.mapper.PlanTargetMapper; -import com.sino.training.module.training.mapper.TrainingPlanMapper; -import com.sino.training.module.training.service.PlanProgressService; -import com.sino.training.module.training.service.TrainingPlanService; -import com.sino.training.module.training.vo.MyPlanVO; -import com.sino.training.module.training.vo.TrainingPlanVO; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 培训计划服务实现类 - * - * @author training-system - */ -@Service -@RequiredArgsConstructor -public class TrainingPlanServiceImpl extends ServiceImpl implements TrainingPlanService { - - private final PlanKnowledgeMapper planKnowledgeMapper; - private final PlanExamMapper planExamMapper; - private final PlanTargetMapper planTargetMapper; - private final PlanProgressService planProgressService; - private final DepartmentMapper departmentMapper; - private final GroupMapper groupMapper; - private final UserMapper userMapper; - private final KnowledgeMapper knowledgeMapper; - private final ExamMapper examMapper; - private final ExamRecordMapper examRecordMapper; - - @Override - public PageResult pagePlans(TrainingPlanQueryDTO queryDTO) { - Page page = queryDTO.toPage(); - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - - if (StrUtil.isNotBlank(queryDTO.getKeyword())) { - queryWrapper.like(TrainingPlan::getTitle, queryDTO.getKeyword()); - } - if (queryDTO.getDepartmentId() != null) { - queryWrapper.eq(TrainingPlan::getDepartmentId, queryDTO.getDepartmentId()); - } - if (StrUtil.isNotBlank(queryDTO.getStatus())) { - queryWrapper.eq(TrainingPlan::getStatus, PlanStatus.valueOf(queryDTO.getStatus())); - } - - queryWrapper.orderByDesc(TrainingPlan::getCreateTime); - - Page result = page(page, queryWrapper); - - List voList = new ArrayList<>(); - for (TrainingPlan plan : result.getRecords()) { - voList.add(convertToVO(plan, false)); - } - - return PageResult.of(result, voList); - } - - @Override - public TrainingPlanVO getPlanDetail(Long id) { - TrainingPlan plan = getById(id); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - return convertToVO(plan, true); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Long createPlan(TrainingPlanDTO dto) { - // 确定部门ID:管理员使用前端传值,其他用户强制使用自己的部门 - UserContext userContext = UserContextHolder.getContext(); - Long departmentId; - if ("ADMIN".equals(userContext.getRole())) { - departmentId = dto.getDepartmentId(); - if (departmentId == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "管理员必须指定所属部门"); - } - } else { - departmentId = userContext.getDepartmentId(); - } - - // 验证部门 - Department department = departmentMapper.selectById(departmentId); - if (department == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "所属部门不存在"); - } - - // 验证日期 - if (dto.getStartDate().isAfter(dto.getEndDate())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "开始日期不能晚于结束日期"); - } - - TrainingPlan plan = new TrainingPlan(); - plan.setTitle(dto.getTitle()); - plan.setDescription(dto.getDescription()); - plan.setStartDate(dto.getStartDate()); - plan.setEndDate(dto.getEndDate()); - plan.setDepartmentId(departmentId); - plan.setStatus(PlanStatus.NOT_STARTED); - plan.setCreatorId(UserContextHolder.getContext().getUserId()); - - save(plan); - - // 保存关联知识 - savePlanKnowledge(plan.getId(), dto.getKnowledgeList()); - - // 保存关联考试 - savePlanExams(plan.getId(), dto.getExamList()); - - // 保存培训对象 - savePlanTargets(plan.getId(), dto.getTargets()); - - return plan.getId(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updatePlan(TrainingPlanDTO dto) { - if (dto.getId() == null) { - throw new BusinessException(ResultCode.BAD_REQUEST, "计划ID不能为空"); - } - - TrainingPlan plan = getById(dto.getId()); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - - // 验证日期 - if (dto.getStartDate().isAfter(dto.getEndDate())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "开始日期不能晚于结束日期"); - } - - plan.setTitle(dto.getTitle()); - plan.setDescription(dto.getDescription()); - plan.setStartDate(dto.getStartDate()); - plan.setEndDate(dto.getEndDate()); - plan.setDepartmentId(dto.getDepartmentId()); - - updateById(plan); - - // 更新关联知识 - if (dto.getKnowledgeList() != null) { - planKnowledgeMapper.delete( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, plan.getId()) - ); - savePlanKnowledge(plan.getId(), dto.getKnowledgeList()); - } - - // 更新关联考试 - if (dto.getExamList() != null) { - planExamMapper.delete( - new LambdaQueryWrapper() - .eq(PlanExam::getPlanId, plan.getId()) - ); - savePlanExams(plan.getId(), dto.getExamList()); - } - - // 更新培训对象 - if (dto.getTargets() != null) { - planTargetMapper.delete( - new LambdaQueryWrapper() - .eq(PlanTarget::getPlanId, plan.getId()) - ); - savePlanTargets(plan.getId(), dto.getTargets()); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deletePlan(Long id) { - TrainingPlan plan = getById(id); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - - // 删除关联知识 - planKnowledgeMapper.delete( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, id) - ); - - // 删除关联考试 - planExamMapper.delete( - new LambdaQueryWrapper() - .eq(PlanExam::getPlanId, id) - ); - - // 删除培训对象 - planTargetMapper.delete( - new LambdaQueryWrapper() - .eq(PlanTarget::getPlanId, id) - ); - - removeById(id); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void publishPlan(Long id) { - TrainingPlan plan = getById(id); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - - if (plan.getStatus() != PlanStatus.NOT_STARTED) { - throw new BusinessException(ResultCode.BAD_REQUEST, "只有未开始的计划才能发布"); - } - - plan.setStatus(PlanStatus.IN_PROGRESS); - updateById(plan); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void endPlan(Long id) { - TrainingPlan plan = getById(id); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - - if (plan.getStatus() != PlanStatus.IN_PROGRESS) { - throw new BusinessException(ResultCode.BAD_REQUEST, "只有进行中的计划才能结束"); - } - - plan.setStatus(PlanStatus.ENDED); - updateById(plan); - } - - @Override - public List listMyPlans(Long userId) { - User user = userMapper.selectById(userId); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - // 查询进行中的培训计划 - LambdaQueryWrapper planQuery = new LambdaQueryWrapper<>(); - planQuery.eq(TrainingPlan::getStatus, PlanStatus.IN_PROGRESS); - List allPlans = list(planQuery); - - List result = new ArrayList<>(); - for (TrainingPlan plan : allPlans) { - if (isUserTargeted(plan.getId(), user)) { - result.add(convertToMyPlanVO(plan, userId, false)); - } - } - - return result; - } - - @Override - public MyPlanVO getMyPlanDetail(Long planId, Long userId) { - TrainingPlan plan = getById(planId); - if (plan == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "培训计划不存在"); - } - - User user = userMapper.selectById(userId); - if (user == null) { - throw new BusinessException(ResultCode.USER_NOT_FOUND); - } - - if (!isUserTargeted(planId, user)) { - throw new BusinessException(ResultCode.FORBIDDEN, "您不在该培训计划的培训对象中"); - } - - return convertToMyPlanVO(plan, userId, true); - } - - private void savePlanKnowledge(Long planId, List knowledgeList) { - if (knowledgeList == null || knowledgeList.isEmpty()) { - return; - } - - int sortOrder = 1; - for (TrainingPlanDTO.PlanKnowledgeDTO dto : knowledgeList) { - // 验证知识是否存在 - Knowledge knowledge = knowledgeMapper.selectById(dto.getKnowledgeId()); - if (knowledge == null) { - throw new BusinessException(ResultCode.DATA_NOT_FOUND, "知识不存在: " + dto.getKnowledgeId()); - } - - PlanKnowledge pk = new PlanKnowledge(); - pk.setPlanId(planId); - pk.setKnowledgeId(dto.getKnowledgeId()); - pk.setRequired(dto.getRequired() != null ? dto.getRequired() : true); - pk.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : sortOrder++); - planKnowledgeMapper.insert(pk); - } - } - - private void savePlanExams(Long planId, List examList) { - if (examList == null || examList.isEmpty()) { - return; - } - - int sortOrder = 1; - for (TrainingPlanDTO.PlanExamDTO dto : examList) { - // 验证考试是否存在 - Exam exam = examMapper.selectById(dto.getExamId()); - if (exam == null) { - throw new BusinessException(ResultCode.EXAM_NOT_FOUND, "考试不存在: " + dto.getExamId()); - } - - PlanExam pe = new PlanExam(); - pe.setPlanId(planId); - pe.setExamId(dto.getExamId()); - pe.setRequired(dto.getRequired() != null ? dto.getRequired() : true); - pe.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : sortOrder++); - planExamMapper.insert(pe); - } - } - - private void savePlanTargets(Long planId, List targets) { - if (targets == null || targets.isEmpty()) { - return; - } - - for (TrainingPlanDTO.PlanTargetDTO dto : targets) { - PlanTarget target = new PlanTarget(); - target.setPlanId(planId); - target.setTargetType(TargetType.valueOf(dto.getTargetType())); - target.setTargetId(dto.getTargetId()); - planTargetMapper.insert(target); - } - } - - private boolean isUserTargeted(Long planId, User user) { - LambdaQueryWrapper targetQuery = new LambdaQueryWrapper<>(); - targetQuery.eq(PlanTarget::getPlanId, planId); - List targets = planTargetMapper.selectList(targetQuery); - - for (PlanTarget target : targets) { - switch (target.getTargetType()) { - case DEPARTMENT: - if (target.getTargetId().equals(user.getDepartmentId())) { - return true; - } - break; - case GROUP: - if (target.getTargetId().equals(user.getGroupId())) { - return true; - } - break; - case USER: - if (target.getTargetId().equals(user.getId())) { - return true; - } - break; - } - } - - return false; - } - - private TrainingPlanVO convertToVO(TrainingPlan plan, boolean includeDetails) { - TrainingPlanVO vo = new TrainingPlanVO(); - vo.setId(plan.getId()); - vo.setTitle(plan.getTitle()); - vo.setDescription(plan.getDescription()); - vo.setStartDate(plan.getStartDate()); - vo.setEndDate(plan.getEndDate()); - vo.setDepartmentId(plan.getDepartmentId()); - vo.setStatus(plan.getStatus().name()); - vo.setStatusName(plan.getStatus().getDesc()); - vo.setCreatorId(plan.getCreatorId()); - vo.setCreateTime(plan.getCreateTime()); - - // 获取部门名称 - if (plan.getDepartmentId() != null) { - Department department = departmentMapper.selectById(plan.getDepartmentId()); - if (department != null) { - vo.setDepartmentName(department.getName()); - } - } - - // 获取创建人姓名 - if (plan.getCreatorId() != null) { - User user = userMapper.selectById(plan.getCreatorId()); - if (user != null) { - vo.setCreatorName(user.getRealName()); - } - } - - // 获取知识数量 - Long knowledgeCount = planKnowledgeMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, plan.getId()) - ); - vo.setKnowledgeCount(knowledgeCount != null ? knowledgeCount.intValue() : 0); - - // 获取参与人数 - Long participantCount = planTargetMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanTarget::getPlanId, plan.getId()) - ); - vo.setParticipantCount(participantCount != null ? participantCount.intValue() : 0); - - // 获取考试任务数量 - Long examCount = planExamMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanExam::getPlanId, plan.getId()) - ); - vo.setExamCount(examCount != null ? examCount.intValue() : 0); - - if (includeDetails) { - // 获取关联知识列表 - List planKnowledgeList = planKnowledgeMapper.selectList( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, plan.getId()) - .orderByAsc(PlanKnowledge::getSortOrder) - ); - - List knowledgeVOList = new ArrayList<>(); - for (PlanKnowledge pk : planKnowledgeList) { - TrainingPlanVO.PlanKnowledgeVO pkVO = new TrainingPlanVO.PlanKnowledgeVO(); - pkVO.setId(pk.getId()); - pkVO.setKnowledgeId(pk.getKnowledgeId()); - pkVO.setRequired(pk.getRequired()); - pkVO.setSortOrder(pk.getSortOrder()); - - Knowledge knowledge = knowledgeMapper.selectById(pk.getKnowledgeId()); - if (knowledge != null) { - KnowledgeVO kvo = new KnowledgeVO(); - kvo.setId(knowledge.getId()); - kvo.setTitle(knowledge.getTitle()); - kvo.setType(knowledge.getType().name()); - kvo.setTypeName(knowledge.getType().getDesc()); - pkVO.setKnowledge(kvo); - } - knowledgeVOList.add(pkVO); - } - vo.setKnowledgeList(knowledgeVOList); - - // 获取关联考试列表 - List planExamList = planExamMapper.selectList( - new LambdaQueryWrapper() - .eq(PlanExam::getPlanId, plan.getId()) - .orderByAsc(PlanExam::getSortOrder) - ); - - List examVOList = new ArrayList<>(); - for (PlanExam pe : planExamList) { - TrainingPlanVO.PlanExamVO peVO = new TrainingPlanVO.PlanExamVO(); - peVO.setId(pe.getId()); - peVO.setExamId(pe.getExamId()); - peVO.setRequired(pe.getRequired()); - peVO.setSortOrder(pe.getSortOrder()); - - Exam exam = examMapper.selectById(pe.getExamId()); - if (exam != null) { - peVO.setExamTitle(exam.getTitle()); - } - examVOList.add(peVO); - } - vo.setExamList(examVOList); - - // 获取培训对象列表 - List planTargets = planTargetMapper.selectList( - new LambdaQueryWrapper() - .eq(PlanTarget::getPlanId, plan.getId()) - ); - - List targetVOList = new ArrayList<>(); - for (PlanTarget target : planTargets) { - TrainingPlanVO.PlanTargetVO targetVO = new TrainingPlanVO.PlanTargetVO(); - targetVO.setId(target.getId()); - targetVO.setTargetType(target.getTargetType().name()); - targetVO.setTargetTypeName(target.getTargetType().getDesc()); - targetVO.setTargetId(target.getTargetId()); - targetVO.setTargetName(getTargetName(target.getTargetType(), target.getTargetId())); - targetVOList.add(targetVO); - } - vo.setTargets(targetVOList); - } - - return vo; - } - - private MyPlanVO convertToMyPlanVO(TrainingPlan plan, Long userId, boolean includeKnowledgeProgress) { - MyPlanVO vo = new MyPlanVO(); - vo.setPlanId(plan.getId()); - vo.setPlanTitle(plan.getTitle()); - vo.setPlanDescription(plan.getDescription()); - vo.setStartDate(plan.getStartDate()); - vo.setEndDate(plan.getEndDate()); - vo.setStatus(plan.getStatus().name()); - vo.setStatusName(plan.getStatus().getDesc()); - - // 获取知识总数和已学习数量 - Long totalKnowledge = planKnowledgeMapper.selectCount( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, plan.getId()) - ); - vo.setTotalKnowledge(totalKnowledge != null ? totalKnowledge.intValue() : 0); - - Integer learnedKnowledge = planProgressService.getLearnedCount(plan.getId(), userId); - vo.setLearnedKnowledge(learnedKnowledge); - - // 获取考试总数和已通过数量 - List planExamList = planExamMapper.selectList( - new LambdaQueryWrapper() - .eq(PlanExam::getPlanId, plan.getId()) - .orderByAsc(PlanExam::getSortOrder) - ); - vo.setTotalExam(planExamList.size()); - - int passedExamCount = 0; - List examProgressList = new ArrayList<>(); - for (PlanExam pe : planExamList) { - MyPlanVO.ExamProgressVO epVO = new MyPlanVO.ExamProgressVO(); - epVO.setExamId(pe.getExamId()); - epVO.setRequired(pe.getRequired()); - boolean passed = hasPassedExam(pe.getExamId(), userId); - epVO.setPassed(passed); - if (passed) { - passedExamCount++; - } - - Exam exam = examMapper.selectById(pe.getExamId()); - if (exam != null) { - epVO.setExamTitle(exam.getTitle()); - } - examProgressList.add(epVO); - } - vo.setPassedExam(passedExamCount); - vo.setExamProgress(examProgressList); - - // 计算进度百分比(知识学习进度) - if (vo.getTotalKnowledge() > 0) { - vo.setProgressPercent((int) Math.round(learnedKnowledge * 100.0 / vo.getTotalKnowledge())); - } else { - vo.setProgressPercent(0); - } - - // 获取知识学习进度 - if (includeKnowledgeProgress) { - List planKnowledgeList = planKnowledgeMapper.selectList( - new LambdaQueryWrapper() - .eq(PlanKnowledge::getPlanId, plan.getId()) - .orderByAsc(PlanKnowledge::getSortOrder) - ); - - List progressList = new ArrayList<>(); - for (PlanKnowledge pk : planKnowledgeList) { - MyPlanVO.KnowledgeProgressVO kpVO = new MyPlanVO.KnowledgeProgressVO(); - kpVO.setKnowledgeId(pk.getKnowledgeId()); - kpVO.setRequired(pk.getRequired()); - kpVO.setLearned(planProgressService.hasLearned(plan.getId(), pk.getKnowledgeId(), userId)); - - Knowledge knowledge = knowledgeMapper.selectById(pk.getKnowledgeId()); - if (knowledge != null) { - kpVO.setKnowledgeTitle(knowledge.getTitle()); - kpVO.setKnowledgeType(knowledge.getType().name()); - } - progressList.add(kpVO); - } - vo.setKnowledgeProgress(progressList); - } - - return vo; - } - - private boolean hasPassedExam(Long examId, Long userId) { - Exam exam = examMapper.selectById(examId); - if (exam == null) { - return false; - } - - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - query.eq(ExamRecord::getExamId, examId) - .eq(ExamRecord::getUserId, userId) - .eq(ExamRecord::getStatus, 1) // 已提交 - .eq(ExamRecord::getPassed, true); - - Long count = examRecordMapper.selectCount(query); - return count != null && count > 0; - } - - private String getTargetName(TargetType type, Long targetId) { - return switch (type) { - case DEPARTMENT -> { - Department dept = departmentMapper.selectById(targetId); - yield dept != null ? dept.getName() : ""; - } - case GROUP -> { - Group group = groupMapper.selectById(targetId); - yield group != null ? group.getName() : ""; - } - case USER -> { - User user = userMapper.selectById(targetId); - yield user != null ? user.getRealName() : ""; - } - }; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/training/vo/MyPlanVO.java b/training-system/src/main/java/com/sino/training/module/training/vo/MyPlanVO.java deleted file mode 100644 index 24fb194..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/vo/MyPlanVO.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.sino.training.module.training.vo; - -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDate; -import java.util.List; - -/** - * 我的培训计划VO - * - * @author training-system - */ -@Data -public class MyPlanVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long planId; - private String planTitle; - private String planDescription; - private LocalDate startDate; - private LocalDate endDate; - private String status; - private String statusName; - - /** - * 进度百分比 - */ - private Integer progressPercent; - - /** - * 总知识数量 - */ - private Integer totalKnowledge; - - /** - * 已学习知识数量 - */ - private Integer learnedKnowledge; - - /** - * 总考试数量 - */ - private Integer totalExam; - - /** - * 已通过考试数量 - */ - private Integer passedExam; - - /** - * 知识学习进度列表 - */ - private List knowledgeProgress; - - /** - * 考试进度列表 - */ - private List examProgress; - - @Data - public static class KnowledgeProgressVO implements Serializable { - private Long knowledgeId; - private String knowledgeTitle; - private String knowledgeType; - private Boolean required; - private Boolean learned; - } - - @Data - public static class ExamProgressVO implements Serializable { - private Long examId; - private String examTitle; - private Boolean required; - private Boolean passed; - } -} diff --git a/training-system/src/main/java/com/sino/training/module/training/vo/TrainingPlanVO.java b/training-system/src/main/java/com/sino/training/module/training/vo/TrainingPlanVO.java deleted file mode 100644 index 264fbc9..0000000 --- a/training-system/src/main/java/com/sino/training/module/training/vo/TrainingPlanVO.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.sino.training.module.training.vo; - -import com.sino.training.module.knowledge.vo.KnowledgeVO; -import lombok.Data; - -import java.io.Serializable; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 培训计划VO - * - * @author training-system - */ -@Data -public class TrainingPlanVO implements Serializable { - - private static final long serialVersionUID = 1L; - - private Long id; - private String title; - private String description; - private LocalDate startDate; - private LocalDate endDate; - private Long departmentId; - private String departmentName; - private String status; - private String statusName; - private Long creatorId; - private String creatorName; - private LocalDateTime createTime; - - /** - * 知识数量 - */ - private Integer knowledgeCount; - - /** - * 参与人数 - */ - private Integer participantCount; - - /** - * 考试任务数量 - */ - private Integer examCount; - - /** - * 关联知识列表 - */ - private List knowledgeList; - - /** - * 关联考试列表 - */ - private List examList; - - /** - * 培训对象列表 - */ - private List targets; - - /** - * 当前用户的进度(学员查看时) - */ - private Integer progressPercent; - - /** - * 当前用户是否完成(学员查看时) - */ - private Boolean completed; - - @Data - public static class PlanKnowledgeVO implements Serializable { - private Long id; - private Long knowledgeId; - private Boolean required; - private Integer sortOrder; - private KnowledgeVO knowledge; - /** - * 当前用户是否已学习 - */ - private Boolean learned; - } - - @Data - public static class PlanExamVO implements Serializable { - private Long id; - private Long examId; - private String examTitle; - private Boolean required; - private Integer sortOrder; - /** - * 当前用户是否已通过 - */ - private Boolean passed; - } - - @Data - public static class PlanTargetVO implements Serializable { - private Long id; - private String targetType; - private String targetTypeName; - private Long targetId; - private String targetName; - } -} diff --git a/training-system/src/main/resources/application-dev.yml b/training-system/src/main/resources/application-dev.yml deleted file mode 100644 index 63779fd..0000000 --- a/training-system/src/main/resources/application-dev.yml +++ /dev/null @@ -1,23 +0,0 @@ -# 开发环境配置 -spring: - # 数据源配置 - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://192.168.1.226:3306/training_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true - username: root - password: root - -# 企业微信配置(开发环境) -wechat: -cp: - corp-id: ww0b2dc90421854148 - agent-id: 1000008 - secret: AQSWrg_TMHaWHXpiVLLUkTepUzcE4JjZLmUTl48ezMA - # OAuth2 回调地址 - oauth2-redirect-uri: http://localhost:8080/auth/wechat/callback - -# 日志配置 -logging: - level: - com.sino.training: debug - com.baomidou.mybatisplus: debug diff --git a/training-system/src/main/resources/application-prod.yml b/training-system/src/main/resources/application-prod.yml deleted file mode 100644 index 1a3c9ae..0000000 --- a/training-system/src/main/resources/application-prod.yml +++ /dev/null @@ -1,44 +0,0 @@ -# 生产环境配置 -spring: - # 数据源配置 - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:training_system}?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 - username: ${DB_USERNAME:root} - password: ${DB_PASSWORD:root} - # 生产环境开启模板缓存 - thymeleaf: - cache: true - -# 企业微信配置(生产环境,通过环境变量注入) -wechat: - cp: - corp-id: ${WECHAT_CORP_ID} - agent-id: ${WECHAT_AGENT_ID} - secret: ${WECHAT_SECRET} - oauth2-redirect-uri: ${WECHAT_OAUTH2_REDIRECT_URI} - -# JWT 配置(生产环境使用环境变量) -training: - jwt: - secret: ${JWT_SECRET:training-system-jwt-secret-key-prod} - -# 日志配置 -logging: - level: - com.sino.training: info - com.baomidou.mybatisplus: warn - file: - name: ./logs/training-system.log - -# MyBatis Plus 生产环境关闭SQL日志 -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl - -# Springdoc 生产环境可选择关闭 -springdoc: - api-docs: - enabled: false - swagger-ui: - enabled: false diff --git a/training-system/src/main/resources/application.yml b/training-system/src/main/resources/application.yml deleted file mode 100644 index a1613b8..0000000 --- a/training-system/src/main/resources/application.yml +++ /dev/null @@ -1,66 +0,0 @@ -# 道路救援企业培训系统 - 主配置文件 -server: - port: 8080 - servlet: - context-path: / - -spring: - profiles: - active: dev - application: - name: training-system - # Thymeleaf 模板配置 - thymeleaf: - prefix: classpath:/templates/ - suffix: .html - mode: HTML - encoding: UTF-8 - cache: false - # 文件上传配置 - servlet: - multipart: - enabled: true - max-file-size: 100MB - max-request-size: 100MB - # Jackson 配置 - jackson: - date-format: yyyy-MM-dd HH:mm:ss - time-zone: GMT+8 - default-property-inclusion: non_null - -# MyBatis Plus 配置 -mybatis-plus: - mapper-locations: classpath:/mapper/**/*.xml - type-aliases-package: com.sino.training.module.*.entity - global-config: - db-config: - id-type: auto - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 - configuration: - map-underscore-to-camel-case: true - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl - -# Springdoc OpenAPI 配置 -springdoc: - api-docs: - enabled: true - path: /v3/api-docs - swagger-ui: - enabled: true - path: /swagger-ui.html - -# 自定义配置 -training: - # JWT 配置 - jwt: - secret: training-system-jwt-secret-key-2026 - expire: 86400000 # 24小时,单位毫秒 - header: Authorization - prefix: Bearer - # 文件上传配置 - upload: - path: D:/upload/ - allowed-types: pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif,mp4,avi,mov - max-size: 104857600 # 100MB diff --git a/training-system/src/main/resources/mapper/.gitkeep b/training-system/src/main/resources/mapper/.gitkeep deleted file mode 100644 index 16742ca..0000000 --- a/training-system/src/main/resources/mapper/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# This file ensures the mapper directory is tracked by git diff --git a/training-system/src/main/resources/static/css/common.css b/training-system/src/main/resources/static/css/common.css deleted file mode 100644 index 30327ce..0000000 --- a/training-system/src/main/resources/static/css/common.css +++ /dev/null @@ -1,615 +0,0 @@ -/** - * 道路救援企业培训系统 - 公共样式 - * 基于 Bootstrap 5 扩展 - */ - -/* ========== CSS变量体系 ========== */ -:root { - /* 主色系 - 蓝色 */ - --primary-color: #0d6efd; - --primary-hover: #0b5ed7; - --primary-light: rgba(13, 110, 253, 0.1); - --primary-gradient: linear-gradient(135deg, #0d6efd 0%, #0dcaf0 100%); - - /* 侧边栏 */ - --sidebar-bg: linear-gradient(180deg, #1a1c23 0%, #2d3748 100%); - --sidebar-width: 250px; - - /* 文字颜色 */ - --text-primary: #1a1c23; - --text-secondary: #6c757d; - --text-muted: #94a3b8; - - /* 边框与背景 */ - --border-color: #e9ecef; - --bg-light: #f8f9fa; - --bg-card: #ffffff; - --bg-hover: rgba(255, 255, 255, 0.08); - - /* 状态色 */ - --success-color: #198754; - --success-light: rgba(25, 135, 84, 0.1); - --warning-color: #ffc107; - --warning-light: rgba(255, 193, 7, 0.1); - --danger-color: #dc3545; - --danger-light: rgba(220, 53, 69, 0.1); - --info-color: #0dcaf0; - --info-light: rgba(13, 202, 240, 0.1); - - /* 阴影 */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15); - - /* 圆角 */ - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; - - /* 间距 */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 16px; - --spacing-lg: 24px; - --spacing-xl: 32px; - - /* 动画 */ - --transition-fast: 0.15s ease; - --transition-normal: 0.2s ease; - --transition-slow: 0.3s ease; -} - -/* ========== 布局相关 ========== */ -html, body { - height: 100%; -} - -/* 主布局容器 */ -.layout-wrapper { - display: flex; - min-height: 100vh; -} - -/* ========== 侧边栏 ========== */ -.sidebar { - width: var(--sidebar-width); - min-height: 100vh; - background: var(--sidebar-bg); - position: fixed; - left: 0; - top: 0; - z-index: 1000; - transition: all var(--transition-slow); -} - -.sidebar-logo { - height: 64px; - display: flex; - align-items: center; - padding: 0 20px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.sidebar-logo-icon { - width: 36px; - height: 36px; - background: var(--primary-gradient); - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - margin-right: 12px; -} - -.sidebar-logo-text { - color: #fff; - font-size: 16px; - font-weight: 600; - white-space: nowrap; -} - -/* 侧边栏菜单 */ -.sidebar-menu { - padding: 16px 0; - overflow-y: auto; - height: calc(100vh - 64px); -} - -.sidebar-menu::-webkit-scrollbar { - width: 4px; -} - -.sidebar-menu::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; -} - -.menu-item { - display: flex; - align-items: center; - padding: 12px 20px; - color: rgba(255, 255, 255, 0.7); - text-decoration: none; - transition: all var(--transition-normal); - cursor: pointer; - border-left: 3px solid transparent; -} - -.menu-item:hover { - color: #fff; - background: var(--bg-hover); -} - -.menu-item.active { - color: #fff; - background: var(--primary-light); - border-left-color: var(--primary-color); -} - -.menu-item i { - font-size: 18px; - width: 24px; - margin-right: 12px; - text-align: center; -} - -.menu-group-title { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 20px; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - transition: all 0.2s ease; -} - -.menu-group-title:hover { - color: #fff; - background: rgba(255, 255, 255, 0.05); -} - -.menu-group-title i.arrow { - font-size: 12px; - transition: transform 0.2s ease; -} - -.menu-group.collapsed .menu-group-title i.arrow { - transform: rotate(-90deg); -} - -.menu-group.collapsed .menu-submenu { - display: none; -} - -.menu-submenu { - background: rgba(0, 0, 0, 0.15); -} - -.menu-submenu .menu-item { - padding-left: 56px; - font-size: 14px; -} - -/* 侧边栏分隔线 */ -.sidebar-divider { - border-top: 1px solid rgba(255, 255, 255, 0.1); - margin: var(--spacing-md) 0; -} - -/* ========== 主内容区 ========== */ -.main-wrapper { - flex: 1; - margin-left: var(--sidebar-width); - min-height: 100vh; - background-color: var(--bg-light); - display: flex; - flex-direction: column; -} - -/* 顶部导航 */ -.top-header { - height: 64px; - background: var(--bg-card); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--spacing-lg); - position: sticky; - top: 0; - z-index: 100; -} - -.header-title { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); -} - -.header-user { - display: flex; - align-items: center; - cursor: pointer; -} - -.header-user .avatar { - width: 36px; - height: 36px; - border-radius: 50%; - background: var(--primary-gradient); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - margin-right: var(--spacing-sm); -} - -/* 内容区域 */ -.main-content { - flex: 1; - padding: var(--spacing-lg); -} - -/* ========== 页面卡片 ========== */ -.page-card { - background: var(--bg-card); - border-radius: var(--radius-md); - box-shadow: var(--shadow-sm); - margin-bottom: var(--spacing-lg); -} - -.page-card-header { - padding: var(--spacing-md) var(--spacing-lg); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-between; -} - -.page-card-title { - font-size: 16px; - font-weight: 600; - color: var(--text-primary); - margin: 0; -} - -.page-card-body { - padding: var(--spacing-lg); -} - -/* ========== 统计卡片 ========== */ -.stat-card { - background: var(--bg-card); - border-radius: var(--radius-md); - padding: var(--spacing-lg); - box-shadow: var(--shadow-sm); - transition: all var(--transition-normal); -} - -.stat-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.stat-card-icon { - width: 48px; - height: 48px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - margin-bottom: var(--spacing-md); -} - -.stat-card-icon.primary { - background: var(--primary-light); - color: var(--primary-color); -} - -.stat-card-icon.success { - background: var(--success-light); - color: var(--success-color); -} - -.stat-card-icon.warning { - background: var(--warning-light); - color: var(--warning-color); -} - -.stat-card-icon.info { - background: var(--info-light); - color: var(--info-color); -} - -.stat-card-value { - font-size: 28px; - font-weight: 700; - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.stat-card-label { - font-size: 14px; - color: var(--text-secondary); -} - -/* ========== 工具栏 ========== */ -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-lg); -} - -.toolbar-left { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-sm); -} - -.toolbar-right { - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -/* ========== 表格增强 ========== */ -.table-wrapper { - overflow-x: auto; -} - -.table th { - font-weight: 600; - color: var(--text-secondary); - background-color: var(--bg-light); - white-space: nowrap; -} - -.table td { - vertical-align: middle; -} - -.table .actions { - white-space: nowrap; -} - -.table .actions .btn { - padding: 4px 8px; - font-size: 13px; -} - -/* ========== 状态标签 ========== */ -.status-badge { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: 20px; - font-size: 12px; - font-weight: 500; -} - -.status-badge.draft { - background: #fff3cd; - color: #856404; -} - -.status-badge.published { - background: #d1e7dd; - color: #0f5132; -} - -.status-badge.offline { - background: #f8d7da; - color: #842029; -} - -.status-badge.not-started { - background: #e2e3e5; - color: #41464b; -} - -.status-badge.in-progress { - background: #cff4fc; - color: #055160; -} - -.status-badge.ended { - background: #d1e7dd; - color: #0f5132; -} - -/* ========== 空状态 ========== */ -.empty-state { - text-align: center; - padding: 48px var(--spacing-lg); - color: var(--text-secondary); -} - -.empty-state i { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; -} - -.empty-state p { - margin: 0; - font-size: 14px; -} - -/* ========== 加载状态 ========== */ -.loading-state { - display: flex; - align-items: center; - justify-content: center; - padding: 48px; -} - -/* ========== 文件上传区域 ========== */ -.upload-area { - border: 2px dashed var(--border-color); - border-radius: var(--radius-md); - padding: var(--spacing-xl); - text-align: center; - cursor: pointer; - transition: all var(--transition-normal); - background: var(--bg-light); -} - -.upload-area:hover { - border-color: var(--primary-color); - background: rgba(13, 110, 253, 0.02); -} - -.upload-area.dragover { - border-color: var(--primary-color); - background: rgba(13, 110, 253, 0.05); -} - -.upload-area i { - font-size: 48px; - color: var(--text-secondary); - margin-bottom: var(--spacing-md); -} - -.upload-area p { - margin: 0; - color: var(--text-secondary); -} - -.upload-area .upload-hint { - font-size: 12px; - margin-top: 8px; -} - -/* ========== 进度条 ========== */ -.progress-wrapper { - display: flex; - align-items: center; - gap: 12px; -} - -.progress-wrapper .progress { - flex: 1; - height: 8px; -} - -.progress-wrapper .progress-text { - font-size: 13px; - font-weight: 500; - color: #495057; - min-width: 40px; - text-align: right; -} - -/* ========== 消息提示(Toast增强) ========== */ -.toast-container { - position: fixed; - top: 24px; - right: 24px; - z-index: 1100; -} - -/* ========== 答题卡样式 ========== */ -.answer-sheet { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); -} - -.answer-sheet-item { - width: 36px; - height: 36px; - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: 500; - cursor: pointer; - border: 1px solid var(--border-color); - background: var(--bg-card); - transition: all var(--transition-normal); -} - -.answer-sheet-item:hover { - border-color: var(--primary-color); -} - -.answer-sheet-item.current { - border-color: var(--primary-color); - background: var(--primary-color); - color: #fff; -} - -.answer-sheet-item.answered { - background: #d1e7dd; - border-color: var(--success-color); - color: var(--success-color); -} - -/* ========== 考试计时器 ========== */ -.exam-timer { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-md); - background: var(--warning-light); - border-radius: var(--radius-md); - font-weight: 600; -} - -.exam-timer.warning { - background: var(--danger-light); - color: var(--danger-color); -} - -.exam-timer i { - font-size: 18px; -} - -/* ========== 响应式 ========== */ -@media (max-width: 992px) { - .sidebar { - transform: translateX(-100%); - } - - .sidebar.show { - transform: translateX(0); - } - - .main-wrapper { - margin-left: 0; - } - - .top-header { - padding: 0 var(--spacing-md); - } - - .main-content { - padding: var(--spacing-md); - } -} - -@media (max-width: 576px) { - .toolbar { - flex-direction: column; - align-items: stretch; - } - - .toolbar-left, - .toolbar-right { - width: 100%; - justify-content: flex-start; - } - - .stat-card-value { - font-size: 24px; - } -} diff --git a/training-system/src/main/resources/static/exam/edit.html b/training-system/src/main/resources/static/exam/edit.html deleted file mode 100644 index 3c34dab..0000000 --- a/training-system/src/main/resources/static/exam/edit.html +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - 发布考试 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
考试设置
-
-
- -
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
选择参与人员
-
-
-
- - 已选择 0 人参与考试 -
-
-
- -
-
-
- - -
-
-
-
-
- - -
-
-
-
加载中...
-
-
-
-
- 返回 - -
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/list.html b/training-system/src/main/resources/static/exam/list.html deleted file mode 100644 index ae64918..0000000 --- a/training-system/src/main/resources/static/exam/list.html +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - 考试管理 - 道路救援培训系统 - - - - - -
-
-
-
- - -
-
-
-
-
考试管理
- 发布考试 -
-
-
-
-
- - -
- -
-
- -
-
-
- - - - - - - - - - - - - - - -
考试名称试卷考试时间参与人数及格率状态操作
-
- -
-
-
-
-
- - - - - - - - - diff --git a/training-system/src/main/resources/static/exam/my-exams.html b/training-system/src/main/resources/static/exam/my-exams.html deleted file mode 100644 index 71e8414..0000000 --- a/training-system/src/main/resources/static/exam/my-exams.html +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - 我的考试 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
我的考试
-
-
-
-
全部
-
进行中
-
待开始
-
已结束
-
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/paper-edit.html b/training-system/src/main/resources/static/exam/paper-edit.html deleted file mode 100644 index 0a0ebfc..0000000 --- a/training-system/src/main/resources/static/exam/paper-edit.html +++ /dev/null @@ -1,506 +0,0 @@ - - - - - - 编辑试卷 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
试卷设置
-
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- 题目数量 - 0 -
-
- 总分 - 0 -
-
-
-
-
-
-
-
-
组卷方式
-
-
-
-
- -
手动选题
-
-
- -
随机抽题
-
-
- - -
-
-
- -
-
- -
-
-
- - -
-
-
-
-
加载中...
-
-
- -
-
- - - -
-
- -
-
-
试卷题目 0
- -
-
-

请从左侧题库中选择题目

-
-
- -
- 返回 - - -
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/paper-preview.html b/training-system/src/main/resources/static/exam/paper-preview.html deleted file mode 100644 index a19ce62..0000000 --- a/training-system/src/main/resources/static/exam/paper-preview.html +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - 试卷预览 - 道路救援培训系统 - - - - - - -
-
-
-
- -
- - -
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/paper.html b/training-system/src/main/resources/static/exam/paper.html deleted file mode 100644 index 3f2c5ab..0000000 --- a/training-system/src/main/resources/static/exam/paper.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - 试卷管理 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
试卷管理
- 创建试卷 -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/question-category.html b/training-system/src/main/resources/static/exam/question-category.html deleted file mode 100644 index 1eb2c5e..0000000 --- a/training-system/src/main/resources/static/exam/question-category.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - 题库分类 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
题库分类
-
-
-
-
-
-
分类详情
-

请从左侧选择一个分类查看详情

-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/question-edit.html b/training-system/src/main/resources/static/exam/question-edit.html deleted file mode 100644 index 4e9d319..0000000 --- a/training-system/src/main/resources/static/exam/question-edit.html +++ /dev/null @@ -1,426 +0,0 @@ - - - - - - 编辑题目 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
新增题目
-
-
-
- - - -
- -
-
-
- -
单选题
- 只有一个正确答案 -
-
-
-
- -
多选题
- 可以有多个正确答案 -
-
-
-
- -
判断题
- 判断对错 -
-
-
-
- -
-
- - -
-
- - -
-
- - -
-
- -
- - -
- - -
-
-
- - -
-
-
-
A
- - -
-
-
B
- - -
-
-
C
- - -
-
-
D
- - -
-
-
- -
- -
- - - - -
- 点击选择正确答案 -
-
- - - - -
- - -
- -
- 返回 - -
-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/question.html b/training-system/src/main/resources/static/exam/question.html deleted file mode 100644 index 687e5fb..0000000 --- a/training-system/src/main/resources/static/exam/question.html +++ /dev/null @@ -1,383 +0,0 @@ - - - - - - 题目列表 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
题库分类
-
-
- 全部题目 - 0 -
- -
-
-
-
-
-
-
题目列表
- 新增题目 -
-
-
-
-
- - -
- - -
-
- - -
-
-
-
-
- -
-
-
-
-
-
-
- - - - - - - - - diff --git a/training-system/src/main/resources/static/exam/result.html b/training-system/src/main/resources/static/exam/result.html deleted file mode 100644 index ab40205..0000000 --- a/training-system/src/main/resources/static/exam/result.html +++ /dev/null @@ -1,286 +0,0 @@ - - - - - - 考试成绩 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/exam/taking.html b/training-system/src/main/resources/static/exam/taking.html deleted file mode 100644 index 6a937d4..0000000 --- a/training-system/src/main/resources/static/exam/taking.html +++ /dev/null @@ -1,332 +0,0 @@ - - - - - - 在线考试 - 道路救援培训系统 - - - - - - -
-
-
-
-
考试加载中...
-
- - - 0 - 满分 0 -
-
-
- - 00:00:00 -
-
-
-
-
-
-
-
-

正在加载试卷...

-
-
-
- -
-
- - - - - - diff --git a/training-system/src/main/resources/static/index.html b/training-system/src/main/resources/static/index.html deleted file mode 100644 index e218261..0000000 --- a/training-system/src/main/resources/static/index.html +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - 工作台 - 道路救援培训系统 - - - - - - - - -
- -
- - -
- -
-
- 工作台 -
- -
- - -
- -
-
-
-
-
-
-
-

欢迎回来,用户

-

2026年1月8日 星期三

-
-
-
-
-
-
- - -
-
-
-
- -
-
0
-
待学习课程
-
-
-
-
-
- -
-
0
-
待完成考试
-
-
-
-
-
- -
-
0%
-
培训进度
-
-
-
-
-
- -
-
0h
-
本月学习时长
-
-
-
- -
- -
-
-
-
- 我的培训计划 -
- 查看全部 -
-
-
- - - - - - - - - - - - - - -
培训名称状态进度截止日期
-
- -

暂无培训计划

-
-
-
-
-
-
- - -
-
-
-
- 待完成考试 -
- 查看全部 -
-
-
- - - - - - - - - - - - - - -
考试名称考试时长剩余次数操作
-
- -

暂无待完成考试

-
-
-
-
-
-
-
- - -
-
- -
-
-
-
-
- - - - - - - diff --git a/training-system/src/main/resources/static/js/common.js b/training-system/src/main/resources/static/js/common.js deleted file mode 100644 index dad4929..0000000 --- a/training-system/src/main/resources/static/js/common.js +++ /dev/null @@ -1,705 +0,0 @@ -/** - * 道路救援企业培训系统 - 公共JS - * 基于 Bootstrap 5 - */ - -// API 基础路径 -const API_BASE_URL = '/api'; - -// Token 存储键名 -const TOKEN_KEY = 'training_token'; -const USER_KEY = 'training_user'; - -/** - * 获取存储的Token - */ -function getToken() { - return localStorage.getItem(TOKEN_KEY); -} - -/** - * 设置Token - */ -function setToken(token) { - localStorage.setItem(TOKEN_KEY, token); -} - -/** - * 移除Token - */ -function removeToken() { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); -} - -/** - * 获取当前用户信息 - */ -function getCurrentUser() { - const userStr = localStorage.getItem(USER_KEY); - if (!userStr || userStr === 'undefined' || userStr === 'null') { - return null; - } - return JSON.parse(userStr); -} - -/** - * 设置当前用户信息 - */ -function setCurrentUser(user) { - localStorage.setItem(USER_KEY, JSON.stringify(user)); -} - -/** - * 检查是否已登录 - */ -function isLoggedIn() { - return !!getToken(); -} - -/** - * 封装的 fetch 请求 - */ -async function request(url, options = {}) { - const token = getToken(); - - const defaultOptions = { - headers: { - 'Content-Type': 'application/json', - ...(token ? { 'Authorization': `Bearer ${token}` } : {}) - } - }; - - const mergedOptions = { - ...defaultOptions, - ...options, - headers: { - ...defaultOptions.headers, - ...options.headers - } - }; - - try { - const response = await fetch(API_BASE_URL + url, mergedOptions); - const data = await response.json(); - - // 处理未授权 - if (data.code === 401 || data.code === 1101 || data.code === 1102) { - removeToken(); - window.location.href = '/login.html'; - return null; - } - - return data; - } catch (error) { - console.error('请求失败:', error); - showMessage('网络请求失败', 'danger'); - return null; - } -} - -/** - * GET 请求 - */ -async function get(url, params = {}) { - const queryString = new URLSearchParams(params).toString(); - const fullUrl = queryString ? `${url}?${queryString}` : url; - return request(fullUrl, { method: 'GET' }); -} - -/** - * POST 请求 - */ -async function post(url, data = {}) { - return request(url, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -/** - * PUT 请求 - */ -async function put(url, data = {}) { - return request(url, { - method: 'PUT', - body: JSON.stringify(data) - }); -} - -/** - * DELETE 请求 - */ -async function del(url) { - return request(url, { method: 'DELETE' }); -} - -/** - * 文件上传 - */ -async function upload(url, file, onProgress) { - const token = getToken(); - const formData = new FormData(); - formData.append('file', file); - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && onProgress) { - const percent = Math.round((e.loaded / e.total) * 100); - onProgress(percent); - } - }); - - xhr.addEventListener('load', () => { - if (xhr.status === 200) { - resolve(JSON.parse(xhr.responseText)); - } else { - reject(new Error('上传失败')); - } - }); - - xhr.addEventListener('error', () => { - reject(new Error('上传失败')); - }); - - xhr.open('POST', API_BASE_URL + url); - if (token) { - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - } - xhr.send(formData); - }); -} - -/** - * 显示消息提示 (Bootstrap Toast) - * @param {string} text - 消息文本 - * @param {string} type - 类型: success, danger, warning, info - */ -function showMessage(text, type = 'info') { - // 确保toast容器存在 - let container = document.querySelector('.toast-container'); - if (!container) { - container = document.createElement('div'); - container.className = 'toast-container position-fixed top-0 end-0 p-3'; - container.style.zIndex = '1100'; - document.body.appendChild(container); - } - - // 图标映射 - const icons = { - success: 'bi-check-circle-fill', - danger: 'bi-x-circle-fill', - warning: 'bi-exclamation-triangle-fill', - info: 'bi-info-circle-fill' - }; - - // 创建toast元素 - const toastId = 'toast-' + Date.now(); - const toastHtml = ` - - `; - - container.insertAdjacentHTML('beforeend', toastHtml); - - const toastEl = document.getElementById(toastId); - const toast = new bootstrap.Toast(toastEl, { delay: 3000 }); - toast.show(); - - // 自动移除DOM - toastEl.addEventListener('hidden.bs.toast', () => { - toastEl.remove(); - }); -} - -/** - * 显示确认对话框 (Bootstrap Modal) - */ -function showConfirm(text, onConfirm, onCancel) { - // 确保modal容器存在 - let modal = document.getElementById('confirmModal'); - if (!modal) { - const modalHtml = ` - - `; - document.body.insertAdjacentHTML('beforeend', modalHtml); - modal = document.getElementById('confirmModal'); - } - - document.getElementById('confirmModalBody').textContent = text; - - const bsModal = new bootstrap.Modal(modal); - - const okBtn = document.getElementById('confirmModalOk'); - const newOkBtn = okBtn.cloneNode(true); - okBtn.parentNode.replaceChild(newOkBtn, okBtn); - - newOkBtn.addEventListener('click', () => { - bsModal.hide(); - onConfirm && onConfirm(); - }); - - modal.addEventListener('hidden.bs.modal', () => { - // 如果是点击取消或关闭 - }, { once: true }); - - bsModal.show(); -} - -/** - * 格式化日期 - */ -function formatDate(dateStr, format = 'YYYY-MM-DD') { - if (!dateStr) return '-'; - const date = new Date(dateStr); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - - return format - .replace('YYYY', year) - .replace('MM', month) - .replace('DD', day) - .replace('HH', hours) - .replace('mm', minutes) - .replace('ss', seconds); -} - -/** - * 格式化日期时间 - */ -function formatDateTime(dateStr) { - return formatDate(dateStr, 'YYYY-MM-DD HH:mm'); -} - -/** - * 格式化文件大小 - */ -function formatFileSize(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -/** - * 防抖函数 - */ -function debounce(func, wait) { - let timeout; - return function(...args) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} - -/** - * 节流函数 - */ -function throttle(func, limit) { - let inThrottle; - return function(...args) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; -} - -/** - * 获取URL参数 - */ -function getUrlParam(name) { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get(name); -} - -/** - * 生成分页组件 (Bootstrap Pagination) - */ -function renderPagination(current, total, pageSize, onPageChange) { - const totalPages = Math.ceil(total / pageSize); - if (totalPages <= 1) return ''; - - let html = ''; - - // 添加总数信息 - html = `
- 共 ${total} 条记录 - ${html} -
`; - - return html; -} - -/** - * 初始化页面(检查登录状态) - */ -function initPage() { - if (!isLoggedIn()) { - window.location.href = '/login.html'; - return false; - } - initUserInfo(); - initMenuState(); - return true; -} - -/** - * 初始化用户信息显示 - */ -function initUserInfo() { - const user = getCurrentUser(); - if (user) { - const userName = document.getElementById('userName'); - const userAvatar = document.getElementById('userAvatar'); - if (userName) userName.textContent = user.realName || user.username; - if (userAvatar) userAvatar.textContent = (user.realName || user.username).charAt(0); - } -} - -/** - * 初始化菜单展开/折叠状态 - */ -function initMenuState() { - document.querySelectorAll('.menu-group-title').forEach(title => { - title.addEventListener('click', function() { - const group = this.closest('.menu-group'); - group.classList.toggle('collapsed'); - }); - }); -} - -/** - * 退出登录 - */ -function logout() { - showConfirm('确定要退出登录吗?', () => { - removeToken(); - window.location.href = '/login.html'; - }); -} - -/** - * 获取状态标签HTML - */ -function getStatusBadge(status, statusName) { - const statusClass = { - 'DRAFT': 'draft', - 'PUBLISHED': 'published', - 'OFFLINE': 'offline', - 'NOT_STARTED': 'not-started', - 'IN_PROGRESS': 'in-progress', - 'ENDED': 'ended' - }; - return `${statusName || status}`; -} - -/** - * 菜单配置 - 根据角色控制可见性 - * roles: ADMIN=管理员, LECTURER=讲师, STUDENT=学员 - */ -const MENU_CONFIG = [ - { - id: 'dashboard', - name: '工作台', - icon: 'bi-grid-1x2', - url: '/index.html', - roles: ['ADMIN', 'LECTURER', 'STUDENT'] - }, - { - id: 'system', - name: '人员管理', - icon: 'bi-people', - roles: ['ADMIN', 'LECTURER'], - children: [ - { id: 'org', name: '组织架构', url: '/system/org.html', roles: ['ADMIN'] }, - { id: 'user', name: '员工管理', url: '/system/user.html', roles: ['ADMIN', 'LECTURER'] } - ] - }, - { - id: 'knowledge', - name: '知识库', - icon: 'bi-journal-richtext', - roles: ['ADMIN', 'LECTURER', 'STUDENT'], - children: [ - { id: 'knowledge-category', name: '知识分类', url: '/knowledge/category.html', roles: ['ADMIN', 'LECTURER'] }, - { id: 'knowledge-list', name: '知识列表', url: '/knowledge/list.html', roles: ['ADMIN', 'LECTURER', 'STUDENT'] } - ] - }, - { - id: 'question', - name: '考题管理', - icon: 'bi-pencil-square', - roles: ['ADMIN', 'LECTURER'], - children: [ - { id: 'question-category', name: '题库分类', url: '/exam/question-category.html', roles: ['ADMIN', 'LECTURER'] }, - { id: 'question-list', name: '题目列表', url: '/exam/question.html', roles: ['ADMIN', 'LECTURER'] } - ] - }, - { - id: 'paper', - name: '试卷管理', - icon: 'bi-file-earmark-text', - url: '/exam/paper.html', - roles: ['ADMIN', 'LECTURER'] - }, - { - id: 'exam', - name: '考试管理', - icon: 'bi-clipboard-check', - url: '/exam/list.html', - roles: ['ADMIN', 'LECTURER', 'STUDENT'] - }, - { - id: 'training', - name: '培训计划', - icon: 'bi-calendar-check', - url: '/training/plan.html', - roles: ['ADMIN', 'LECTURER', 'STUDENT'] - }, - { - id: 'divider', - type: 'divider', - roles: ['ADMIN'] - }, - { - id: 'setting', - name: '系统设置', - icon: 'bi-gear', - url: '/system/setting.html', - roles: ['ADMIN'] - } -]; - -/** - * 根据角色过滤菜单 - */ -function filterMenuByRole(menuConfig, userRole) { - return menuConfig - .filter(item => item.roles.includes(userRole)) - .map(item => { - if (item.children) { - return { - ...item, - children: item.children.filter(child => child.roles.includes(userRole)) - }; - } - return item; - }) - .filter(item => !item.children || item.children.length > 0); -} - -/** - * 生成侧边栏HTML - */ -function renderSidebar(activeMenu) { - const user = getCurrentUser(); - const userRole = user?.role || 'STUDENT'; - const filteredMenu = filterMenuByRole(MENU_CONFIG, userRole); - - let menuHtml = ''; - filteredMenu.forEach(item => { - if (item.type === 'divider') { - menuHtml += ''; - return; - } - - if (item.children) { - // 判断子菜单是否有激活项 - const hasActiveChild = item.children.some(child => child.id === activeMenu); - menuHtml += ` - - `; - } else { - menuHtml += ` - - - ${item.name} - - `; - } - }); - - return ` - - `; -} - -/** - * 初始化侧边栏(动态渲染到页面) - */ -function initSidebar(activeMenu) { - const sidebarContainer = document.getElementById('sidebarContainer'); - if (sidebarContainer) { - sidebarContainer.innerHTML = renderSidebar(activeMenu); - initMenuState(); - } -} - -/** - * 生成顶部导航HTML - */ -function renderHeader(title, breadcrumb = []) { - let breadcrumbHtml = ''; - if (breadcrumb.length > 0) { - breadcrumbHtml = ` - - `; - } - - return ` -
-
- ${breadcrumbHtml || `${title}`} -
- -
- `; -} - -// 导出全局函数 -window.TrainingSystem = { - getToken, - setToken, - removeToken, - getCurrentUser, - setCurrentUser, - isLoggedIn, - request, - get, - post, - put, - del, - upload, - showMessage, - showConfirm, - formatDate, - formatDateTime, - formatFileSize, - debounce, - throttle, - getUrlParam, - renderPagination, - initPage, - initUserInfo, - initMenuState, - logout, - getStatusBadge, - renderSidebar, - initSidebar, - renderHeader -}; diff --git a/training-system/src/main/resources/static/knowledge/category.html b/training-system/src/main/resources/static/knowledge/category.html deleted file mode 100644 index bcab151..0000000 --- a/training-system/src/main/resources/static/knowledge/category.html +++ /dev/null @@ -1,217 +0,0 @@ - - - - - - 知识分类 - 道路救援培训系统 - - - - - - -
-
- -
-
-
- -
- -
- -
-
-
-
-
-
分类列表
- -
-
-
-
-
-
-
-
-
分类详情
-
-

请从左侧选择一个分类查看详情

-
-
-
-
-
-
-
- - - - - - - - diff --git a/training-system/src/main/resources/static/knowledge/list.html b/training-system/src/main/resources/static/knowledge/list.html deleted file mode 100644 index 4af38f1..0000000 --- a/training-system/src/main/resources/static/knowledge/list.html +++ /dev/null @@ -1,574 +0,0 @@ - - - - - - 知识列表 - 道路救援培训系统 - - - - - - - - -
- -
- - -
- -
-
- -
- -
- - -
-
-
-
- 知识列表 -
- -
-
- -
-
-
- - -
- - - -
-
- -
-
- - -
- - - - - - - - - - - - - - - - - - -
- - 标题分类类型状态创建人创建时间操作
-
-
- 加载中... -
-
-
-
- - - -
-
-
-
-
- - - - - - - - - - diff --git a/training-system/src/main/resources/static/knowledge/view.html b/training-system/src/main/resources/static/knowledge/view.html deleted file mode 100644 index 0bfc6f3..0000000 --- a/training-system/src/main/resources/static/knowledge/view.html +++ /dev/null @@ -1,236 +0,0 @@ - - - - - - 知识学习 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/login.html b/training-system/src/main/resources/static/login.html deleted file mode 100644 index d0bcf98..0000000 --- a/training-system/src/main/resources/static/login.html +++ /dev/null @@ -1,365 +0,0 @@ - - - - - - 登录 - 道路救援培训系统 - - - - - - - - - - - - - - - diff --git a/training-system/src/main/resources/static/system/org.html b/training-system/src/main/resources/static/system/org.html deleted file mode 100644 index c6226ad..0000000 --- a/training-system/src/main/resources/static/system/org.html +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - 组织架构 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
组织架构
-
-
-
-
-
-
组织详情
-

请从左侧选择一个组织查看详情

-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/system/setting.html b/training-system/src/main/resources/static/system/setting.html deleted file mode 100644 index af32f77..0000000 --- a/training-system/src/main/resources/static/system/setting.html +++ /dev/null @@ -1,318 +0,0 @@ - - - - - - 系统设置 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
- -
- -
-
-
个人信息
-
-
-
-
- - -
-
- -

点击更换头像

-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-
-
-
-
- - -
-
-
修改密码
-
-
-
- - -
-
- - -
密码长度至少6位
-
-
- - -
- -
-
-
-
- - -
-
-
系统设置
-
-
-
- - -
-
- -
-
- -
-
-
- - -
新建员工和重置密码时使用的默认密码
-
-
-
考试设置
-
-
- - -
-
- - -
-
-
- -
-
-
-
-
- - -
-
-
关于系统
-
-
-
-
- -
-
-

道路救援企业培训系统

-

版本 1.0.0

-
-
-
- - - - - -
技术框架Spring Boot 3.1.2
前端框架Bootstrap 5.3.2
数据库MySQL 8.0
ORM框架MyBatis Plus
-
-
-

Copyright © 2024 道路救援企业培训系统. All rights reserved.

-
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/system/user.html b/training-system/src/main/resources/static/system/user.html deleted file mode 100644 index f936ffd..0000000 --- a/training-system/src/main/resources/static/system/user.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - 员工管理 - 道路救援培训系统 - - - - - -
-
-
-
- - -
-
-
-
员工管理
-
-
-
-
- - - -
-
-
-
- - - -
员工信息用户名部门角色状态创建时间操作
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/training/detail.html b/training-system/src/main/resources/static/training/detail.html deleted file mode 100644 index 398511a..0000000 --- a/training-system/src/main/resources/static/training/detail.html +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - 培训详情 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/training/my-training.html b/training-system/src/main/resources/static/training/my-training.html deleted file mode 100644 index e3900d9..0000000 --- a/training-system/src/main/resources/static/training/my-training.html +++ /dev/null @@ -1,180 +0,0 @@ - - - - - - 我的培训 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
我的培训
-
-
- -
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/src/main/resources/static/training/plan-edit.html b/training-system/src/main/resources/static/training/plan-edit.html deleted file mode 100644 index 357b9cf..0000000 --- a/training-system/src/main/resources/static/training/plan-edit.html +++ /dev/null @@ -1,527 +0,0 @@ - - - - - - 编辑培训计划 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
-
基本信息
-
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
-
-
-
-
参与人员
- 0 -
-
-
-
- -
-
-
- - -
-
-
-
-
加载中...
-
-
-
-
-
-
-
-
培训内容
- 0 -
-
-
-
- -

请添加培训内容

-
-
-
-
-
- -
添加知识学习
-
-
-
-
- -
添加考试任务
-
-
-
-
-
-
- 返回 - - -
-
-
-
-
-
- - - - - - - - - - - - diff --git a/training-system/src/main/resources/static/training/plan.html b/training-system/src/main/resources/static/training/plan.html deleted file mode 100644 index 994bf7a..0000000 --- a/training-system/src/main/resources/static/training/plan.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - 培训计划 - 道路救援培训系统 - - - - - - -
-
-
-
- - -
-
-
-
-
培训计划
- 创建计划 -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/src/test/java/com/sino/training/common/utils/JwtUtilsTest.java b/training-system/src/test/java/com/sino/training/common/utils/JwtUtilsTest.java deleted file mode 100644 index 8bd9cc9..0000000 --- a/training-system/src/test/java/com/sino/training/common/utils/JwtUtilsTest.java +++ /dev/null @@ -1,310 +0,0 @@ -package com.sino.training.common.utils; - -import com.auth0.jwt.interfaces.DecodedJWT; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.*; - -/** - * JWT工具类单元测试 - * - * @author testing - */ -@DisplayName("JWT工具类测试") -class JwtUtilsTest { - - private JwtUtils jwtUtils; - - private static final String TEST_SECRET = "test-secret-key-for-jwt-signing-2026"; - private static final long TEST_EXPIRE = 3600000L; // 1小时 - - @BeforeEach - void setUp() { - jwtUtils = new JwtUtils(); - ReflectionTestUtils.setField(jwtUtils, "secret", TEST_SECRET); - ReflectionTestUtils.setField(jwtUtils, "expire", TEST_EXPIRE); - } - - @Nested - @DisplayName("生成Token测试") - class GenerateTokenTest { - - @Test - @DisplayName("正常生成Token - 应返回有效的JWT字符串") - void generateToken_ValidInput_ReturnsToken() { - // Act - String token = jwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - - // Assert - assertThat(token).isNotNull(); - assertThat(token).isNotEmpty(); - assertThat(token.split("\\.")).hasSize(3); // JWT格式: header.payload.signature - } - - @Test - @DisplayName("不同用户生成不同Token") - void generateToken_DifferentUsers_ReturnsDifferentTokens() { - // Act - String token1 = jwtUtils.generateToken(1L, "user1", "ADMIN", 1L); - String token2 = jwtUtils.generateToken(2L, "user2", "STUDENT", 2L); - - // Assert - assertThat(token1).isNotEqualTo(token2); - } - - @Test - @DisplayName("同一用户多次生成Token - JWT时间戳精度为秒,同秒内生成的Token可能相同") - void generateToken_SameUser_TokensContainSameUserInfo() throws InterruptedException { - // Act - String token1 = jwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - Thread.sleep(1100); // 等待超过1秒确保时间戳不同 - String token2 = jwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - - // Assert - 两个Token应该都是有效的,包含相同的用户信息 - assertThat(jwtUtils.getUserId(token1)).isEqualTo(1L); - assertThat(jwtUtils.getUserId(token2)).isEqualTo(1L); - assertThat(jwtUtils.getUsername(token1)).isEqualTo("admin"); - assertThat(jwtUtils.getUsername(token2)).isEqualTo("admin"); - // 时间戳超过1秒后生成的Token应该不同 - assertThat(token1).isNotEqualTo(token2); - } - } - - @Nested - @DisplayName("验证Token测试") - class VerifyTokenTest { - - @Test - @DisplayName("有效Token - 应返回DecodedJWT") - void verifyToken_ValidToken_ReturnsDecodedJWT() { - // Arrange - String token = jwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - - // Act - DecodedJWT jwt = jwtUtils.verifyToken(token); - - // Assert - assertThat(jwt).isNotNull(); - assertThat(jwt.getSubject()).isEqualTo("1"); - assertThat(jwt.getClaim("username").asString()).isEqualTo("admin"); - assertThat(jwt.getClaim("role").asString()).isEqualTo("ADMIN"); - assertThat(jwt.getClaim("departmentId").asLong()).isEqualTo(1L); - } - - @Test - @DisplayName("无效Token - 应返回null") - void verifyToken_InvalidToken_ReturnsNull() { - // Act - DecodedJWT jwt = jwtUtils.verifyToken("invalid.token.here"); - - // Assert - assertThat(jwt).isNull(); - } - - @Test - @DisplayName("空Token - 应返回null") - void verifyToken_EmptyToken_ReturnsNull() { - // Act - DecodedJWT jwt = jwtUtils.verifyToken(""); - - // Assert - assertThat(jwt).isNull(); - } - - @Test - @DisplayName("null Token - 应返回null") - void verifyToken_NullToken_ReturnsNull() { - // Act - DecodedJWT jwt = jwtUtils.verifyToken(null); - - // Assert - assertThat(jwt).isNull(); - } - - @Test - @DisplayName("被篡改的Token - 应返回null") - void verifyToken_TamperedToken_ReturnsNull() { - // Arrange - String token = jwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - String tamperedToken = token.substring(0, token.length() - 5) + "xxxxx"; - - // Act - DecodedJWT jwt = jwtUtils.verifyToken(tamperedToken); - - // Assert - assertThat(jwt).isNull(); - } - - @Test - @DisplayName("错误密钥签名的Token - 应返回null") - void verifyToken_WrongSecret_ReturnsNull() { - // Arrange - 用不同密钥生成Token - JwtUtils otherJwtUtils = new JwtUtils(); - ReflectionTestUtils.setField(otherJwtUtils, "secret", "different-secret-key"); - ReflectionTestUtils.setField(otherJwtUtils, "expire", TEST_EXPIRE); - String token = otherJwtUtils.generateToken(1L, "admin", "ADMIN", 1L); - - // Act - 用原密钥验证 - DecodedJWT jwt = jwtUtils.verifyToken(token); - - // Assert - assertThat(jwt).isNull(); - } - } - - @Nested - @DisplayName("获取用户ID测试") - class GetUserIdTest { - - @Test - @DisplayName("有效Token - 应返回正确的用户ID") - void getUserId_ValidToken_ReturnsUserId() { - // Arrange - String token = jwtUtils.generateToken(123L, "testuser", "STUDENT", null); - - // Act - Long userId = jwtUtils.getUserId(token); - - // Assert - assertThat(userId).isEqualTo(123L); - } - - @Test - @DisplayName("无效Token - 应返回null") - void getUserId_InvalidToken_ReturnsNull() { - // Act - Long userId = jwtUtils.getUserId("invalid.token"); - - // Assert - assertThat(userId).isNull(); - } - } - - @Nested - @DisplayName("获取用户名测试") - class GetUsernameTest { - - @Test - @DisplayName("有效Token - 应返回正确的用户名") - void getUsername_ValidToken_ReturnsUsername() { - // Arrange - String token = jwtUtils.generateToken(1L, "testuser", "ADMIN", null); - - // Act - String username = jwtUtils.getUsername(token); - - // Assert - assertThat(username).isEqualTo("testuser"); - } - - @Test - @DisplayName("无效Token - 应返回null") - void getUsername_InvalidToken_ReturnsNull() { - // Act - String username = jwtUtils.getUsername("invalid.token"); - - // Assert - assertThat(username).isNull(); - } - } - - @Nested - @DisplayName("获取角色测试") - class GetRoleTest { - - @Test - @DisplayName("有效Token - 应返回正确的角色") - void getRole_ValidToken_ReturnsRole() { - // Arrange - String token = jwtUtils.generateToken(1L, "admin", "ADMIN", null); - - // Act - String role = jwtUtils.getRole(token); - - // Assert - assertThat(role).isEqualTo("ADMIN"); - } - - @Test - @DisplayName("不同角色Token - 应返回对应角色") - void getRole_DifferentRoles_ReturnsCorrectRole() { - // Arrange & Act & Assert - assertThat(jwtUtils.getRole(jwtUtils.generateToken(1L, "u1", "ADMIN", null))).isEqualTo("ADMIN"); - assertThat(jwtUtils.getRole(jwtUtils.generateToken(2L, "u2", "LECTURER", null))).isEqualTo("LECTURER"); - assertThat(jwtUtils.getRole(jwtUtils.generateToken(3L, "u3", "STUDENT", null))).isEqualTo("STUDENT"); - } - } - - @Nested - @DisplayName("Token过期测试") - class IsTokenExpiredTest { - - @Test - @DisplayName("未过期Token - 应返回false") - void isTokenExpired_ValidToken_ReturnsFalse() { - // Arrange - String token = jwtUtils.generateToken(1L, "admin", "ADMIN", null); - - // Act - boolean expired = jwtUtils.isTokenExpired(token); - - // Assert - assertThat(expired).isFalse(); - } - - @Test - @DisplayName("过期Token - 应返回true") - void isTokenExpired_ExpiredToken_ReturnsTrue() { - // Arrange - 创建一个立即过期的JwtUtils - JwtUtils expiredJwtUtils = new JwtUtils(); - ReflectionTestUtils.setField(expiredJwtUtils, "secret", TEST_SECRET); - ReflectionTestUtils.setField(expiredJwtUtils, "expire", -1000L); // 负数表示已过期 - - String token = expiredJwtUtils.generateToken(1L, "admin", "ADMIN", null); - - // Act - boolean expired = jwtUtils.isTokenExpired(token); - - // Assert - assertThat(expired).isTrue(); - } - - @Test - @DisplayName("无效Token - 应返回true") - void isTokenExpired_InvalidToken_ReturnsTrue() { - // Act - boolean expired = jwtUtils.isTokenExpired("invalid.token"); - - // Assert - assertThat(expired).isTrue(); - } - } - - @Nested - @DisplayName("Token完整性测试") - class TokenIntegrityTest { - - @Test - @DisplayName("Token应包含所有必要信息") - void token_ContainsAllClaims() { - // Arrange - String token = jwtUtils.generateToken(999L, "fulluser", "LECTURER", null); - - // Act - DecodedJWT jwt = jwtUtils.verifyToken(token); - - // Assert - assertThat(jwt).isNotNull(); - assertThat(jwt.getSubject()).isEqualTo("999"); // userId - assertThat(jwt.getClaim("username").asString()).isEqualTo("fulluser"); - assertThat(jwt.getClaim("role").asString()).isEqualTo("LECTURER"); - assertThat(jwt.getIssuedAt()).isNotNull(); - assertThat(jwt.getExpiresAt()).isNotNull(); - assertThat(jwt.getExpiresAt()).isAfter(jwt.getIssuedAt()); - } - } -} diff --git a/training-system/src/test/java/com/sino/training/module/auth/LoginIntegrationTest.java b/training-system/src/test/java/com/sino/training/module/auth/LoginIntegrationTest.java deleted file mode 100644 index 9bf3019..0000000 --- a/training-system/src/test/java/com/sino/training/module/auth/LoginIntegrationTest.java +++ /dev/null @@ -1,275 +0,0 @@ -package com.sino.training.module.auth; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sino.training.module.auth.dto.LoginRequest; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * 登录功能集成测试 - * 测试真实的登录流程,需要数据库连接 - * - * @author testing - */ -@SpringBootTest -@AutoConfigureMockMvc -@DisplayName("登录功能集成测试") -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class LoginIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - private static final String LOGIN_URL = "/api/auth/login"; - private static final String USERINFO_URL = "/api/auth/userinfo"; - - // 保存登录后的Token,供后续测试使用 - private static String authToken; - - @Nested - @DisplayName("场景1: 账号密码登录测试") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class PasswordLoginTest { - - @Test - @Order(1) - @DisplayName("1.1 正确的账号密码 - 应登录成功并返回Token") - void login_CorrectCredentials_Success() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("admin123"); // 正确密码 - - // Act & Assert - MvcResult result = mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.token").exists()) - .andExpect(jsonPath("$.data.userId").exists()) - .andExpect(jsonPath("$.data.username").value("admin")) - .andExpect(jsonPath("$.data.role").value("ADMIN")) - .andReturn(); - - // 保存Token供后续测试使用 - String responseBody = result.getResponse().getContentAsString(); - JsonNode jsonNode = objectMapper.readTree(responseBody); - authToken = jsonNode.get("data").get("token").asText(); - - assertThat(authToken).isNotNull(); - assertThat(authToken).isNotEmpty(); - System.out.println("登录成功,获取Token: " + authToken.substring(0, 20) + "..."); - } - - @Test - @Order(2) - @DisplayName("1.2 错误的密码 - 应返回密码错误") - void login_WrongPassword_Fail() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("admin"); // 错误密码 - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(1003)) // PASSWORD_ERROR - .andExpect(jsonPath("$.message").value("密码错误")); - } - - @Test - @Order(3) - @DisplayName("1.3 不存在的用户名 - 应返回用户不存在") - void login_UserNotFound_Fail() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("nonexistent_user"); - request.setPassword("anypassword"); - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(1001)) // USER_NOT_FOUND - .andExpect(jsonPath("$.message").value("用户不存在")); - } - - @Test - @Order(4) - @DisplayName("1.4 空用户名 - 应返回验证错误") - void login_EmptyUsername_Fail() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername(""); - request.setPassword("admin123"); - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @Order(5) - @DisplayName("1.5 空密码 - 应返回验证错误") - void login_EmptyPassword_Fail() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword(""); - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @Order(6) - @DisplayName("1.6 用户名和密码都为空 - 应返回验证错误") - void login_EmptyCredentials_Fail() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername(""); - request.setPassword(""); - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("场景2: Token验证测试") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class TokenValidationTest { - - @Test - @Order(1) - @DisplayName("2.1 使用有效Token获取用户信息 - 应成功") - void getUserInfo_WithValidToken_Success() throws Exception { - // 先登录获取Token - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("admin123"); - - MvcResult loginResult = mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andReturn(); - - String responseBody = loginResult.getResponse().getContentAsString(); - JsonNode jsonNode = objectMapper.readTree(responseBody); - String token = jsonNode.get("data").get("token").asText(); - - // 使用Token获取用户信息 - mockMvc.perform(get(USERINFO_URL) - .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.userId").exists()) - .andExpect(jsonPath("$.data.username").value("admin")) - .andExpect(jsonPath("$.data.realName").value("系统管理员")) - .andExpect(jsonPath("$.data.role").value("ADMIN")); - } - - @Test - @Order(2) - @DisplayName("2.2 不携带Token访问受保护接口 - 应返回未授权") - void getUserInfo_WithoutToken_Unauthorized() throws Exception { - mockMvc.perform(get(USERINFO_URL)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(1101)); // UNAUTHORIZED - } - - @Test - @Order(3) - @DisplayName("2.3 使用无效Token - 应返回未授权") - void getUserInfo_WithInvalidToken_Unauthorized() throws Exception { - mockMvc.perform(get(USERINFO_URL) - .header("Authorization", "Bearer invalid.token.here")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(1101)); // UNAUTHORIZED - } - - @Test - @Order(4) - @DisplayName("2.4 Token格式错误(无Bearer前缀) - 应返回未授权") - void getUserInfo_WithoutBearerPrefix_Unauthorized() throws Exception { - // 先登录获取Token - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("admin123"); - - MvcResult loginResult = mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andReturn(); - - String responseBody = loginResult.getResponse().getContentAsString(); - JsonNode jsonNode = objectMapper.readTree(responseBody); - String token = jsonNode.get("data").get("token").asText(); - - // 不加Bearer前缀 - mockMvc.perform(get(USERINFO_URL) - .header("Authorization", token)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(1101)); // UNAUTHORIZED - } - } - - @Nested - @DisplayName("场景3: 登录响应数据完整性测试") - class LoginResponseTest { - - @Test - @DisplayName("3.1 登录成功后应返回完整的用户信息") - void login_Success_ReturnsCompleteUserInfo() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("admin123"); - - // Act & Assert - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.message").value("success")) - // 验证Token - .andExpect(jsonPath("$.data.token").isString()) - .andExpect(jsonPath("$.data.token").isNotEmpty()) - // 验证用户基本信息 - .andExpect(jsonPath("$.data.userId").isNumber()) - .andExpect(jsonPath("$.data.username").value("admin")) - .andExpect(jsonPath("$.data.realName").value("系统管理员")) - .andExpect(jsonPath("$.data.role").value("ADMIN")) - // 验证部门信息 - .andExpect(jsonPath("$.data.departmentId").exists()); - } - } -} diff --git a/training-system/src/test/java/com/sino/training/module/auth/PasswordEncoderTest.java b/training-system/src/test/java/com/sino/training/module/auth/PasswordEncoderTest.java deleted file mode 100644 index 7ad3a34..0000000 --- a/training-system/src/test/java/com/sino/training/module/auth/PasswordEncoderTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.sino.training.module.auth; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 密码编码测试 - 用于验证和生成BCrypt密码 - */ -@DisplayName("密码编码测试") -class PasswordEncoderTest { - - private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); - - // 数据库中存储的密码哈希(已修正) - private static final String DB_PASSWORD_HASH = "$2a$10$TWbA.Dgh6pjuVoLZzRod3usjs7fOyyofpTVCi1mz.okHtO55vGgBW"; - - @Test - @DisplayName("验证 'admin123' 是否匹配数据库密码") - void verify_admin123_matches_db_password() { - boolean matches = encoder.matches("admin123", DB_PASSWORD_HASH); - System.out.println("'admin123' 匹配数据库密码: " + matches); - assertThat(matches).isTrue(); - } - - @Test - @DisplayName("验证 'admin' 是否匹配数据库密码") - void verify_admin_matches_db_password() { - boolean matches = encoder.matches("admin", DB_PASSWORD_HASH); - System.out.println("'admin' 匹配数据库密码: " + matches); - // 预期不匹配 - assertThat(matches).isFalse(); - } - - @Test - @DisplayName("生成 'admin123' 的BCrypt密码") - void generate_bcrypt_for_admin123() { - String encoded = encoder.encode("admin123"); - System.out.println("'admin123' 的BCrypt密码: " + encoded); - - // 验证生成的密码可以匹配 - assertThat(encoder.matches("admin123", encoded)).isTrue(); - } - - @Test - @DisplayName("生成 'admin' 的BCrypt密码") - void generate_bcrypt_for_admin() { - String encoded = encoder.encode("admin"); - System.out.println("'admin' 的BCrypt密码: " + encoded); - System.out.println("如果要使用 'admin' 作为密码,请更新数据库中的password字段为上面的值"); - - // 验证生成的密码可以匹配 - assertThat(encoder.matches("admin", encoded)).isTrue(); - } -} diff --git a/training-system/src/test/java/com/sino/training/module/auth/controller/AuthControllerTest.java b/training-system/src/test/java/com/sino/training/module/auth/controller/AuthControllerTest.java deleted file mode 100644 index e6242eb..0000000 --- a/training-system/src/test/java/com/sino/training/module/auth/controller/AuthControllerTest.java +++ /dev/null @@ -1,282 +0,0 @@ -package com.sino.training.module.auth.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.exception.GlobalExceptionHandler; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.auth.dto.LoginRequest; -import com.sino.training.module.auth.dto.LoginResponse; -import com.sino.training.module.auth.dto.UserInfoResponse; -import com.sino.training.module.auth.service.AuthService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * 认证控制器单元测试 - * - * @author testing - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("认证控制器测试") -class AuthControllerTest { - - private MockMvc mockMvc; - - private ObjectMapper objectMapper; - - @Mock - private AuthService authService; - - @InjectMocks - private AuthController authController; - - private static final String BASE_URL = "/api/auth"; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - objectMapper.findAndRegisterModules(); - - mockMvc = MockMvcBuilders.standaloneSetup(authController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - } - - @Nested - @DisplayName("POST /login - 账号密码登录") - class LoginTest { - - @Test - @DisplayName("有效凭证登录 - 应返回200和Token") - void login_ValidCredentials_ReturnsOkWithToken() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("123456"); - - LoginResponse response = new LoginResponse(); - response.setToken("mock.jwt.token"); - response.setUserId(1L); - response.setUsername("admin"); - response.setRealName("管理员"); - response.setRole("ADMIN"); - response.setDepartmentId(1L); - response.setDepartmentName("总部"); - - when(authService.login(any(LoginRequest.class))).thenReturn(response); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.token").value("mock.jwt.token")) - .andExpect(jsonPath("$.data.userId").value(1)) - .andExpect(jsonPath("$.data.username").value("admin")) - .andExpect(jsonPath("$.data.realName").value("管理员")) - .andExpect(jsonPath("$.data.role").value("ADMIN")) - .andExpect(jsonPath("$.data.departmentName").value("总部")); - - verify(authService, times(1)).login(any(LoginRequest.class)); - } - - @Test - @DisplayName("用户名为空 - 应返回400验证错误") - void login_EmptyUsername_ReturnsBadRequest() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername(""); // 空用户名 - request.setPassword("123456"); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - - verify(authService, never()).login(any()); - } - - @Test - @DisplayName("密码为空 - 应返回400验证错误") - void login_EmptyPassword_ReturnsBadRequest() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword(""); // 空密码 - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - - verify(authService, never()).login(any()); - } - - @Test - @DisplayName("用户名和密码都为空 - 应返回400验证错误") - void login_EmptyCredentials_ReturnsBadRequest() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername(""); - request.setPassword(""); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - - verify(authService, never()).login(any()); - } - - @Test - @DisplayName("用户不存在 - 应返回业务错误码") - void login_UserNotFound_ReturnsBusinessError() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("nonexistent"); - request.setPassword("123456"); - - when(authService.login(any(LoginRequest.class))) - .thenThrow(new BusinessException(ResultCode.USER_NOT_FOUND)); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.USER_NOT_FOUND.getCode())); - } - - @Test - @DisplayName("密码错误 - 应返回PASSWORD_ERROR错误码") - void login_WrongPassword_ReturnsPasswordError() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("wrongpassword"); - - when(authService.login(any(LoginRequest.class))) - .thenThrow(new BusinessException(ResultCode.PASSWORD_ERROR)); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.PASSWORD_ERROR.getCode())); - } - - @Test - @DisplayName("用户被禁用 - 应返回USER_DISABLED错误码") - void login_UserDisabled_ReturnsUserDisabledError() throws Exception { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("123456"); - - when(authService.login(any(LoginRequest.class))) - .thenThrow(new BusinessException(ResultCode.USER_DISABLED)); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.USER_DISABLED.getCode())); - } - - @Test - @DisplayName("请求体为空 - 应返回400错误") - void login_NullBody_ReturnsBadRequest() throws Exception { - // Act & Assert - mockMvc.perform(post(BASE_URL + "/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andExpect(status().isBadRequest()); - - verify(authService, never()).login(any()); - } - } - - @Nested - @DisplayName("GET /userinfo - 获取当前用户信息") - class GetUserInfoTest { - - @Test - @DisplayName("已认证用户 - 应返回用户信息") - void getUserInfo_Authenticated_ReturnsUserInfo() throws Exception { - // Arrange - UserInfoResponse response = new UserInfoResponse(); - response.setUserId(1L); - response.setUsername("admin"); - response.setRealName("管理员"); - response.setRole("ADMIN"); - response.setDepartmentId(1L); - response.setDepartmentName("总部"); - response.setPhone("13800138000"); - - when(authService.getCurrentUserInfo()).thenReturn(response); - - // Act & Assert - mockMvc.perform(get(BASE_URL + "/userinfo")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.userId").value(1)) - .andExpect(jsonPath("$.data.username").value("admin")) - .andExpect(jsonPath("$.data.realName").value("管理员")) - .andExpect(jsonPath("$.data.role").value("ADMIN")); - - verify(authService, times(1)).getCurrentUserInfo(); - } - - @Test - @DisplayName("未认证用户 - 应返回UNAUTHORIZED错误码") - void getUserInfo_Unauthorized_ReturnsError() throws Exception { - // Arrange - when(authService.getCurrentUserInfo()) - .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED)); - - // Act & Assert - mockMvc.perform(get(BASE_URL + "/userinfo")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.UNAUTHORIZED.getCode())); - } - } - - @Nested - @DisplayName("POST /logout - 退出登录") - class LogoutTest { - - @Test - @DisplayName("正常退出 - 应返回200") - void logout_ReturnsOk() throws Exception { - // Arrange - doNothing().when(authService).logout(); - - // Act & Assert - mockMvc.perform(post(BASE_URL + "/logout")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)); - - verify(authService, times(1)).logout(); - } - } -} diff --git a/training-system/src/test/java/com/sino/training/module/auth/service/AuthServiceTest.java b/training-system/src/test/java/com/sino/training/module/auth/service/AuthServiceTest.java deleted file mode 100644 index 6d0ac68..0000000 --- a/training-system/src/test/java/com/sino/training/module/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,347 +0,0 @@ -package com.sino.training.module.auth.service; - -import com.sino.training.common.context.UserContext; -import com.sino.training.common.context.UserContextHolder; -import com.sino.training.common.enums.UserRole; -import com.sino.training.common.enums.UserStatus; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.common.utils.JwtUtils; -import com.sino.training.module.auth.dto.LoginRequest; -import com.sino.training.module.auth.dto.LoginResponse; -import com.sino.training.module.auth.dto.UserInfoResponse; -import com.sino.training.module.auth.service.impl.AuthServiceImpl; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.entity.User; -import com.sino.training.module.system.mapper.DepartmentMapper; -import com.sino.training.module.system.mapper.GroupMapper; -import com.sino.training.module.system.mapper.UserMapper; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * 认证服务单元测试 - * - * @author testing - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("认证服务测试") -class AuthServiceTest { - - @Mock - private UserMapper userMapper; - - @Mock - private DepartmentMapper departmentMapper; - - @Mock - private GroupMapper groupMapper; - - @Mock - private JwtUtils jwtUtils; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private AuthServiceImpl authService; - - private User mockUser; - private Department mockDepartment; - - @BeforeEach - void setUp() { - // 初始化Mock用户 - mockUser = new User(); - mockUser.setId(1L); - mockUser.setUsername("admin"); - mockUser.setPassword("$2a$10$encryptedPassword"); // BCrypt加密后的密码 - mockUser.setRealName("管理员"); - mockUser.setRole(UserRole.ADMIN); - mockUser.setStatus(UserStatus.ENABLED); - mockUser.setDepartmentId(1L); - mockUser.setPhone("13800138000"); - - // 初始化Mock部门 - mockDepartment = new Department(); - mockDepartment.setId(1L); - mockDepartment.setName("总部"); - } - - @AfterEach - void tearDown() { - // 清除用户上下文 - UserContextHolder.clearContext(); - } - - @Nested - @DisplayName("登录测试") - class LoginTest { - - @Test - @DisplayName("正常登录 - 应返回Token和用户信息") - void login_ValidCredentials_ReturnsLoginResponse() { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("123456"); - - when(userMapper.selectOne(any())).thenReturn(mockUser); - when(passwordEncoder.matches("123456", mockUser.getPassword())).thenReturn(true); - when(jwtUtils.generateToken(eq(1L), eq("admin"), eq("ADMIN"), eq(1L))) - .thenReturn("mock.jwt.token"); - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - - // Act - LoginResponse response = authService.login(request); - - // Assert - assertThat(response).isNotNull(); - assertThat(response.getToken()).isEqualTo("mock.jwt.token"); - assertThat(response.getUserId()).isEqualTo(1L); - assertThat(response.getUsername()).isEqualTo("admin"); - assertThat(response.getRealName()).isEqualTo("管理员"); - assertThat(response.getRole()).isEqualTo("ADMIN"); - assertThat(response.getDepartmentName()).isEqualTo("总部"); - - verify(userMapper, times(1)).selectOne(any()); - verify(passwordEncoder, times(1)).matches("123456", mockUser.getPassword()); - verify(jwtUtils, times(1)).generateToken(1L, "admin", "ADMIN", 1L); - } - - @Test - @DisplayName("用户不存在 - 应抛出USER_NOT_FOUND异常") - void login_UserNotFound_ThrowsException() { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("nonexistent"); - request.setPassword("123456"); - - when(userMapper.selectOne(any())).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.USER_NOT_FOUND.getCode()); - }); - - verify(userMapper, times(1)).selectOne(any()); - verify(passwordEncoder, never()).matches(anyString(), anyString()); - } - - @Test - @DisplayName("密码错误 - 应抛出PASSWORD_ERROR异常") - void login_WrongPassword_ThrowsException() { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("wrongpassword"); - - when(userMapper.selectOne(any())).thenReturn(mockUser); - when(passwordEncoder.matches("wrongpassword", mockUser.getPassword())).thenReturn(false); - - // Act & Assert - assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.PASSWORD_ERROR.getCode()); - }); - - verify(passwordEncoder, times(1)).matches("wrongpassword", mockUser.getPassword()); - verify(jwtUtils, never()).generateToken(anyLong(), anyString(), anyString(), any()); - } - - @Test - @DisplayName("用户被禁用 - 应抛出USER_DISABLED异常") - void login_UserDisabled_ThrowsException() { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("123456"); - - mockUser.setStatus(UserStatus.DISABLED); - - when(userMapper.selectOne(any())).thenReturn(mockUser); - when(passwordEncoder.matches("123456", mockUser.getPassword())).thenReturn(true); - - // Act & Assert - assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.USER_DISABLED.getCode()); - }); - - verify(jwtUtils, never()).generateToken(anyLong(), anyString(), anyString(), any()); - } - - @Test - @DisplayName("登录不同角色用户 - 应返回正确的角色信息") - void login_DifferentRoles_ReturnsCorrectRole() { - // Arrange - 学员角色 - LoginRequest request = new LoginRequest(); - request.setUsername("student"); - request.setPassword("123456"); - - User studentUser = new User(); - studentUser.setId(2L); - studentUser.setUsername("student"); - studentUser.setPassword("$2a$10$encrypted"); - studentUser.setRealName("学员一"); - studentUser.setRole(UserRole.STUDENT); - studentUser.setStatus(UserStatus.ENABLED); - - when(userMapper.selectOne(any())).thenReturn(studentUser); - when(passwordEncoder.matches("123456", studentUser.getPassword())).thenReturn(true); - when(jwtUtils.generateToken(eq(2L), eq("student"), eq("STUDENT"), isNull())) - .thenReturn("student.jwt.token"); - - // Act - LoginResponse response = authService.login(request); - - // Assert - assertThat(response.getRole()).isEqualTo("STUDENT"); - assertThat(response.getUsername()).isEqualTo("student"); - verify(jwtUtils).generateToken(2L, "student", "STUDENT", null); - } - - @Test - @DisplayName("用户无部门 - 应正常登录但部门名称为空") - void login_NoDepartment_ReturnsNullDepartmentName() { - // Arrange - LoginRequest request = new LoginRequest(); - request.setUsername("admin"); - request.setPassword("123456"); - - mockUser.setDepartmentId(null); - - when(userMapper.selectOne(any())).thenReturn(mockUser); - when(passwordEncoder.matches("123456", mockUser.getPassword())).thenReturn(true); - when(jwtUtils.generateToken(eq(1L), eq("admin"), eq("ADMIN"), isNull())) - .thenReturn("mock.jwt.token"); - - // Act - LoginResponse response = authService.login(request); - - // Assert - assertThat(response).isNotNull(); - assertThat(response.getDepartmentId()).isNull(); - assertThat(response.getDepartmentName()).isNull(); - verify(departmentMapper, never()).selectById(anyLong()); - } - } - - @Nested - @DisplayName("获取当前用户信息测试") - class GetCurrentUserInfoTest { - - @Test - @DisplayName("有效上下文 - 应返回用户信息") - void getCurrentUserInfo_ValidContext_ReturnsUserInfo() { - // Arrange - UserContext context = new UserContext(); - context.setUserId(1L); - context.setUsername("admin"); - context.setRole("ADMIN"); - UserContextHolder.setContext(context); - - when(userMapper.selectById(1L)).thenReturn(mockUser); - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - - // Act - UserInfoResponse response = authService.getCurrentUserInfo(); - - // Assert - assertThat(response).isNotNull(); - assertThat(response.getUserId()).isEqualTo(1L); - assertThat(response.getUsername()).isEqualTo("admin"); - assertThat(response.getRealName()).isEqualTo("管理员"); - assertThat(response.getRole()).isEqualTo("ADMIN"); - assertThat(response.getDepartmentName()).isEqualTo("总部"); - } - - @Test - @DisplayName("无上下文 - 应抛出UNAUTHORIZED异常") - void getCurrentUserInfo_NoContext_ThrowsException() { - // Arrange - 确保没有上下文 - UserContextHolder.clearContext(); - - // Act & Assert - assertThatThrownBy(() -> authService.getCurrentUserInfo()) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode()); - }); - } - - @Test - @DisplayName("上下文用户ID为空 - 应抛出UNAUTHORIZED异常") - void getCurrentUserInfo_NullUserId_ThrowsException() { - // Arrange - UserContext context = new UserContext(); - context.setUserId(null); // 空用户ID - UserContextHolder.setContext(context); - - // Act & Assert - assertThatThrownBy(() -> authService.getCurrentUserInfo()) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode()); - }); - } - - @Test - @DisplayName("用户已被删除 - 应抛出USER_NOT_FOUND异常") - void getCurrentUserInfo_UserDeleted_ThrowsException() { - // Arrange - UserContext context = new UserContext(); - context.setUserId(999L); - UserContextHolder.setContext(context); - - when(userMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> authService.getCurrentUserInfo()) - .isInstanceOf(BusinessException.class) - .satisfies(e -> { - BusinessException be = (BusinessException) e; - assertThat(be.getCode()).isEqualTo(ResultCode.USER_NOT_FOUND.getCode()); - }); - } - } - - @Nested - @DisplayName("退出登录测试") - class LogoutTest { - - @Test - @DisplayName("正常退出 - 应清除上下文") - void logout_ClearsContext() { - // Arrange - UserContext context = new UserContext(); - context.setUserId(1L); - UserContextHolder.setContext(context); - - // Act - authService.logout(); - - // Assert - logout方法会调用clearContext - // 由于logout内部调用clearContext,而我们afterEach也会调用 - // 这里主要验证不抛异常 - assertThat(UserContextHolder.getContext()).isNull(); - } - } -} diff --git a/training-system/src/test/java/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.java b/training-system/src/test/java/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.java deleted file mode 100644 index 818ff8a..0000000 --- a/training-system/src/test/java/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.java +++ /dev/null @@ -1,329 +0,0 @@ -package com.sino.training.module.exam.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.exception.GlobalExceptionHandler; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.QuestionCategoryDTO; -import com.sino.training.module.exam.service.QuestionCategoryService; -import com.sino.training.module.exam.vo.QuestionCategoryVO; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * 题目分类控制器单元测试 - * 使用 MockMvc standalone 模式,不加载Spring Context - * - * @author testing - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("题目分类控制器测试") -class QuestionCategoryControllerTest { - - private MockMvc mockMvc; - - private ObjectMapper objectMapper; - - @Mock - private QuestionCategoryService questionCategoryService; - - @InjectMocks - private QuestionCategoryController questionCategoryController; - - private static final String BASE_URL = "/api/exam/question-category"; - - private QuestionCategoryVO mockCategoryVO; - private List mockCategoryTree; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - objectMapper.findAndRegisterModules(); - - // 使用standalone模式,包含全局异常处理器 - mockMvc = MockMvcBuilders.standaloneSetup(questionCategoryController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - - // 初始化Mock分类VO - mockCategoryVO = new QuestionCategoryVO(); - mockCategoryVO.setId(1L); - mockCategoryVO.setName("安全知识"); - mockCategoryVO.setParentId(0L); - mockCategoryVO.setDepartmentId(1L); - mockCategoryVO.setDepartmentName("测试部门"); - mockCategoryVO.setSortOrder(0); - mockCategoryVO.setQuestionCount(10); - mockCategoryVO.setCreateTime(LocalDateTime.now()); - - // 子分类 - QuestionCategoryVO childVO = new QuestionCategoryVO(); - childVO.setId(2L); - childVO.setName("消防安全"); - childVO.setParentId(1L); - childVO.setParentName("安全知识"); - childVO.setDepartmentId(1L); - childVO.setSortOrder(1); - childVO.setQuestionCount(5); - childVO.setChildren(new ArrayList<>()); - - mockCategoryVO.setChildren(List.of(childVO)); - mockCategoryTree = List.of(mockCategoryVO); - } - - @Nested - @DisplayName("GET /tree - 获取分类树") - class GetTreeTest { - - @Test - @DisplayName("无参数获取全部分类树 - 应返回200") - void getTree_NoParams_ReturnsOk() throws Exception { - when(questionCategoryService.getCategoryTree(null)).thenReturn(mockCategoryTree); - - mockMvc.perform(get(BASE_URL + "/tree")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data[0].name").value("安全知识")) - .andExpect(jsonPath("$.data[0].children").isArray()) - .andExpect(jsonPath("$.data[0].children[0].name").value("消防安全")); - - verify(questionCategoryService, times(1)).getCategoryTree(null); - } - - @Test - @DisplayName("按部门ID筛选分类树 - 应返回200") - void getTree_WithDepartmentId_ReturnsOk() throws Exception { - when(questionCategoryService.getCategoryTree(1L)).thenReturn(mockCategoryTree); - - mockMvc.perform(get(BASE_URL + "/tree") - .param("departmentId", "1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()); - - verify(questionCategoryService, times(1)).getCategoryTree(1L); - } - - @Test - @DisplayName("空数据应返回空数组") - void getTree_EmptyData_ReturnsEmptyArray() throws Exception { - when(questionCategoryService.getCategoryTree(any())).thenReturn(new ArrayList<>()); - - mockMvc.perform(get(BASE_URL + "/tree")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data").isEmpty()); - } - } - - @Nested - @DisplayName("GET /list - 获取分类列表") - class GetListTest { - - @Test - @DisplayName("获取分类列表 - 应返回200") - void getList_ReturnsOk() throws Exception { - List list = List.of(mockCategoryVO); - when(questionCategoryService.listCategories(null)).thenReturn(list); - - mockMvc.perform(get(BASE_URL + "/list")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data[0].id").value(1)); - - verify(questionCategoryService, times(1)).listCategories(null); - } - } - - @Nested - @DisplayName("GET /{id} - 获取分类详情") - class GetDetailTest { - - @Test - @DisplayName("获取存在的分类详情 - 应返回200") - void getDetail_Exists_ReturnsOk() throws Exception { - when(questionCategoryService.getCategoryDetail(1L)).thenReturn(mockCategoryVO); - - mockMvc.perform(get(BASE_URL + "/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.id").value(1)) - .andExpect(jsonPath("$.data.name").value("安全知识")) - .andExpect(jsonPath("$.data.questionCount").value(10)); - - verify(questionCategoryService, times(1)).getCategoryDetail(1L); - } - - @Test - @DisplayName("获取不存在的分类详情 - 应返回业务错误码") - void getDetail_NotExists_ReturnsNotFound() throws Exception { - when(questionCategoryService.getCategoryDetail(999L)) - .thenThrow(new BusinessException(ResultCode.CATEGORY_NOT_FOUND)); - - mockMvc.perform(get(BASE_URL + "/999")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.CATEGORY_NOT_FOUND.getCode())); - } - } - - @Nested - @DisplayName("POST / - 创建分类") - class CreateTest { - - @Test - @DisplayName("有效数据创建分类 - 应返回200") - void create_ValidData_ReturnsOk() throws Exception { - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("新分类"); - dto.setDepartmentId(1L); - dto.setParentId(0L); - dto.setSortOrder(0); - - when(questionCategoryService.createCategory(any(QuestionCategoryDTO.class))).thenReturn(100L); - - mockMvc.perform(post(BASE_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").value(100)); - - verify(questionCategoryService, times(1)).createCategory(any(QuestionCategoryDTO.class)); - } - - @Test - @DisplayName("同名分类已存在应返回业务错误") - void create_DuplicateName_ReturnsBusinessError() throws Exception { - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("已存在分类"); - dto.setDepartmentId(1L); - dto.setParentId(0L); - - when(questionCategoryService.createCategory(any(QuestionCategoryDTO.class))) - .thenThrow(new BusinessException(ResultCode.DATA_EXISTS, "该级别下已存在同名分类")); - - mockMvc.perform(post(BASE_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.DATA_EXISTS.getCode())) - .andExpect(jsonPath("$.message").value("该级别下已存在同名分类")); - } - } - - @Nested - @DisplayName("PUT / - 更新分类") - class UpdateTest { - - @Test - @DisplayName("有效数据更新分类 - 应返回200") - void update_ValidData_ReturnsOk() throws Exception { - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(1L); - dto.setName("更新后的名称"); - dto.setDepartmentId(1L); - dto.setParentId(0L); - - doNothing().when(questionCategoryService).updateCategory(any(QuestionCategoryDTO.class)); - - mockMvc.perform(put(BASE_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)); - - verify(questionCategoryService, times(1)).updateCategory(any(QuestionCategoryDTO.class)); - } - - @Test - @DisplayName("分类不存在应返回业务错误") - void update_NotExists_ReturnsBusinessError() throws Exception { - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(999L); - dto.setName("更新名称"); - dto.setDepartmentId(1L); - - doThrow(new BusinessException(ResultCode.CATEGORY_NOT_FOUND)) - .when(questionCategoryService).updateCategory(any(QuestionCategoryDTO.class)); - - mockMvc.perform(put(BASE_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.CATEGORY_NOT_FOUND.getCode())); - } - } - - @Nested - @DisplayName("DELETE /{id} - 删除分类") - class DeleteTest { - - @Test - @DisplayName("删除存在且无依赖的分类 - 应返回200") - void delete_ValidCategory_ReturnsOk() throws Exception { - doNothing().when(questionCategoryService).deleteCategory(1L); - - mockMvc.perform(delete(BASE_URL + "/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)); - - verify(questionCategoryService, times(1)).deleteCategory(1L); - } - - @Test - @DisplayName("删除有子分类的分类 - 应返回业务错误") - void delete_HasChildren_ReturnsBusinessError() throws Exception { - doThrow(new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在子分类,无法删除")) - .when(questionCategoryService).deleteCategory(1L); - - mockMvc.perform(delete(BASE_URL + "/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.DATA_REFERENCED.getCode())) - .andExpect(jsonPath("$.message").value("该分类下存在子分类,无法删除")); - } - - @Test - @DisplayName("删除有关联题目的分类 - 应返回业务错误") - void delete_HasQuestions_ReturnsBusinessError() throws Exception { - doThrow(new BusinessException(ResultCode.DATA_REFERENCED, "该分类下存在题目,无法删除")) - .when(questionCategoryService).deleteCategory(2L); - - mockMvc.perform(delete(BASE_URL + "/2")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.DATA_REFERENCED.getCode())) - .andExpect(jsonPath("$.message").value("该分类下存在题目,无法删除")); - } - - @Test - @DisplayName("删除不存在的分类 - 应返回404") - void delete_NotExists_ReturnsNotFound() throws Exception { - doThrow(new BusinessException(ResultCode.CATEGORY_NOT_FOUND)) - .when(questionCategoryService).deleteCategory(999L); - - mockMvc.perform(delete(BASE_URL + "/999")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResultCode.CATEGORY_NOT_FOUND.getCode())); - } - } -} diff --git a/training-system/src/test/java/com/sino/training/module/exam/service/QuestionCategoryServiceTest.java b/training-system/src/test/java/com/sino/training/module/exam/service/QuestionCategoryServiceTest.java deleted file mode 100644 index 0b46c35..0000000 --- a/training-system/src/test/java/com/sino/training/module/exam/service/QuestionCategoryServiceTest.java +++ /dev/null @@ -1,411 +0,0 @@ -package com.sino.training.module.exam.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.sino.training.common.exception.BusinessException; -import com.sino.training.common.result.ResultCode; -import com.sino.training.module.exam.dto.QuestionCategoryDTO; -import com.sino.training.module.exam.entity.QuestionCategory; -import com.sino.training.module.exam.mapper.QuestionCategoryMapper; -import com.sino.training.module.exam.mapper.QuestionMapper; -import com.sino.training.module.exam.service.impl.QuestionCategoryServiceImpl; -import com.sino.training.module.exam.vo.QuestionCategoryVO; -import com.sino.training.module.system.entity.Department; -import com.sino.training.module.system.mapper.DepartmentMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * 题目分类服务单元测试 - * - * @author testing - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("题目分类服务测试") -class QuestionCategoryServiceTest { - - @Mock - private QuestionCategoryMapper questionCategoryMapper; - - @Mock - private DepartmentMapper departmentMapper; - - @Mock - private QuestionMapper questionMapper; - - private QuestionCategoryServiceImpl questionCategoryService; - - private Department mockDepartment; - private QuestionCategory mockCategory; - private QuestionCategory mockParentCategory; - - @BeforeEach - void setUp() { - // 手动创建服务实例并注入mock依赖 - questionCategoryService = new QuestionCategoryServiceImpl(departmentMapper, questionMapper); - // 设置baseMapper用于ServiceImpl内部方法 - ReflectionTestUtils.setField(questionCategoryService, "baseMapper", questionCategoryMapper); - - // 初始化Mock部门 - mockDepartment = new Department(); - mockDepartment.setId(1L); - mockDepartment.setName("测试部门"); - - // 初始化Mock父分类 - mockParentCategory = new QuestionCategory(); - mockParentCategory.setId(1L); - mockParentCategory.setName("父分类"); - mockParentCategory.setParentId(0L); - mockParentCategory.setDepartmentId(1L); - mockParentCategory.setSortOrder(0); - mockParentCategory.setCreateTime(LocalDateTime.now()); - - // 初始化Mock分类 - mockCategory = new QuestionCategory(); - mockCategory.setId(2L); - mockCategory.setName("子分类"); - mockCategory.setParentId(1L); - mockCategory.setDepartmentId(1L); - mockCategory.setSortOrder(1); - mockCategory.setCreateTime(LocalDateTime.now()); - } - - @Nested - @DisplayName("创建分类测试") - class CreateCategoryTest { - - @Test - @DisplayName("正常创建分类 - 应该成功") - void createCategory_Success() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("新分类"); - dto.setDepartmentId(1L); - dto.setParentId(0L); - dto.setSortOrder(0); - - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - when(questionCategoryMapper.selectCount(any())).thenReturn(0L); - when(questionCategoryMapper.insert(any(QuestionCategory.class))).thenAnswer(invocation -> { - QuestionCategory category = invocation.getArgument(0); - category.setId(100L); - return 1; - }); - - // Act - Long result = questionCategoryService.createCategory(dto); - - // Assert - assertThat(result).isEqualTo(100L); - verify(questionCategoryMapper, times(1)).insert(any(QuestionCategory.class)); - } - - @Test - @DisplayName("创建分类 - 部门不存在应抛出异常") - void createCategory_DepartmentNotFound_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("新分类"); - dto.setDepartmentId(999L); - dto.setParentId(0L); - - when(departmentMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.createCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("部门不存在"); - } - - @Test - @DisplayName("创建分类 - 父分类不存在应抛出异常") - void createCategory_ParentNotFound_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("新分类"); - dto.setDepartmentId(1L); - dto.setParentId(999L); - - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - when(questionCategoryMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.createCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("父分类不存在"); - } - - @Test - @DisplayName("创建分类 - 同级同名分类已存在应抛出异常") - void createCategory_DuplicateName_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("已存在分类"); - dto.setDepartmentId(1L); - dto.setParentId(0L); - - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - when(questionCategoryMapper.selectCount(any())).thenReturn(1L); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.createCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("同名分类"); - } - - @Test - @DisplayName("创建分类 - parentId为null时应默认为0") - void createCategory_NullParentId_DefaultsToZero() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setName("新分类"); - dto.setDepartmentId(1L); - dto.setParentId(null); - - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - when(questionCategoryMapper.selectCount(any())).thenReturn(0L); - when(questionCategoryMapper.insert(any(QuestionCategory.class))).thenAnswer(invocation -> { - QuestionCategory category = invocation.getArgument(0); - assertThat(category.getParentId()).isEqualTo(0L); - category.setId(100L); - return 1; - }); - - // Act - questionCategoryService.createCategory(dto); - - // Assert - verify(questionCategoryMapper).insert(argThat(c -> c.getParentId().equals(0L))); - } - } - - @Nested - @DisplayName("更新分类测试") - class UpdateCategoryTest { - - @Test - @DisplayName("正常更新分类 - 应该成功") - void updateCategory_Success() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(2L); - dto.setName("更新后的名称"); - dto.setParentId(1L); - dto.setSortOrder(5); - - when(questionCategoryMapper.selectById(2L)).thenReturn(mockCategory); - when(questionCategoryMapper.selectCount(any())).thenReturn(0L); - when(questionCategoryMapper.updateById(any(QuestionCategory.class))).thenReturn(1); - - // Act - questionCategoryService.updateCategory(dto); - - // Assert - verify(questionCategoryMapper, times(1)).updateById(any(QuestionCategory.class)); - } - - @Test - @DisplayName("更新分类 - ID为空应抛出异常") - void updateCategory_NullId_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(null); - dto.setName("更新名称"); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.updateCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("ID不能为空"); - } - - @Test - @DisplayName("更新分类 - 分类不存在应抛出异常") - void updateCategory_CategoryNotFound_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(999L); - dto.setName("更新名称"); - - when(questionCategoryMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.updateCategory(dto)) - .isInstanceOf(BusinessException.class); - } - - @Test - @DisplayName("更新分类 - 不能将自己设为父分类") - void updateCategory_SelfAsParent_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(2L); - dto.setName("更新名称"); - dto.setParentId(2L); // 自己设为父分类 - - when(questionCategoryMapper.selectById(2L)).thenReturn(mockCategory); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.updateCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("不能将自己设为父分类"); - } - - @Test - @DisplayName("更新分类 - 同级同名分类已存在应抛出异常") - void updateCategory_DuplicateName_ThrowsException() { - // Arrange - QuestionCategoryDTO dto = new QuestionCategoryDTO(); - dto.setId(2L); - dto.setName("已存在分类"); - dto.setParentId(1L); - - when(questionCategoryMapper.selectById(2L)).thenReturn(mockCategory); - when(questionCategoryMapper.selectCount(any())).thenReturn(1L); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.updateCategory(dto)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("同名分类"); - } - } - - @Nested - @DisplayName("删除分类测试") - class DeleteCategoryTest { - - // 注意:正常删除测试需要完整的MyBatis Plus TableInfo支持 - // 在纯Mock环境下,ServiceImpl.removeById()内部依赖TableInfo - // 该功能的正确性已通过其他异常测试间接验证 - - @Test - @DisplayName("删除分类 - 分类不存在应抛出异常") - void deleteCategory_NotFound_ThrowsException() { - // Arrange - when(questionCategoryMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.deleteCategory(999L)) - .isInstanceOf(BusinessException.class); - } - - @Test - @DisplayName("删除分类 - 存在子分类应抛出异常") - void deleteCategory_HasChildren_ThrowsException() { - // Arrange - when(questionCategoryMapper.selectById(1L)).thenReturn(mockParentCategory); - when(questionCategoryMapper.selectCount(any())).thenReturn(1L); // 有子分类 - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.deleteCategory(1L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("子分类"); - } - - @Test - @DisplayName("删除分类 - 存在关联题目应抛出异常") - void deleteCategory_HasQuestions_ThrowsException() { - // Arrange - when(questionCategoryMapper.selectById(2L)).thenReturn(mockCategory); - when(questionCategoryMapper.selectCount(any())).thenReturn(0L); // 无子分类 - when(questionMapper.selectCount(any())).thenReturn(5L); // 有5道题目 - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.deleteCategory(2L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("题目"); - } - } - - @Nested - @DisplayName("获取分类详情测试") - class GetCategoryDetailTest { - - @Test - @DisplayName("获取详情 - 应该返回完整信息") - void getCategoryDetail_Success() { - // Arrange - when(questionCategoryMapper.selectById(2L)).thenReturn(mockCategory); - when(questionCategoryMapper.selectById(1L)).thenReturn(mockParentCategory); - when(departmentMapper.selectById(1L)).thenReturn(mockDepartment); - - // Act - QuestionCategoryVO result = questionCategoryService.getCategoryDetail(2L); - - // Assert - assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(2L); - assertThat(result.getName()).isEqualTo("子分类"); - assertThat(result.getParentId()).isEqualTo(1L); - assertThat(result.getParentName()).isEqualTo("父分类"); - assertThat(result.getDepartmentName()).isEqualTo("测试部门"); - } - - @Test - @DisplayName("获取详情 - 分类不存在应抛出异常") - void getCategoryDetail_NotFound_ThrowsException() { - // Arrange - when(questionCategoryMapper.selectById(999L)).thenReturn(null); - - // Act & Assert - assertThatThrownBy(() -> questionCategoryService.getCategoryDetail(999L)) - .isInstanceOf(BusinessException.class); - } - } - - @Nested - @DisplayName("获取分类树测试") - class GetCategoryTreeTest { - - @Test - @DisplayName("获取分类树 - 应该返回正确的树形结构") - void getCategoryTree_Success() { - // Arrange - List categories = new ArrayList<>(); - categories.add(mockParentCategory); - categories.add(mockCategory); - - when(questionCategoryMapper.selectList(any())).thenReturn(categories); - when(questionMapper.selectList(any())).thenReturn(new ArrayList<>()); - when(questionCategoryMapper.selectById(anyLong())).thenReturn(mockParentCategory); - when(departmentMapper.selectById(anyLong())).thenReturn(mockDepartment); - - // Act - List result = questionCategoryService.getCategoryTree(null); - - // Assert - assertThat(result).isNotNull(); - assertThat(result).hasSize(1); // 只有一个根节点 - assertThat(result.get(0).getName()).isEqualTo("父分类"); - assertThat(result.get(0).getChildren()).hasSize(1); - assertThat(result.get(0).getChildren().get(0).getName()).isEqualTo("子分类"); - } - - @Test - @DisplayName("获取分类树 - 按部门筛选") - void getCategoryTree_FilterByDepartment() { - // Arrange - when(questionCategoryMapper.selectList(any())).thenReturn(new ArrayList<>()); - when(questionMapper.selectList(any())).thenReturn(new ArrayList<>()); - - // Act - List result = questionCategoryService.getCategoryTree(1L); - - // Assert - assertThat(result).isEmpty(); - verify(questionCategoryMapper).selectList(argThat(wrapper -> wrapper != null)); - } - } -} diff --git a/training-system/target/classes/application-dev.yml b/training-system/target/classes/application-dev.yml deleted file mode 100644 index 63779fd..0000000 --- a/training-system/target/classes/application-dev.yml +++ /dev/null @@ -1,23 +0,0 @@ -# 开发环境配置 -spring: - # 数据源配置 - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://192.168.1.226:3306/training_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true - username: root - password: root - -# 企业微信配置(开发环境) -wechat: -cp: - corp-id: ww0b2dc90421854148 - agent-id: 1000008 - secret: AQSWrg_TMHaWHXpiVLLUkTepUzcE4JjZLmUTl48ezMA - # OAuth2 回调地址 - oauth2-redirect-uri: http://localhost:8080/auth/wechat/callback - -# 日志配置 -logging: - level: - com.sino.training: debug - com.baomidou.mybatisplus: debug diff --git a/training-system/target/classes/application-prod.yml b/training-system/target/classes/application-prod.yml deleted file mode 100644 index 1a3c9ae..0000000 --- a/training-system/target/classes/application-prod.yml +++ /dev/null @@ -1,44 +0,0 @@ -# 生产环境配置 -spring: - # 数据源配置 - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:training_system}?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 - username: ${DB_USERNAME:root} - password: ${DB_PASSWORD:root} - # 生产环境开启模板缓存 - thymeleaf: - cache: true - -# 企业微信配置(生产环境,通过环境变量注入) -wechat: - cp: - corp-id: ${WECHAT_CORP_ID} - agent-id: ${WECHAT_AGENT_ID} - secret: ${WECHAT_SECRET} - oauth2-redirect-uri: ${WECHAT_OAUTH2_REDIRECT_URI} - -# JWT 配置(生产环境使用环境变量) -training: - jwt: - secret: ${JWT_SECRET:training-system-jwt-secret-key-prod} - -# 日志配置 -logging: - level: - com.sino.training: info - com.baomidou.mybatisplus: warn - file: - name: ./logs/training-system.log - -# MyBatis Plus 生产环境关闭SQL日志 -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl - -# Springdoc 生产环境可选择关闭 -springdoc: - api-docs: - enabled: false - swagger-ui: - enabled: false diff --git a/training-system/target/classes/application.yml b/training-system/target/classes/application.yml deleted file mode 100644 index a1613b8..0000000 --- a/training-system/target/classes/application.yml +++ /dev/null @@ -1,66 +0,0 @@ -# 道路救援企业培训系统 - 主配置文件 -server: - port: 8080 - servlet: - context-path: / - -spring: - profiles: - active: dev - application: - name: training-system - # Thymeleaf 模板配置 - thymeleaf: - prefix: classpath:/templates/ - suffix: .html - mode: HTML - encoding: UTF-8 - cache: false - # 文件上传配置 - servlet: - multipart: - enabled: true - max-file-size: 100MB - max-request-size: 100MB - # Jackson 配置 - jackson: - date-format: yyyy-MM-dd HH:mm:ss - time-zone: GMT+8 - default-property-inclusion: non_null - -# MyBatis Plus 配置 -mybatis-plus: - mapper-locations: classpath:/mapper/**/*.xml - type-aliases-package: com.sino.training.module.*.entity - global-config: - db-config: - id-type: auto - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 - configuration: - map-underscore-to-camel-case: true - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl - -# Springdoc OpenAPI 配置 -springdoc: - api-docs: - enabled: true - path: /v3/api-docs - swagger-ui: - enabled: true - path: /swagger-ui.html - -# 自定义配置 -training: - # JWT 配置 - jwt: - secret: training-system-jwt-secret-key-2026 - expire: 86400000 # 24小时,单位毫秒 - header: Authorization - prefix: Bearer - # 文件上传配置 - upload: - path: D:/upload/ - allowed-types: pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif,mp4,avi,mov - max-size: 104857600 # 100MB diff --git a/training-system/target/classes/com/sino/training/TrainingApplication.class b/training-system/target/classes/com/sino/training/TrainingApplication.class deleted file mode 100644 index 8601d60..0000000 Binary files a/training-system/target/classes/com/sino/training/TrainingApplication.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/annotation/RequireRole.class b/training-system/target/classes/com/sino/training/common/annotation/RequireRole.class deleted file mode 100644 index ec24e55..0000000 Binary files a/training-system/target/classes/com/sino/training/common/annotation/RequireRole.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/base/BaseEntity.class b/training-system/target/classes/com/sino/training/common/base/BaseEntity.class deleted file mode 100644 index c166ede..0000000 Binary files a/training-system/target/classes/com/sino/training/common/base/BaseEntity.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/base/PageQuery.class b/training-system/target/classes/com/sino/training/common/base/PageQuery.class deleted file mode 100644 index c5d54b0..0000000 Binary files a/training-system/target/classes/com/sino/training/common/base/PageQuery.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig$1.class b/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig$1.class deleted file mode 100644 index 4d71021..0000000 Binary files a/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig.class b/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig.class deleted file mode 100644 index 23670c0..0000000 Binary files a/training-system/target/classes/com/sino/training/common/config/MybatisPlusConfig.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/config/SecurityConfig.class b/training-system/target/classes/com/sino/training/common/config/SecurityConfig.class deleted file mode 100644 index 99829bf..0000000 Binary files a/training-system/target/classes/com/sino/training/common/config/SecurityConfig.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/config/SwaggerConfig.class b/training-system/target/classes/com/sino/training/common/config/SwaggerConfig.class deleted file mode 100644 index b998884..0000000 Binary files a/training-system/target/classes/com/sino/training/common/config/SwaggerConfig.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/config/WebMvcConfig.class b/training-system/target/classes/com/sino/training/common/config/WebMvcConfig.class deleted file mode 100644 index c7d2d65..0000000 Binary files a/training-system/target/classes/com/sino/training/common/config/WebMvcConfig.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/context/UserContext.class b/training-system/target/classes/com/sino/training/common/context/UserContext.class deleted file mode 100644 index 05ef90f..0000000 Binary files a/training-system/target/classes/com/sino/training/common/context/UserContext.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/context/UserContextHolder.class b/training-system/target/classes/com/sino/training/common/context/UserContextHolder.class deleted file mode 100644 index 7c6764f..0000000 Binary files a/training-system/target/classes/com/sino/training/common/context/UserContextHolder.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/controller/FileController.class b/training-system/target/classes/com/sino/training/common/controller/FileController.class deleted file mode 100644 index 8ec9679..0000000 Binary files a/training-system/target/classes/com/sino/training/common/controller/FileController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/dto/FileDTO.class b/training-system/target/classes/com/sino/training/common/dto/FileDTO.class deleted file mode 100644 index e21078a..0000000 Binary files a/training-system/target/classes/com/sino/training/common/dto/FileDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/ContentStatus.class b/training-system/target/classes/com/sino/training/common/enums/ContentStatus.class deleted file mode 100644 index 02199c9..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/ContentStatus.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/ExamStatus.class b/training-system/target/classes/com/sino/training/common/enums/ExamStatus.class deleted file mode 100644 index fa6ded9..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/ExamStatus.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/KnowledgeType.class b/training-system/target/classes/com/sino/training/common/enums/KnowledgeType.class deleted file mode 100644 index 649cd4a..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/KnowledgeType.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/PlanStatus.class b/training-system/target/classes/com/sino/training/common/enums/PlanStatus.class deleted file mode 100644 index 4878fa9..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/PlanStatus.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/QuestionType.class b/training-system/target/classes/com/sino/training/common/enums/QuestionType.class deleted file mode 100644 index ab1d9cf..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/QuestionType.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/TargetType.class b/training-system/target/classes/com/sino/training/common/enums/TargetType.class deleted file mode 100644 index fdcd613..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/TargetType.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/UserRole.class b/training-system/target/classes/com/sino/training/common/enums/UserRole.class deleted file mode 100644 index 8cc20b1..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/UserRole.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/enums/UserStatus.class b/training-system/target/classes/com/sino/training/common/enums/UserStatus.class deleted file mode 100644 index 16d481a..0000000 Binary files a/training-system/target/classes/com/sino/training/common/enums/UserStatus.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/exception/BusinessException.class b/training-system/target/classes/com/sino/training/common/exception/BusinessException.class deleted file mode 100644 index 7b4baac..0000000 Binary files a/training-system/target/classes/com/sino/training/common/exception/BusinessException.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/exception/GlobalExceptionHandler.class b/training-system/target/classes/com/sino/training/common/exception/GlobalExceptionHandler.class deleted file mode 100644 index a7eac00..0000000 Binary files a/training-system/target/classes/com/sino/training/common/exception/GlobalExceptionHandler.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/interceptor/AuthInterceptor.class b/training-system/target/classes/com/sino/training/common/interceptor/AuthInterceptor.class deleted file mode 100644 index ccdd66e..0000000 Binary files a/training-system/target/classes/com/sino/training/common/interceptor/AuthInterceptor.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/interceptor/RoleInterceptor.class b/training-system/target/classes/com/sino/training/common/interceptor/RoleInterceptor.class deleted file mode 100644 index 2041591..0000000 Binary files a/training-system/target/classes/com/sino/training/common/interceptor/RoleInterceptor.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/result/PageResult.class b/training-system/target/classes/com/sino/training/common/result/PageResult.class deleted file mode 100644 index 99376e8..0000000 Binary files a/training-system/target/classes/com/sino/training/common/result/PageResult.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/result/Result.class b/training-system/target/classes/com/sino/training/common/result/Result.class deleted file mode 100644 index 2f1f586..0000000 Binary files a/training-system/target/classes/com/sino/training/common/result/Result.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/result/ResultCode.class b/training-system/target/classes/com/sino/training/common/result/ResultCode.class deleted file mode 100644 index 5da7d9b..0000000 Binary files a/training-system/target/classes/com/sino/training/common/result/ResultCode.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/service/FileService.class b/training-system/target/classes/com/sino/training/common/service/FileService.class deleted file mode 100644 index 9f34ccb..0000000 Binary files a/training-system/target/classes/com/sino/training/common/service/FileService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/service/impl/LocalFileServiceImpl.class b/training-system/target/classes/com/sino/training/common/service/impl/LocalFileServiceImpl.class deleted file mode 100644 index 3026bee..0000000 Binary files a/training-system/target/classes/com/sino/training/common/service/impl/LocalFileServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/utils/JwtUtils.class b/training-system/target/classes/com/sino/training/common/utils/JwtUtils.class deleted file mode 100644 index 6056de1..0000000 Binary files a/training-system/target/classes/com/sino/training/common/utils/JwtUtils.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/common/utils/SecurityUtils.class b/training-system/target/classes/com/sino/training/common/utils/SecurityUtils.class deleted file mode 100644 index 973e001..0000000 Binary files a/training-system/target/classes/com/sino/training/common/utils/SecurityUtils.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/controller/AuthController.class b/training-system/target/classes/com/sino/training/module/auth/controller/AuthController.class deleted file mode 100644 index d9ec970..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/controller/AuthController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/dto/LoginRequest.class b/training-system/target/classes/com/sino/training/module/auth/dto/LoginRequest.class deleted file mode 100644 index 97b5557..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/dto/LoginRequest.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/dto/LoginResponse.class b/training-system/target/classes/com/sino/training/module/auth/dto/LoginResponse.class deleted file mode 100644 index 025e16f..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/dto/LoginResponse.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/dto/UserInfoResponse.class b/training-system/target/classes/com/sino/training/module/auth/dto/UserInfoResponse.class deleted file mode 100644 index 4422ea7..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/dto/UserInfoResponse.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/dto/WechatLoginRequest.class b/training-system/target/classes/com/sino/training/module/auth/dto/WechatLoginRequest.class deleted file mode 100644 index 3ab19f1..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/dto/WechatLoginRequest.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/service/AuthService.class b/training-system/target/classes/com/sino/training/module/auth/service/AuthService.class deleted file mode 100644 index e8d4555..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/service/AuthService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/auth/service/impl/AuthServiceImpl.class b/training-system/target/classes/com/sino/training/module/auth/service/impl/AuthServiceImpl.class deleted file mode 100644 index dc9e6a8..0000000 Binary files a/training-system/target/classes/com/sino/training/module/auth/service/impl/AuthServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/controller/ExamController.class b/training-system/target/classes/com/sino/training/module/exam/controller/ExamController.class deleted file mode 100644 index 949c954..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/controller/ExamController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/controller/PaperController.class b/training-system/target/classes/com/sino/training/module/exam/controller/PaperController.class deleted file mode 100644 index f67a5ed..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/controller/PaperController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/controller/QuestionCategoryController.class b/training-system/target/classes/com/sino/training/module/exam/controller/QuestionCategoryController.class deleted file mode 100644 index 024a84e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/controller/QuestionCategoryController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/controller/QuestionController.class b/training-system/target/classes/com/sino/training/module/exam/controller/QuestionController.class deleted file mode 100644 index bf21357..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/controller/QuestionController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/AutoPaperDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/AutoPaperDTO.class deleted file mode 100644 index 016b85c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/AutoPaperDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO$ExamTargetDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO$ExamTargetDTO.class deleted file mode 100644 index f952ca4..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO$ExamTargetDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO.class deleted file mode 100644 index a1f9cd5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/ExamDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/ExamQueryDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/ExamQueryDTO.class deleted file mode 100644 index 19c895e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/ExamQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO$PaperQuestionDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO$PaperQuestionDTO.class deleted file mode 100644 index fcd41d7..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO$PaperQuestionDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO.class deleted file mode 100644 index c2a3227..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/PaperDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/PaperQueryDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/PaperQueryDTO.class deleted file mode 100644 index 117d8ae..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/PaperQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionCategoryDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/QuestionCategoryDTO.class deleted file mode 100644 index 1f9aec1..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionCategoryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionDTO.class deleted file mode 100644 index 063ac33..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionListDeserializer.class b/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionListDeserializer.class deleted file mode 100644 index e5264f2..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO$OptionListDeserializer.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO.class deleted file mode 100644 index 7b85bc2..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionQueryDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/QuestionQueryDTO.class deleted file mode 100644 index 74e8762..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/QuestionQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO$AnswerDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO$AnswerDTO.class deleted file mode 100644 index f1822ad..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO$AnswerDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO.class b/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO.class deleted file mode 100644 index 8691163..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/dto/SubmitExamDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/Exam.class b/training-system/target/classes/com/sino/training/module/exam/entity/Exam.class deleted file mode 100644 index 05979e7..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/Exam.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/ExamRecord.class b/training-system/target/classes/com/sino/training/module/exam/entity/ExamRecord.class deleted file mode 100644 index 08436e5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/ExamRecord.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/ExamTarget.class b/training-system/target/classes/com/sino/training/module/exam/entity/ExamTarget.class deleted file mode 100644 index 43f7253..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/ExamTarget.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/Paper.class b/training-system/target/classes/com/sino/training/module/exam/entity/Paper.class deleted file mode 100644 index aa468bb..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/Paper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/PaperQuestion.class b/training-system/target/classes/com/sino/training/module/exam/entity/PaperQuestion.class deleted file mode 100644 index b209d10..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/PaperQuestion.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/Question.class b/training-system/target/classes/com/sino/training/module/exam/entity/Question.class deleted file mode 100644 index 4732707..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/Question.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/entity/QuestionCategory.class b/training-system/target/classes/com/sino/training/module/exam/entity/QuestionCategory.class deleted file mode 100644 index 5ecb83b..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/entity/QuestionCategory.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/ExamMapper.class deleted file mode 100644 index d620224..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamRecordMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/ExamRecordMapper.class deleted file mode 100644 index 4827c60..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamRecordMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamTargetMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/ExamTargetMapper.class deleted file mode 100644 index 2730564..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/ExamTargetMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/PaperMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/PaperMapper.class deleted file mode 100644 index 8db8c85..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/PaperMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/PaperQuestionMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/PaperQuestionMapper.class deleted file mode 100644 index 68ec3af..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/PaperQuestionMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionCategoryMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionCategoryMapper.class deleted file mode 100644 index a15aecf..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionCategoryMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionMapper.class b/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionMapper.class deleted file mode 100644 index 11920e4..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/mapper/QuestionMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/ExamRecordService.class b/training-system/target/classes/com/sino/training/module/exam/service/ExamRecordService.class deleted file mode 100644 index 42c809e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/ExamRecordService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/ExamService.class b/training-system/target/classes/com/sino/training/module/exam/service/ExamService.class deleted file mode 100644 index 9750576..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/ExamService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/PaperService.class b/training-system/target/classes/com/sino/training/module/exam/service/PaperService.class deleted file mode 100644 index 63bf53b..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/PaperService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/QuestionCategoryService.class b/training-system/target/classes/com/sino/training/module/exam/service/QuestionCategoryService.class deleted file mode 100644 index 5ad932c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/QuestionCategoryService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/QuestionService.class b/training-system/target/classes/com/sino/training/module/exam/service/QuestionService.class deleted file mode 100644 index 00f4899..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/QuestionService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$1.class deleted file mode 100644 index d47cbc4..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$AnswerDetail.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$AnswerDetail.class deleted file mode 100644 index 1286e8a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl$AnswerDetail.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.class deleted file mode 100644 index 7790306..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamRecordServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl$1.class deleted file mode 100644 index 56e6b67..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl.class deleted file mode 100644 index d2ecb4e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/ExamServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl$1.class deleted file mode 100644 index 2e43aa6..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl.class deleted file mode 100644 index 02af7e2..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/PaperServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.class deleted file mode 100644 index 464b368..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionCategoryServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl$1.class deleted file mode 100644 index 2e51fe7..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl.class b/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl.class deleted file mode 100644 index 5f9bdee..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/service/impl/QuestionServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO$ExamQuestionVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO$ExamQuestionVO.class deleted file mode 100644 index f51717c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO$ExamQuestionVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO.class deleted file mode 100644 index fe168b0..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamPaperVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO$AnswerDetailVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO$AnswerDetailVO.class deleted file mode 100644 index 9a2ac85..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO$AnswerDetailVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO.class deleted file mode 100644 index a7b8bb2..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamRecordVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO$ExamTargetVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO$ExamTargetVO.class deleted file mode 100644 index 0b8f2a8..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO$ExamTargetVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO.class deleted file mode 100644 index 753732f..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/ExamVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO$PaperQuestionVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO$PaperQuestionVO.class deleted file mode 100644 index d6d1a03..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO$PaperQuestionVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO.class deleted file mode 100644 index c21cbe3..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/PaperVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionCategoryVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/QuestionCategoryVO.class deleted file mode 100644 index af1ad67..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionCategoryVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO$OptionVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO$OptionVO.class deleted file mode 100644 index 3082c44..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO$OptionVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO.class b/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO.class deleted file mode 100644 index a82376b..0000000 Binary files a/training-system/target/classes/com/sino/training/module/exam/vo/QuestionVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/controller/CategoryController.class b/training-system/target/classes/com/sino/training/module/knowledge/controller/CategoryController.class deleted file mode 100644 index 4d19051..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/controller/CategoryController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/controller/KnowledgeController.class b/training-system/target/classes/com/sino/training/module/knowledge/controller/KnowledgeController.class deleted file mode 100644 index 524a124..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/controller/KnowledgeController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/dto/CategoryDTO.class b/training-system/target/classes/com/sino/training/module/knowledge/dto/CategoryDTO.class deleted file mode 100644 index 1743507..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/dto/CategoryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeDTO.class b/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeDTO.class deleted file mode 100644 index 221b9f5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.class b/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.class deleted file mode 100644 index bf279d8..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/dto/KnowledgeQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/entity/Category.class b/training-system/target/classes/com/sino/training/module/knowledge/entity/Category.class deleted file mode 100644 index 0f0f36f..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/entity/Category.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/entity/Knowledge.class b/training-system/target/classes/com/sino/training/module/knowledge/entity/Knowledge.class deleted file mode 100644 index 8c5c5f3..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/entity/Knowledge.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/mapper/CategoryMapper.class b/training-system/target/classes/com/sino/training/module/knowledge/mapper/CategoryMapper.class deleted file mode 100644 index 450c6a2..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/mapper/CategoryMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/mapper/KnowledgeMapper.class b/training-system/target/classes/com/sino/training/module/knowledge/mapper/KnowledgeMapper.class deleted file mode 100644 index a18021c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/mapper/KnowledgeMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/service/CategoryService.class b/training-system/target/classes/com/sino/training/module/knowledge/service/CategoryService.class deleted file mode 100644 index 69a4df1..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/service/CategoryService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/service/KnowledgeService.class b/training-system/target/classes/com/sino/training/module/knowledge/service/KnowledgeService.class deleted file mode 100644 index 94063f5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/service/KnowledgeService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.class b/training-system/target/classes/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.class deleted file mode 100644 index 9876ab1..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/CategoryServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl$1.class deleted file mode 100644 index 61b5808..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.class b/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.class deleted file mode 100644 index 0a93e3a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/service/impl/KnowledgeServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/vo/CategoryVO.class b/training-system/target/classes/com/sino/training/module/knowledge/vo/CategoryVO.class deleted file mode 100644 index 1318b7d..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/vo/CategoryVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/knowledge/vo/KnowledgeVO.class b/training-system/target/classes/com/sino/training/module/knowledge/vo/KnowledgeVO.class deleted file mode 100644 index e4086d4..0000000 Binary files a/training-system/target/classes/com/sino/training/module/knowledge/vo/KnowledgeVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/controller/CenterController.class b/training-system/target/classes/com/sino/training/module/system/controller/CenterController.class deleted file mode 100644 index 3f9b962..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/controller/CenterController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/controller/DepartmentController.class b/training-system/target/classes/com/sino/training/module/system/controller/DepartmentController.class deleted file mode 100644 index 7fc83b5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/controller/DepartmentController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/controller/GroupController.class b/training-system/target/classes/com/sino/training/module/system/controller/GroupController.class deleted file mode 100644 index 6496574..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/controller/GroupController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/controller/OrganizationController.class b/training-system/target/classes/com/sino/training/module/system/controller/OrganizationController.class deleted file mode 100644 index a8fbdcb..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/controller/OrganizationController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/controller/UserController.class b/training-system/target/classes/com/sino/training/module/system/controller/UserController.class deleted file mode 100644 index d041950..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/controller/UserController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/CenterDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/CenterDTO.class deleted file mode 100644 index a7e8b2d..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/CenterDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/DepartmentDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/DepartmentDTO.class deleted file mode 100644 index 955140e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/DepartmentDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/GroupDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/GroupDTO.class deleted file mode 100644 index c356eb3..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/GroupDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/PasswordDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/PasswordDTO.class deleted file mode 100644 index 59844be..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/PasswordDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/UserDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/UserDTO.class deleted file mode 100644 index 49689a4..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/UserDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/dto/UserQueryDTO.class b/training-system/target/classes/com/sino/training/module/system/dto/UserQueryDTO.class deleted file mode 100644 index ec5bbcb..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/dto/UserQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/entity/Center.class b/training-system/target/classes/com/sino/training/module/system/entity/Center.class deleted file mode 100644 index bec39a9..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/entity/Center.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/entity/Department.class b/training-system/target/classes/com/sino/training/module/system/entity/Department.class deleted file mode 100644 index f249245..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/entity/Department.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/entity/Group.class b/training-system/target/classes/com/sino/training/module/system/entity/Group.class deleted file mode 100644 index 7719f28..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/entity/Group.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/entity/User.class b/training-system/target/classes/com/sino/training/module/system/entity/User.class deleted file mode 100644 index 74c3ff7..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/entity/User.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/mapper/CenterMapper.class b/training-system/target/classes/com/sino/training/module/system/mapper/CenterMapper.class deleted file mode 100644 index bcd506c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/mapper/CenterMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/mapper/DepartmentMapper.class b/training-system/target/classes/com/sino/training/module/system/mapper/DepartmentMapper.class deleted file mode 100644 index c080d4e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/mapper/DepartmentMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/mapper/GroupMapper.class b/training-system/target/classes/com/sino/training/module/system/mapper/GroupMapper.class deleted file mode 100644 index 7987ecb..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/mapper/GroupMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/mapper/UserMapper.class b/training-system/target/classes/com/sino/training/module/system/mapper/UserMapper.class deleted file mode 100644 index 27fb20f..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/mapper/UserMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/CenterService.class b/training-system/target/classes/com/sino/training/module/system/service/CenterService.class deleted file mode 100644 index 73c488d..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/CenterService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/DepartmentService.class b/training-system/target/classes/com/sino/training/module/system/service/DepartmentService.class deleted file mode 100644 index 28f1083..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/DepartmentService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/GroupService.class b/training-system/target/classes/com/sino/training/module/system/service/GroupService.class deleted file mode 100644 index 7f85070..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/GroupService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/OrganizationService.class b/training-system/target/classes/com/sino/training/module/system/service/OrganizationService.class deleted file mode 100644 index 2f7b8af..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/OrganizationService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/UserService.class b/training-system/target/classes/com/sino/training/module/system/service/UserService.class deleted file mode 100644 index 49c5029..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/UserService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/CenterServiceImpl.class b/training-system/target/classes/com/sino/training/module/system/service/impl/CenterServiceImpl.class deleted file mode 100644 index 6dcae11..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/CenterServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/DepartmentServiceImpl.class b/training-system/target/classes/com/sino/training/module/system/service/impl/DepartmentServiceImpl.class deleted file mode 100644 index 90d7e70..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/DepartmentServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/GroupServiceImpl.class b/training-system/target/classes/com/sino/training/module/system/service/impl/GroupServiceImpl.class deleted file mode 100644 index e8b0460..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/GroupServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/OrganizationServiceImpl.class b/training-system/target/classes/com/sino/training/module/system/service/impl/OrganizationServiceImpl.class deleted file mode 100644 index 35e365a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/OrganizationServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl$1.class deleted file mode 100644 index 8ae4e4e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl.class b/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl.class deleted file mode 100644 index 0e2a098..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/service/impl/UserServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/vo/CenterVO.class b/training-system/target/classes/com/sino/training/module/system/vo/CenterVO.class deleted file mode 100644 index 6b38a8a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/vo/CenterVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/vo/DepartmentVO.class b/training-system/target/classes/com/sino/training/module/system/vo/DepartmentVO.class deleted file mode 100644 index 3ea33d5..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/vo/DepartmentVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/vo/GroupVO.class b/training-system/target/classes/com/sino/training/module/system/vo/GroupVO.class deleted file mode 100644 index 901ab17..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/vo/GroupVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/vo/OrgTreeVO.class b/training-system/target/classes/com/sino/training/module/system/vo/OrgTreeVO.class deleted file mode 100644 index 9471558..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/vo/OrgTreeVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/system/vo/UserVO.class b/training-system/target/classes/com/sino/training/module/system/vo/UserVO.class deleted file mode 100644 index e0ce1ec..0000000 Binary files a/training-system/target/classes/com/sino/training/module/system/vo/UserVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/controller/TrainingPlanController.class b/training-system/target/classes/com/sino/training/module/training/controller/TrainingPlanController.class deleted file mode 100644 index 082a3f6..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/controller/TrainingPlanController.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanExamDTO.class b/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanExamDTO.class deleted file mode 100644 index 0a42208..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanExamDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanKnowledgeDTO.class b/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanKnowledgeDTO.class deleted file mode 100644 index 76e8430..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanKnowledgeDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanTargetDTO.class b/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanTargetDTO.class deleted file mode 100644 index e068110..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO$PlanTargetDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO.class b/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO.class deleted file mode 100644 index a427305..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanQueryDTO.class b/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanQueryDTO.class deleted file mode 100644 index c5486e3..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/dto/TrainingPlanQueryDTO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/entity/PlanExam.class b/training-system/target/classes/com/sino/training/module/training/entity/PlanExam.class deleted file mode 100644 index 719d4ee..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/entity/PlanExam.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/entity/PlanKnowledge.class b/training-system/target/classes/com/sino/training/module/training/entity/PlanKnowledge.class deleted file mode 100644 index 51ec79c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/entity/PlanKnowledge.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/entity/PlanProgress.class b/training-system/target/classes/com/sino/training/module/training/entity/PlanProgress.class deleted file mode 100644 index c4127b3..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/entity/PlanProgress.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/entity/PlanTarget.class b/training-system/target/classes/com/sino/training/module/training/entity/PlanTarget.class deleted file mode 100644 index 29a8a16..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/entity/PlanTarget.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/entity/TrainingPlan.class b/training-system/target/classes/com/sino/training/module/training/entity/TrainingPlan.class deleted file mode 100644 index 520adf8..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/entity/TrainingPlan.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/mapper/PlanExamMapper.class b/training-system/target/classes/com/sino/training/module/training/mapper/PlanExamMapper.class deleted file mode 100644 index ea2ac3a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/mapper/PlanExamMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/mapper/PlanKnowledgeMapper.class b/training-system/target/classes/com/sino/training/module/training/mapper/PlanKnowledgeMapper.class deleted file mode 100644 index 14f5920..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/mapper/PlanKnowledgeMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/mapper/PlanProgressMapper.class b/training-system/target/classes/com/sino/training/module/training/mapper/PlanProgressMapper.class deleted file mode 100644 index 9dc393e..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/mapper/PlanProgressMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/mapper/PlanTargetMapper.class b/training-system/target/classes/com/sino/training/module/training/mapper/PlanTargetMapper.class deleted file mode 100644 index d772aa0..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/mapper/PlanTargetMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/mapper/TrainingPlanMapper.class b/training-system/target/classes/com/sino/training/module/training/mapper/TrainingPlanMapper.class deleted file mode 100644 index b46ea04..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/mapper/TrainingPlanMapper.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/service/PlanProgressService.class b/training-system/target/classes/com/sino/training/module/training/service/PlanProgressService.class deleted file mode 100644 index e8c78fb..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/service/PlanProgressService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/service/TrainingPlanService.class b/training-system/target/classes/com/sino/training/module/training/service/TrainingPlanService.class deleted file mode 100644 index 98fde17..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/service/TrainingPlanService.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.class b/training-system/target/classes/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.class deleted file mode 100644 index a0ae161..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/service/impl/PlanProgressServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl$1.class b/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl$1.class deleted file mode 100644 index 33dcdcf..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl$1.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.class b/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.class deleted file mode 100644 index 77ac036..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/service/impl/TrainingPlanServiceImpl.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$ExamProgressVO.class b/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$ExamProgressVO.class deleted file mode 100644 index 212413f..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$ExamProgressVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$KnowledgeProgressVO.class b/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$KnowledgeProgressVO.class deleted file mode 100644 index 5a6b9cf..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO$KnowledgeProgressVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO.class b/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO.class deleted file mode 100644 index 1cf2b1c..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/MyPlanVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanExamVO.class b/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanExamVO.class deleted file mode 100644 index c2d20fa..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanExamVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanKnowledgeVO.class b/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanKnowledgeVO.class deleted file mode 100644 index fdae38a..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanKnowledgeVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanTargetVO.class b/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanTargetVO.class deleted file mode 100644 index a7c6e4d..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO$PlanTargetVO.class and /dev/null differ diff --git a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO.class b/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO.class deleted file mode 100644 index 4106539..0000000 Binary files a/training-system/target/classes/com/sino/training/module/training/vo/TrainingPlanVO.class and /dev/null differ diff --git a/training-system/target/classes/mapper/.gitkeep b/training-system/target/classes/mapper/.gitkeep deleted file mode 100644 index 16742ca..0000000 --- a/training-system/target/classes/mapper/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# This file ensures the mapper directory is tracked by git diff --git a/training-system/target/classes/static/css/common.css b/training-system/target/classes/static/css/common.css deleted file mode 100644 index 496ebe3..0000000 --- a/training-system/target/classes/static/css/common.css +++ /dev/null @@ -1,552 +0,0 @@ -/** - * 道路救援企业培训系统 - 公共样式 - * 基于 Bootstrap 5 扩展 - */ - -/* ========== 布局相关 ========== */ -html, body { - height: 100%; -} - -/* 主布局容器 */ -.layout-wrapper { - display: flex; - min-height: 100vh; -} - -/* ========== 侧边栏 ========== */ -.sidebar { - width: 250px; - min-height: 100vh; - background: linear-gradient(180deg, #1a1c23 0%, #2d3748 100%); - position: fixed; - left: 0; - top: 0; - z-index: 1000; - transition: all 0.3s ease; -} - -.sidebar-logo { - height: 64px; - display: flex; - align-items: center; - padding: 0 20px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.sidebar-logo-icon { - width: 36px; - height: 36px; - background: linear-gradient(135deg, #0d6efd 0%, #0dcaf0 100%); - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - margin-right: 12px; -} - -.sidebar-logo-text { - color: #fff; - font-size: 16px; - font-weight: 600; - white-space: nowrap; -} - -/* 侧边栏菜单 */ -.sidebar-menu { - padding: 16px 0; - overflow-y: auto; - height: calc(100vh - 64px); -} - -.sidebar-menu::-webkit-scrollbar { - width: 4px; -} - -.sidebar-menu::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; -} - -.menu-item { - display: flex; - align-items: center; - padding: 12px 20px; - color: rgba(255, 255, 255, 0.7); - text-decoration: none; - transition: all 0.2s ease; - cursor: pointer; - border-left: 3px solid transparent; -} - -.menu-item:hover { - color: #fff; - background: rgba(255, 255, 255, 0.08); -} - -.menu-item.active { - color: #fff; - background: rgba(13, 110, 253, 0.2); - border-left-color: #0d6efd; -} - -.menu-item i { - font-size: 18px; - width: 24px; - margin-right: 12px; - text-align: center; -} - -.menu-group-title { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 20px; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - transition: all 0.2s ease; -} - -.menu-group-title:hover { - color: #fff; - background: rgba(255, 255, 255, 0.05); -} - -.menu-group-title i.arrow { - font-size: 12px; - transition: transform 0.2s ease; -} - -.menu-group.collapsed .menu-group-title i.arrow { - transform: rotate(-90deg); -} - -.menu-group.collapsed .menu-submenu { - display: none; -} - -.menu-submenu { - background: rgba(0, 0, 0, 0.15); -} - -.menu-submenu .menu-item { - padding-left: 56px; - font-size: 14px; -} - -/* ========== 主内容区 ========== */ -.main-wrapper { - flex: 1; - margin-left: 250px; - min-height: 100vh; - background-color: #f8f9fa; - display: flex; - flex-direction: column; -} - -/* 顶部导航 */ -.top-header { - height: 64px; - background: #fff; - border-bottom: 1px solid #e9ecef; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - position: sticky; - top: 0; - z-index: 100; -} - -.header-title { - font-size: 18px; - font-weight: 600; - color: #1a1c23; -} - -.header-user { - display: flex; - align-items: center; - cursor: pointer; -} - -.header-user .avatar { - width: 36px; - height: 36px; - border-radius: 50%; - background: linear-gradient(135deg, #0d6efd 0%, #0dcaf0 100%); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - margin-right: 8px; -} - -/* 内容区域 */ -.main-content { - flex: 1; - padding: 24px; -} - -/* ========== 页面卡片 ========== */ -.page-card { - background: #fff; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - margin-bottom: 24px; -} - -.page-card-header { - padding: 16px 24px; - border-bottom: 1px solid #e9ecef; - display: flex; - align-items: center; - justify-content: space-between; -} - -.page-card-title { - font-size: 16px; - font-weight: 600; - color: #1a1c23; - margin: 0; -} - -.page-card-body { - padding: 24px; -} - -/* ========== 统计卡片 ========== */ -.stat-card { - background: #fff; - border-radius: 8px; - padding: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - transition: all 0.2s ease; -} - -.stat-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); -} - -.stat-card-icon { - width: 48px; - height: 48px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - margin-bottom: 16px; -} - -.stat-card-icon.primary { - background: rgba(13, 110, 253, 0.1); - color: #0d6efd; -} - -.stat-card-icon.success { - background: rgba(25, 135, 84, 0.1); - color: #198754; -} - -.stat-card-icon.warning { - background: rgba(255, 193, 7, 0.1); - color: #ffc107; -} - -.stat-card-icon.info { - background: rgba(13, 202, 240, 0.1); - color: #0dcaf0; -} - -.stat-card-value { - font-size: 28px; - font-weight: 700; - color: #1a1c23; - margin-bottom: 4px; -} - -.stat-card-label { - font-size: 14px; - color: #6c757d; -} - -/* ========== 工具栏 ========== */ -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 20px; -} - -.toolbar-left { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; -} - -.toolbar-right { - display: flex; - align-items: center; - gap: 12px; -} - -/* ========== 表格增强 ========== */ -.table-wrapper { - overflow-x: auto; -} - -.table th { - font-weight: 600; - color: #495057; - background-color: #f8f9fa; - white-space: nowrap; -} - -.table td { - vertical-align: middle; -} - -.table .actions { - white-space: nowrap; -} - -.table .actions .btn { - padding: 4px 8px; - font-size: 13px; -} - -/* ========== 状态标签 ========== */ -.status-badge { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: 20px; - font-size: 12px; - font-weight: 500; -} - -.status-badge.draft { - background: #fff3cd; - color: #856404; -} - -.status-badge.published { - background: #d1e7dd; - color: #0f5132; -} - -.status-badge.offline { - background: #f8d7da; - color: #842029; -} - -.status-badge.not-started { - background: #e2e3e5; - color: #41464b; -} - -.status-badge.in-progress { - background: #cff4fc; - color: #055160; -} - -.status-badge.ended { - background: #d1e7dd; - color: #0f5132; -} - -/* ========== 空状态 ========== */ -.empty-state { - text-align: center; - padding: 48px 24px; - color: #6c757d; -} - -.empty-state i { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; -} - -.empty-state p { - margin: 0; - font-size: 14px; -} - -/* ========== 加载状态 ========== */ -.loading-state { - display: flex; - align-items: center; - justify-content: center; - padding: 48px; -} - -/* ========== 文件上传区域 ========== */ -.upload-area { - border: 2px dashed #dee2e6; - border-radius: 8px; - padding: 32px; - text-align: center; - cursor: pointer; - transition: all 0.2s ease; - background: #f8f9fa; -} - -.upload-area:hover { - border-color: #0d6efd; - background: rgba(13, 110, 253, 0.02); -} - -.upload-area.dragover { - border-color: #0d6efd; - background: rgba(13, 110, 253, 0.05); -} - -.upload-area i { - font-size: 48px; - color: #6c757d; - margin-bottom: 16px; -} - -.upload-area p { - margin: 0; - color: #6c757d; -} - -.upload-area .upload-hint { - font-size: 12px; - margin-top: 8px; -} - -/* ========== 进度条 ========== */ -.progress-wrapper { - display: flex; - align-items: center; - gap: 12px; -} - -.progress-wrapper .progress { - flex: 1; - height: 8px; -} - -.progress-wrapper .progress-text { - font-size: 13px; - font-weight: 500; - color: #495057; - min-width: 40px; - text-align: right; -} - -/* ========== 消息提示(Toast增强) ========== */ -.toast-container { - position: fixed; - top: 24px; - right: 24px; - z-index: 1100; -} - -/* ========== 答题卡样式 ========== */ -.answer-sheet { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.answer-sheet-item { - width: 36px; - height: 36px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: 500; - cursor: pointer; - border: 1px solid #dee2e6; - background: #fff; - transition: all 0.2s ease; -} - -.answer-sheet-item:hover { - border-color: #0d6efd; -} - -.answer-sheet-item.current { - border-color: #0d6efd; - background: #0d6efd; - color: #fff; -} - -.answer-sheet-item.answered { - background: #d1e7dd; - border-color: #198754; - color: #198754; -} - -/* ========== 考试计时器 ========== */ -.exam-timer { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background: #fff3cd; - border-radius: 8px; - font-weight: 600; -} - -.exam-timer.warning { - background: #f8d7da; - color: #842029; -} - -.exam-timer i { - font-size: 18px; -} - -/* ========== 响应式 ========== */ -@media (max-width: 992px) { - .sidebar { - transform: translateX(-100%); - } - - .sidebar.show { - transform: translateX(0); - } - - .main-wrapper { - margin-left: 0; - } - - .top-header { - padding: 0 16px; - } - - .main-content { - padding: 16px; - } -} - -@media (max-width: 576px) { - .toolbar { - flex-direction: column; - align-items: stretch; - } - - .toolbar-left, - .toolbar-right { - width: 100%; - justify-content: flex-start; - } - - .stat-card-value { - font-size: 24px; - } -} diff --git a/training-system/target/classes/static/exam/edit.html b/training-system/target/classes/static/exam/edit.html deleted file mode 100644 index 4d083db..0000000 --- a/training-system/target/classes/static/exam/edit.html +++ /dev/null @@ -1,350 +0,0 @@ - - - - - - 发布考试 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
考试设置
-
-
- -
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
选择参与人员
-
-
-
- - 已选择 0 人参与考试 -
-
-
- -
-
-
- - -
-
-
-
-
- - -
-
-
-
加载中...
-
-
-
-
- 返回 - -
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/list.html b/training-system/target/classes/static/exam/list.html deleted file mode 100644 index df3e59c..0000000 --- a/training-system/target/classes/static/exam/list.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - 考试管理 - 道路救援培训系统 - - - - - -
- -
-
- - -
-
-
-
-
考试管理
- 发布考试 -
-
-
-
-
- - -
- -
-
- -
-
-
- - - - - - - - - - - - - - - -
考试名称试卷考试时间参与人数及格率状态操作
-
- -
-
-
-
-
- - - - - - - - - diff --git a/training-system/target/classes/static/exam/my-exams.html b/training-system/target/classes/static/exam/my-exams.html deleted file mode 100644 index fa4f95a..0000000 --- a/training-system/target/classes/static/exam/my-exams.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - 我的考试 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
我的考试
-
-
-
-
全部
-
进行中
-
待开始
-
已结束
-
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/paper-edit.html b/training-system/target/classes/static/exam/paper-edit.html deleted file mode 100644 index 9f7b575..0000000 --- a/training-system/target/classes/static/exam/paper-edit.html +++ /dev/null @@ -1,517 +0,0 @@ - - - - - - 编辑试卷 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
试卷设置
-
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- 题目数量 - 0 -
-
- 总分 - 0 -
-
-
-
-
-
-
-
-
组卷方式
-
-
-
-
- -
手动选题
-
-
- -
随机抽题
-
-
- - -
-
-
- -
-
- -
-
-
- - -
-
-
-
-
加载中...
-
-
- -
-
- - - -
-
- -
-
-
试卷题目 0
- -
-
-

请从左侧题库中选择题目

-
-
- -
- 返回 - - -
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/paper-preview.html b/training-system/target/classes/static/exam/paper-preview.html deleted file mode 100644 index 5b8b277..0000000 --- a/training-system/target/classes/static/exam/paper-preview.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - 试卷预览 - 道路救援培训系统 - - - - - - -
- -
-
- -
- - -
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/paper.html b/training-system/target/classes/static/exam/paper.html deleted file mode 100644 index e4a322e..0000000 --- a/training-system/target/classes/static/exam/paper.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - 试卷管理 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
试卷管理
- 创建试卷 -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/question-category.html b/training-system/target/classes/static/exam/question-category.html deleted file mode 100644 index a13d240..0000000 --- a/training-system/target/classes/static/exam/question-category.html +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - 题库分类 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
题库分类
-
-
-
-
-
-
分类详情
-

请从左侧选择一个分类查看详情

-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/question-edit.html b/training-system/target/classes/static/exam/question-edit.html deleted file mode 100644 index 5268751..0000000 --- a/training-system/target/classes/static/exam/question-edit.html +++ /dev/null @@ -1,437 +0,0 @@ - - - - - - 编辑题目 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
新增题目
-
-
-
- - - -
- -
-
-
- -
单选题
- 只有一个正确答案 -
-
-
-
- -
多选题
- 可以有多个正确答案 -
-
-
-
- -
判断题
- 判断对错 -
-
-
-
- -
-
- - -
-
- - -
-
- - -
-
- -
- - -
- - -
-
-
- - -
-
-
-
A
- - -
-
-
B
- - -
-
-
C
- - -
-
-
D
- - -
-
-
- -
- -
- - - - -
- 点击选择正确答案 -
-
- - - - -
- - -
- -
- 返回 - -
-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/question.html b/training-system/target/classes/static/exam/question.html deleted file mode 100644 index dc6cde0..0000000 --- a/training-system/target/classes/static/exam/question.html +++ /dev/null @@ -1,394 +0,0 @@ - - - - - - 题目列表 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
题库分类
-
-
- 全部题目 - 0 -
- -
-
-
-
-
-
-
题目列表
- 新增题目 -
-
-
-
-
- - -
- - -
-
- - -
-
-
-
-
- -
-
-
-
-
-
-
- - - - - - - - - diff --git a/training-system/target/classes/static/exam/result.html b/training-system/target/classes/static/exam/result.html deleted file mode 100644 index e9c46e4..0000000 --- a/training-system/target/classes/static/exam/result.html +++ /dev/null @@ -1,294 +0,0 @@ - - - - - - 考试成绩 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/exam/taking.html b/training-system/target/classes/static/exam/taking.html deleted file mode 100644 index 6a937d4..0000000 --- a/training-system/target/classes/static/exam/taking.html +++ /dev/null @@ -1,332 +0,0 @@ - - - - - - 在线考试 - 道路救援培训系统 - - - - - - -
-
-
-
-
考试加载中...
-
- - - 0 - 满分 0 -
-
-
- - 00:00:00 -
-
-
-
-
-
-
-
-

正在加载试卷...

-
-
-
- -
-
- - - - - - diff --git a/training-system/target/classes/static/index.html b/training-system/target/classes/static/index.html deleted file mode 100644 index 8b964a0..0000000 --- a/training-system/target/classes/static/index.html +++ /dev/null @@ -1,358 +0,0 @@ - - - - - - 工作台 - 道路救援培训系统 - - - - - - - - -
- - - - -
- -
-
- 工作台 -
- -
- - -
- -
-
-
-
-
-
-
-

欢迎回来,用户

-

2026年1月8日 星期三

-
-
-
-
-
-
- - -
-
-
-
- -
-
0
-
待学习课程
-
-
-
-
-
- -
-
0
-
待完成考试
-
-
-
-
-
- -
-
0%
-
培训进度
-
-
-
-
-
- -
-
0h
-
本月学习时长
-
-
-
- -
- -
-
-
-
- 我的培训计划 -
- 查看全部 -
-
-
- - - - - - - - - - - - - - -
培训名称状态进度截止日期
-
- -

暂无培训计划

-
-
-
-
-
-
- - -
-
-
-
- 待完成考试 -
- 查看全部 -
-
-
- - - - - - - - - - - - - - -
考试名称考试时长剩余次数操作
-
- -

暂无待完成考试

-
-
-
-
-
-
-
- - -
-
- -
-
-
-
-
- - - - - - - diff --git a/training-system/target/classes/static/js/common.js b/training-system/target/classes/static/js/common.js deleted file mode 100644 index 1189419..0000000 --- a/training-system/target/classes/static/js/common.js +++ /dev/null @@ -1,618 +0,0 @@ -/** - * 道路救援企业培训系统 - 公共JS - * 基于 Bootstrap 5 - */ - -// API 基础路径 -const API_BASE_URL = '/api'; - -// Token 存储键名 -const TOKEN_KEY = 'training_token'; -const USER_KEY = 'training_user'; - -/** - * 获取存储的Token - */ -function getToken() { - return localStorage.getItem(TOKEN_KEY); -} - -/** - * 设置Token - */ -function setToken(token) { - localStorage.setItem(TOKEN_KEY, token); -} - -/** - * 移除Token - */ -function removeToken() { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); -} - -/** - * 获取当前用户信息 - */ -function getCurrentUser() { - const userStr = localStorage.getItem(USER_KEY); - if (!userStr || userStr === 'undefined' || userStr === 'null') { - return null; - } - return JSON.parse(userStr); -} - -/** - * 设置当前用户信息 - */ -function setCurrentUser(user) { - localStorage.setItem(USER_KEY, JSON.stringify(user)); -} - -/** - * 检查是否已登录 - */ -function isLoggedIn() { - return !!getToken(); -} - -/** - * 封装的 fetch 请求 - */ -async function request(url, options = {}) { - const token = getToken(); - - const defaultOptions = { - headers: { - 'Content-Type': 'application/json', - ...(token ? { 'Authorization': `Bearer ${token}` } : {}) - } - }; - - const mergedOptions = { - ...defaultOptions, - ...options, - headers: { - ...defaultOptions.headers, - ...options.headers - } - }; - - try { - const response = await fetch(API_BASE_URL + url, mergedOptions); - const data = await response.json(); - - // 处理未授权 - if (data.code === 401 || data.code === 1101 || data.code === 1102) { - removeToken(); - window.location.href = '/login.html'; - return null; - } - - return data; - } catch (error) { - console.error('请求失败:', error); - showMessage('网络请求失败', 'danger'); - return null; - } -} - -/** - * GET 请求 - */ -async function get(url, params = {}) { - const queryString = new URLSearchParams(params).toString(); - const fullUrl = queryString ? `${url}?${queryString}` : url; - return request(fullUrl, { method: 'GET' }); -} - -/** - * POST 请求 - */ -async function post(url, data = {}) { - return request(url, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -/** - * PUT 请求 - */ -async function put(url, data = {}) { - return request(url, { - method: 'PUT', - body: JSON.stringify(data) - }); -} - -/** - * DELETE 请求 - */ -async function del(url) { - return request(url, { method: 'DELETE' }); -} - -/** - * 文件上传 - */ -async function upload(url, file, onProgress) { - const token = getToken(); - const formData = new FormData(); - formData.append('file', file); - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && onProgress) { - const percent = Math.round((e.loaded / e.total) * 100); - onProgress(percent); - } - }); - - xhr.addEventListener('load', () => { - if (xhr.status === 200) { - resolve(JSON.parse(xhr.responseText)); - } else { - reject(new Error('上传失败')); - } - }); - - xhr.addEventListener('error', () => { - reject(new Error('上传失败')); - }); - - xhr.open('POST', API_BASE_URL + url); - if (token) { - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - } - xhr.send(formData); - }); -} - -/** - * 显示消息提示 (Bootstrap Toast) - * @param {string} text - 消息文本 - * @param {string} type - 类型: success, danger, warning, info - */ -function showMessage(text, type = 'info') { - // 确保toast容器存在 - let container = document.querySelector('.toast-container'); - if (!container) { - container = document.createElement('div'); - container.className = 'toast-container position-fixed top-0 end-0 p-3'; - container.style.zIndex = '1100'; - document.body.appendChild(container); - } - - // 图标映射 - const icons = { - success: 'bi-check-circle-fill', - danger: 'bi-x-circle-fill', - warning: 'bi-exclamation-triangle-fill', - info: 'bi-info-circle-fill' - }; - - // 创建toast元素 - const toastId = 'toast-' + Date.now(); - const toastHtml = ` - - `; - - container.insertAdjacentHTML('beforeend', toastHtml); - - const toastEl = document.getElementById(toastId); - const toast = new bootstrap.Toast(toastEl, { delay: 3000 }); - toast.show(); - - // 自动移除DOM - toastEl.addEventListener('hidden.bs.toast', () => { - toastEl.remove(); - }); -} - -/** - * 显示确认对话框 (Bootstrap Modal) - */ -function showConfirm(text, onConfirm, onCancel) { - // 确保modal容器存在 - let modal = document.getElementById('confirmModal'); - if (!modal) { - const modalHtml = ` - - `; - document.body.insertAdjacentHTML('beforeend', modalHtml); - modal = document.getElementById('confirmModal'); - } - - document.getElementById('confirmModalBody').textContent = text; - - const bsModal = new bootstrap.Modal(modal); - - const okBtn = document.getElementById('confirmModalOk'); - const newOkBtn = okBtn.cloneNode(true); - okBtn.parentNode.replaceChild(newOkBtn, okBtn); - - newOkBtn.addEventListener('click', () => { - bsModal.hide(); - onConfirm && onConfirm(); - }); - - modal.addEventListener('hidden.bs.modal', () => { - // 如果是点击取消或关闭 - }, { once: true }); - - bsModal.show(); -} - -/** - * 格式化日期 - */ -function formatDate(dateStr, format = 'YYYY-MM-DD') { - if (!dateStr) return '-'; - const date = new Date(dateStr); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - - return format - .replace('YYYY', year) - .replace('MM', month) - .replace('DD', day) - .replace('HH', hours) - .replace('mm', minutes) - .replace('ss', seconds); -} - -/** - * 格式化日期时间 - */ -function formatDateTime(dateStr) { - return formatDate(dateStr, 'YYYY-MM-DD HH:mm'); -} - -/** - * 格式化文件大小 - */ -function formatFileSize(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -/** - * 防抖函数 - */ -function debounce(func, wait) { - let timeout; - return function(...args) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} - -/** - * 节流函数 - */ -function throttle(func, limit) { - let inThrottle; - return function(...args) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; -} - -/** - * 获取URL参数 - */ -function getUrlParam(name) { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get(name); -} - -/** - * 生成分页组件 (Bootstrap Pagination) - */ -function renderPagination(current, total, pageSize, onPageChange) { - const totalPages = Math.ceil(total / pageSize); - if (totalPages <= 1) return ''; - - let html = ''; - - // 添加总数信息 - html = `
- 共 ${total} 条记录 - ${html} -
`; - - return html; -} - -/** - * 初始化页面(检查登录状态) - */ -function initPage() { - if (!isLoggedIn()) { - window.location.href = '/login.html'; - return false; - } - initUserInfo(); - initMenuState(); - return true; -} - -/** - * 初始化用户信息显示 - */ -function initUserInfo() { - const user = getCurrentUser(); - if (user) { - const userName = document.getElementById('userName'); - const userAvatar = document.getElementById('userAvatar'); - if (userName) userName.textContent = user.realName || user.username; - if (userAvatar) userAvatar.textContent = (user.realName || user.username).charAt(0); - } -} - -/** - * 初始化菜单展开/折叠状态 - */ -function initMenuState() { - document.querySelectorAll('.menu-group-title').forEach(title => { - title.addEventListener('click', function() { - const group = this.closest('.menu-group'); - group.classList.toggle('collapsed'); - }); - }); -} - -/** - * 退出登录 - */ -function logout() { - showConfirm('确定要退出登录吗?', () => { - removeToken(); - window.location.href = '/login.html'; - }); -} - -/** - * 获取状态标签HTML - */ -function getStatusBadge(status, statusName) { - const statusClass = { - 'DRAFT': 'draft', - 'PUBLISHED': 'published', - 'OFFLINE': 'offline', - 'NOT_STARTED': 'not-started', - 'IN_PROGRESS': 'in-progress', - 'ENDED': 'ended' - }; - return `${statusName || status}`; -} - -/** - * 生成侧边栏HTML - */ -function renderSidebar(activeMenu) { - return ` - - `; -} - -/** - * 生成顶部导航HTML - */ -function renderHeader(title, breadcrumb = []) { - let breadcrumbHtml = ''; - if (breadcrumb.length > 0) { - breadcrumbHtml = ` - - `; - } - - return ` -
-
- ${breadcrumbHtml || `${title}`} -
- -
- `; -} - -// 导出全局函数 -window.TrainingSystem = { - getToken, - setToken, - removeToken, - getCurrentUser, - setCurrentUser, - isLoggedIn, - request, - get, - post, - put, - del, - upload, - showMessage, - showConfirm, - formatDate, - formatDateTime, - formatFileSize, - debounce, - throttle, - getUrlParam, - renderPagination, - initPage, - initUserInfo, - logout, - getStatusBadge, - renderSidebar, - renderHeader -}; diff --git a/training-system/target/classes/static/knowledge/category.html b/training-system/target/classes/static/knowledge/category.html deleted file mode 100644 index 960af9c..0000000 --- a/training-system/target/classes/static/knowledge/category.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - 知识分类 - 道路救援培训系统 - - - - - - -
- - -
-
-
- -
- -
- -
-
-
-
-
-
分类列表
- -
-
-
-
-
-
-
-
-
分类详情
-
-

请从左侧选择一个分类查看详情

-
-
-
-
-
-
-
- - - - - - - - diff --git a/training-system/target/classes/static/knowledge/list.html b/training-system/target/classes/static/knowledge/list.html deleted file mode 100644 index a474bfa..0000000 --- a/training-system/target/classes/static/knowledge/list.html +++ /dev/null @@ -1,640 +0,0 @@ - - - - - - 知识列表 - 道路救援培训系统 - - - - - - - - -
- - - - -
- -
-
- -
- -
- - -
-
-
-
- 知识列表 -
- -
-
- -
-
-
- - -
- - - -
-
- -
-
- - -
- - - - - - - - - - - - - - - - - - -
- - 标题分类类型状态创建人创建时间操作
-
-
- 加载中... -
-
-
-
- - - -
-
-
-
-
- - - - - - - - - - diff --git a/training-system/target/classes/static/knowledge/view.html b/training-system/target/classes/static/knowledge/view.html deleted file mode 100644 index 1ebddae..0000000 --- a/training-system/target/classes/static/knowledge/view.html +++ /dev/null @@ -1,244 +0,0 @@ - - - - - - 知识学习 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/login.html b/training-system/target/classes/static/login.html deleted file mode 100644 index b5e8536..0000000 --- a/training-system/target/classes/static/login.html +++ /dev/null @@ -1,360 +0,0 @@ - - - - - - 登录 - 道路救援培训系统 - - - - - - - - - - - - - - - diff --git a/training-system/target/classes/static/system/org.html b/training-system/target/classes/static/system/org.html deleted file mode 100644 index a65fe74..0000000 --- a/training-system/target/classes/static/system/org.html +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - 组织架构 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
组织架构
-
-
-
-
-
-
组织详情
-

请从左侧选择一个组织查看详情

-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/system/setting.html b/training-system/target/classes/static/system/setting.html deleted file mode 100644 index af32f77..0000000 --- a/training-system/target/classes/static/system/setting.html +++ /dev/null @@ -1,318 +0,0 @@ - - - - - - 系统设置 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
- -
- -
-
-
个人信息
-
-
-
-
- - -
-
- -

点击更换头像

-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-
-
-
-
- - -
-
-
修改密码
-
-
-
- - -
-
- - -
密码长度至少6位
-
-
- - -
- -
-
-
-
- - -
-
-
系统设置
-
-
-
- - -
-
- -
-
- -
-
-
- - -
新建员工和重置密码时使用的默认密码
-
-
-
考试设置
-
-
- - -
-
- - -
-
-
- -
-
-
-
-
- - -
-
-
关于系统
-
-
-
-
- -
-
-

道路救援企业培训系统

-

版本 1.0.0

-
-
-
- - - - - -
技术框架Spring Boot 3.1.2
前端框架Bootstrap 5.3.2
数据库MySQL 8.0
ORM框架MyBatis Plus
-
-
-

Copyright © 2024 道路救援企业培训系统. All rights reserved.

-
-
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/system/user.html b/training-system/target/classes/static/system/user.html deleted file mode 100644 index 46b50b9..0000000 --- a/training-system/target/classes/static/system/user.html +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - 员工管理 - 道路救援培训系统 - - - - - -
- -
-
- - -
-
-
-
员工管理
-
-
-
-
- - - -
-
-
-
- - - -
员工信息用户名部门角色状态创建时间操作
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/training/detail.html b/training-system/target/classes/static/training/detail.html deleted file mode 100644 index 0a01417..0000000 --- a/training-system/target/classes/static/training/detail.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - 培训详情 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/training/my-training.html b/training-system/target/classes/static/training/my-training.html deleted file mode 100644 index 7fad7e9..0000000 --- a/training-system/target/classes/static/training/my-training.html +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - 我的培训 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
我的培训
-
-
- -
-
-
-
-
-
-
-
- - - - - - diff --git a/training-system/target/classes/static/training/plan-edit.html b/training-system/target/classes/static/training/plan-edit.html deleted file mode 100644 index 9005478..0000000 --- a/training-system/target/classes/static/training/plan-edit.html +++ /dev/null @@ -1,538 +0,0 @@ - - - - - - 编辑培训计划 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
-
基本信息
-
-
- -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
-
-
-
-
参与人员
- 0 -
-
-
-
- -
-
-
- - -
-
-
-
-
加载中...
-
-
-
-
-
-
-
-
培训内容
- 0 -
-
-
-
- -

请添加培训内容

-
-
-
-
-
- -
添加知识学习
-
-
-
-
- -
添加考试任务
-
-
-
-
-
-
- 返回 - - -
-
-
-
-
-
- - - - - - - - - - - - diff --git a/training-system/target/classes/static/training/plan.html b/training-system/target/classes/static/training/plan.html deleted file mode 100644 index 0692f8f..0000000 --- a/training-system/target/classes/static/training/plan.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - 培训计划 - 道路救援培训系统 - - - - - - -
- -
-
- - -
-
-
-
-
培训计划
- 创建计划 -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
- -
-
-
-
-
- - - - - - diff --git a/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst deleted file mode 100644 index ac86305..0000000 --- a/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ /dev/null @@ -1,176 +0,0 @@ -com\sino\training\module\exam\dto\ExamDTO.class -com\sino\training\module\system\vo\CenterVO.class -com\sino\training\module\training\dto\TrainingPlanQueryDTO.class -com\sino\training\common\utils\JwtUtils.class -com\sino\training\module\exam\service\impl\ExamRecordServiceImpl.class -com\sino\training\module\system\service\impl\UserServiceImpl$1.class -com\sino\training\module\exam\dto\QuestionDTO$OptionDTO.class -com\sino\training\module\training\entity\PlanProgress.class -com\sino\training\module\system\dto\GroupDTO.class -com\sino\training\module\training\mapper\PlanProgressMapper.class -com\sino\training\module\training\vo\TrainingPlanVO$PlanExamVO.class -com\sino\training\module\exam\service\impl\QuestionServiceImpl.class -com\sino\training\module\exam\entity\Question.class -com\sino\training\common\dto\FileDTO.class -com\sino\training\module\training\controller\TrainingPlanController.class -com\sino\training\common\enums\TargetType.class -com\sino\training\module\exam\entity\ExamTarget.class -com\sino\training\module\exam\service\impl\QuestionServiceImpl$1.class -com\sino\training\module\training\vo\MyPlanVO$ExamProgressVO.class -com\sino\training\common\config\WebMvcConfig.class -com\sino\training\module\exam\mapper\QuestionCategoryMapper.class -com\sino\training\module\exam\dto\AutoPaperDTO.class -com\sino\training\module\knowledge\service\KnowledgeService.class -com\sino\training\module\system\service\impl\OrganizationServiceImpl.class -com\sino\training\module\exam\service\impl\ExamServiceImpl$1.class -com\sino\training\module\system\vo\OrgTreeVO.class -com\sino\training\module\exam\vo\QuestionVO$OptionVO.class -com\sino\training\module\auth\dto\UserInfoResponse.class -com\sino\training\module\auth\service\impl\AuthServiceImpl.class -com\sino\training\module\exam\controller\QuestionController.class -com\sino\training\module\system\service\impl\DepartmentServiceImpl.class -com\sino\training\common\enums\QuestionType.class -com\sino\training\module\system\controller\DepartmentController.class -com\sino\training\module\system\service\impl\CenterServiceImpl.class -com\sino\training\module\auth\dto\LoginResponse.class -com\sino\training\common\result\Result.class -com\sino\training\module\exam\vo\ExamPaperVO$ExamQuestionVO.class -com\sino\training\module\exam\entity\QuestionCategory.class -com\sino\training\module\exam\service\impl\ExamServiceImpl.class -com\sino\training\module\knowledge\dto\KnowledgeDTO.class -com\sino\training\module\system\controller\UserController.class -com\sino\training\common\config\SecurityConfig.class -com\sino\training\common\service\FileService.class -com\sino\training\module\training\service\TrainingPlanService.class -com\sino\training\module\knowledge\vo\KnowledgeVO.class -com\sino\training\module\system\service\UserService.class -com\sino\training\module\exam\entity\Paper.class -com\sino\training\module\system\controller\GroupController.class -com\sino\training\module\knowledge\controller\CategoryController.class -com\sino\training\module\training\vo\TrainingPlanVO.class -com\sino\training\module\knowledge\mapper\CategoryMapper.class -com\sino\training\module\system\mapper\DepartmentMapper.class -com\sino\training\module\auth\dto\WechatLoginRequest.class -com\sino\training\module\exam\service\impl\ExamRecordServiceImpl$1.class -com\sino\training\module\system\dto\CenterDTO.class -com\sino\training\module\knowledge\service\CategoryService.class -com\sino\training\module\exam\dto\SubmitExamDTO$AnswerDTO.class -com\sino\training\module\exam\service\QuestionService.class -com\sino\training\module\exam\vo\ExamPaperVO.class -com\sino\training\module\knowledge\entity\Knowledge.class -com\sino\training\module\system\service\CenterService.class -com\sino\training\module\system\dto\PasswordDTO.class -com\sino\training\module\exam\dto\QuestionCategoryDTO.class -com\sino\training\module\exam\vo\ExamVO.class -com\sino\training\module\training\dto\TrainingPlanDTO$PlanKnowledgeDTO.class -com\sino\training\module\exam\dto\PaperDTO$PaperQuestionDTO.class -com\sino\training\common\config\SwaggerConfig.class -com\sino\training\module\training\mapper\TrainingPlanMapper.class -com\sino\training\module\system\mapper\UserMapper.class -com\sino\training\module\exam\service\impl\PaperServiceImpl.class -com\sino\training\common\annotation\RequireRole.class -com\sino\training\module\auth\dto\LoginRequest.class -com\sino\training\module\exam\dto\SubmitExamDTO.class -com\sino\training\module\training\mapper\PlanExamMapper.class -com\sino\training\module\training\vo\MyPlanVO$KnowledgeProgressVO.class -com\sino\training\module\training\service\impl\PlanProgressServiceImpl.class -com\sino\training\module\exam\controller\PaperController.class -com\sino\training\module\exam\mapper\ExamMapper.class -com\sino\training\module\exam\dto\PaperQueryDTO.class -com\sino\training\module\knowledge\vo\CategoryVO.class -com\sino\training\module\knowledge\entity\Category.class -com\sino\training\common\service\impl\LocalFileServiceImpl.class -com\sino\training\module\system\vo\GroupVO.class -com\sino\training\module\training\vo\TrainingPlanVO$PlanKnowledgeVO.class -com\sino\training\module\system\mapper\CenterMapper.class -com\sino\training\module\auth\controller\AuthController.class -com\sino\training\common\result\ResultCode.class -com\sino\training\module\exam\vo\ExamRecordVO$AnswerDetailVO.class -com\sino\training\module\exam\vo\QuestionVO.class -com\sino\training\module\exam\vo\PaperVO$PaperQuestionVO.class -com\sino\training\common\base\BaseEntity.class -com\sino\training\module\exam\dto\ExamQueryDTO.class -com\sino\training\module\training\dto\TrainingPlanDTO$PlanExamDTO.class -com\sino\training\module\exam\vo\ExamRecordVO.class -com\sino\training\module\system\service\impl\GroupServiceImpl.class -com\sino\training\module\knowledge\mapper\KnowledgeMapper.class -com\sino\training\module\system\service\OrganizationService.class -com\sino\training\module\knowledge\service\impl\KnowledgeServiceImpl$1.class -com\sino\training\module\exam\entity\Exam.class -com\sino\training\module\knowledge\service\impl\CategoryServiceImpl.class -com\sino\training\module\exam\service\impl\PaperServiceImpl$1.class -com\sino\training\module\exam\vo\ExamVO$ExamTargetVO.class -com\sino\training\common\enums\ContentStatus.class -com\sino\training\module\training\entity\PlanExam.class -com\sino\training\module\system\dto\UserQueryDTO.class -com\sino\training\module\training\dto\TrainingPlanDTO.class -com\sino\training\module\training\vo\MyPlanVO.class -com\sino\training\module\exam\mapper\ExamRecordMapper.class -com\sino\training\module\knowledge\service\impl\KnowledgeServiceImpl.class -com\sino\training\module\training\service\PlanProgressService.class -com\sino\training\module\exam\controller\ExamController.class -com\sino\training\module\system\service\DepartmentService.class -com\sino\training\module\knowledge\dto\CategoryDTO.class -com\sino\training\module\exam\entity\ExamRecord.class -com\sino\training\module\system\entity\Department.class -com\sino\training\TrainingApplication.class -com\sino\training\common\enums\UserRole.class -com\sino\training\module\exam\mapper\PaperQuestionMapper.class -com\sino\training\module\exam\service\QuestionCategoryService.class -com\sino\training\module\system\service\GroupService.class -com\sino\training\module\system\entity\Group.class -com\sino\training\module\system\service\impl\UserServiceImpl.class -com\sino\training\module\knowledge\controller\KnowledgeController.class -com\sino\training\module\training\vo\TrainingPlanVO$PlanTargetVO.class -com\sino\training\module\exam\dto\QuestionDTO.class -com\sino\training\module\exam\service\ExamRecordService.class -com\sino\training\common\exception\GlobalExceptionHandler.class -com\sino\training\module\system\vo\DepartmentVO.class -com\sino\training\module\exam\service\impl\QuestionCategoryServiceImpl.class -com\sino\training\module\system\entity\Center.class -com\sino\training\module\exam\entity\PaperQuestion.class -com\sino\training\module\system\dto\UserDTO.class -com\sino\training\module\training\service\impl\TrainingPlanServiceImpl.class -com\sino\training\common\base\PageQuery.class -com\sino\training\module\exam\dto\QuestionQueryDTO.class -com\sino\training\module\exam\service\PaperService.class -com\sino\training\module\training\mapper\PlanTargetMapper.class -com\sino\training\module\exam\controller\QuestionCategoryController.class -com\sino\training\module\training\dto\TrainingPlanDTO$PlanTargetDTO.class -com\sino\training\common\interceptor\RoleInterceptor.class -com\sino\training\module\training\entity\TrainingPlan.class -com\sino\training\module\training\service\impl\TrainingPlanServiceImpl$1.class -com\sino\training\common\enums\UserStatus.class -com\sino\training\module\knowledge\dto\KnowledgeQueryDTO.class -com\sino\training\common\utils\SecurityUtils.class -com\sino\training\module\system\entity\User.class -com\sino\training\module\exam\vo\QuestionCategoryVO.class -com\sino\training\common\enums\ExamStatus.class -com\sino\training\common\exception\BusinessException.class -com\sino\training\module\auth\service\AuthService.class -com\sino\training\module\system\controller\CenterController.class -com\sino\training\module\exam\mapper\ExamTargetMapper.class -com\sino\training\module\exam\dto\PaperDTO.class -com\sino\training\common\enums\PlanStatus.class -com\sino\training\common\config\MybatisPlusConfig$1.class -com\sino\training\module\exam\mapper\QuestionMapper.class -com\sino\training\common\context\UserContext.class -com\sino\training\common\enums\KnowledgeType.class -com\sino\training\module\system\dto\DepartmentDTO.class -com\sino\training\module\training\entity\PlanKnowledge.class -com\sino\training\common\interceptor\AuthInterceptor.class -com\sino\training\module\system\controller\OrganizationController.class -com\sino\training\module\exam\service\impl\ExamRecordServiceImpl$AnswerDetail.class -com\sino\training\common\result\PageResult.class -com\sino\training\module\system\vo\UserVO.class -com\sino\training\module\training\entity\PlanTarget.class -com\sino\training\common\controller\FileController.class -com\sino\training\module\exam\dto\ExamDTO$ExamTargetDTO.class -com\sino\training\module\training\mapper\PlanKnowledgeMapper.class -com\sino\training\common\context\UserContextHolder.class -com\sino\training\common\config\MybatisPlusConfig.class -com\sino\training\module\exam\dto\QuestionDTO$OptionListDeserializer.class -com\sino\training\module\exam\mapper\PaperMapper.class -com\sino\training\module\exam\service\ExamService.class -com\sino\training\module\exam\vo\PaperVO.class -com\sino\training\module\system\mapper\GroupMapper.class diff --git a/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst deleted file mode 100644 index 6cd74a1..0000000 --- a/training-system/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ /dev/null @@ -1,149 +0,0 @@ -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\controller\ExamController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\PaperQuestionMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\base\BaseEntity.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\entity\Category.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\mapper\TrainingPlanMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\ExamService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\PaperService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\ExamPaperVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\controller\KnowledgeController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\dto\FileDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\vo\OrgTreeVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\annotation\RequireRole.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\impl\ExamRecordServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\config\SecurityConfig.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\QuestionCategoryVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\entity\Department.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\impl\CenterServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\PasswordDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\TargetType.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\impl\ExamServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\Exam.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\CenterService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\mapper\PlanKnowledgeMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\KnowledgeType.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\impl\GroupServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\service\CategoryService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\impl\OrganizationServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\utils\SecurityUtils.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\base\PageQuery.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\controller\CategoryController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\GroupService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\mapper\UserMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\entity\TrainingPlan.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\SubmitExamDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\DepartmentDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\dto\WechatLoginRequest.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\controller\AuthController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\ExamQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\QuestionCategoryService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\entity\PlanProgress.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\vo\KnowledgeVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\mapper\CategoryMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\UserDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\entity\Group.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\UserStatus.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\CenterDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\UserService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\service\PlanProgressService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\ExamStatus.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\PaperVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\ExamDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\QuestionMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\mapper\CenterMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\result\ResultCode.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\controller\OrganizationController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\interceptor\AuthInterceptor.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\impl\PaperServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\QuestionCategoryMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\TrainingApplication.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\entity\PlanKnowledge.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\vo\MyPlanVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\GroupDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\mapper\GroupMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\entity\Knowledge.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\controller\DepartmentController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\dto\UserQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\ExamRecord.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\ExamTarget.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\PaperQuestion.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\controller\CenterController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\vo\CategoryVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\ContentStatus.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\service\impl\TrainingPlanServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\AutoPaperDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\ExamTargetMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\ExamRecordService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\controller\GroupController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\vo\UserVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\service\AuthService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\vo\DepartmentVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\service\KnowledgeService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\mapper\KnowledgeMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\UserRole.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\controller\TrainingPlanController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\QuestionType.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\PaperDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\vo\CenterVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\vo\TrainingPlanVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\config\SwaggerConfig.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\dto\TrainingPlanQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\mapper\PlanTargetMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\service\impl\LocalFileServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\mapper\DepartmentMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\interceptor\RoleInterceptor.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\dto\CategoryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\ExamVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\controller\PaperController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\controller\UserController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\entity\PlanExam.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\dto\KnowledgeDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\service\FileService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\PaperMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\dto\LoginResponse.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\result\PageResult.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\QuestionDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\dto\KnowledgeQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\ExamMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\service\impl\PlanProgressServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\dto\UserInfoResponse.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\controller\QuestionCategoryController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\QuestionService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\QuestionCategoryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\DepartmentService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\OrganizationService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\QuestionCategory.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\entity\PlanTarget.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\exception\GlobalExceptionHandler.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\impl\DepartmentServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\Paper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\impl\QuestionCategoryServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\controller\FileController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\mapper\PlanExamMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\service\impl\AuthServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\service\impl\QuestionServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\ExamRecordVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\auth\dto\LoginRequest.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\entity\Question.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\exception\BusinessException.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\context\UserContextHolder.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\config\WebMvcConfig.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\context\UserContext.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\vo\GroupVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\controller\QuestionController.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\mapper\PlanProgressMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\PaperQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\mapper\ExamRecordMapper.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\vo\QuestionVO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\entity\Center.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\entity\User.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\utils\JwtUtils.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\service\TrainingPlanService.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\result\Result.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\service\impl\KnowledgeServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\system\service\impl\UserServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\exam\dto\QuestionQueryDTO.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\knowledge\service\impl\CategoryServiceImpl.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\config\MybatisPlusConfig.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\common\enums\PlanStatus.java -D:\SINO\AI-project\training-system\src\main\java\com\sino\training\module\training\dto\TrainingPlanDTO.java diff --git a/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst deleted file mode 100644 index 13cd125..0000000 --- a/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst +++ /dev/null @@ -1,34 +0,0 @@ -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$UpdateTest.class -com\sino\training\module\auth\LoginIntegrationTest$TokenValidationTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$DeleteTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest$CreateCategoryTest.class -com\sino\training\common\utils\JwtUtilsTest$TokenIntegrityTest.class -com\sino\training\module\auth\service\AuthServiceTest$GetCurrentUserInfoTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$GetTreeTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest$GetCategoryTreeTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest.class -com\sino\training\common\utils\JwtUtilsTest$GenerateTokenTest.class -com\sino\training\module\auth\LoginIntegrationTest$LoginResponseTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest.class -com\sino\training\module\auth\LoginIntegrationTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest$GetCategoryDetailTest.class -com\sino\training\module\auth\service\AuthServiceTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$CreateTest.class -com\sino\training\common\utils\JwtUtilsTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest$DeleteCategoryTest.class -com\sino\training\module\auth\LoginIntegrationTest$PasswordLoginTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$GetDetailTest.class -com\sino\training\module\auth\controller\AuthControllerTest.class -com\sino\training\module\auth\PasswordEncoderTest.class -com\sino\training\module\auth\controller\AuthControllerTest$GetUserInfoTest.class -com\sino\training\module\exam\service\QuestionCategoryServiceTest$UpdateCategoryTest.class -com\sino\training\common\utils\JwtUtilsTest$GetUserIdTest.class -com\sino\training\module\auth\service\AuthServiceTest$LogoutTest.class -com\sino\training\common\utils\JwtUtilsTest$IsTokenExpiredTest.class -com\sino\training\module\auth\service\AuthServiceTest$LoginTest.class -com\sino\training\common\utils\JwtUtilsTest$GetUsernameTest.class -com\sino\training\module\auth\controller\AuthControllerTest$LogoutTest.class -com\sino\training\common\utils\JwtUtilsTest$GetRoleTest.class -com\sino\training\common\utils\JwtUtilsTest$VerifyTokenTest.class -com\sino\training\module\auth\controller\AuthControllerTest$LoginTest.class -com\sino\training\module\exam\controller\QuestionCategoryControllerTest$GetListTest.class diff --git a/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst deleted file mode 100644 index 5aa3964..0000000 --- a/training-system/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst +++ /dev/null @@ -1,7 +0,0 @@ -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\auth\LoginIntegrationTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\auth\PasswordEncoderTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\auth\service\AuthServiceTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\exam\controller\QuestionCategoryControllerTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\exam\service\QuestionCategoryServiceTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\common\utils\JwtUtilsTest.java -D:\SINO\AI-project\training-system\src\test\java\com\sino\training\module\auth\controller\AuthControllerTest.java diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GenerateTokenTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GenerateTokenTest.class deleted file mode 100644 index 336d0d8..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GenerateTokenTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetRoleTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetRoleTest.class deleted file mode 100644 index 270a968..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetRoleTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUserIdTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUserIdTest.class deleted file mode 100644 index c12751a..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUserIdTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUsernameTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUsernameTest.class deleted file mode 100644 index 6527381..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$GetUsernameTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$IsTokenExpiredTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$IsTokenExpiredTest.class deleted file mode 100644 index 546130d..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$IsTokenExpiredTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$TokenIntegrityTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$TokenIntegrityTest.class deleted file mode 100644 index 59c272b..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$TokenIntegrityTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$VerifyTokenTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$VerifyTokenTest.class deleted file mode 100644 index d3a2284..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest$VerifyTokenTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest.class b/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest.class deleted file mode 100644 index 5991209..0000000 Binary files a/training-system/target/test-classes/com/sino/training/common/utils/JwtUtilsTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$LoginResponseTest.class b/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$LoginResponseTest.class deleted file mode 100644 index bdbe44d..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$LoginResponseTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$PasswordLoginTest.class b/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$PasswordLoginTest.class deleted file mode 100644 index 9b91312..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$PasswordLoginTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$TokenValidationTest.class b/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$TokenValidationTest.class deleted file mode 100644 index b3ab6c2..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest$TokenValidationTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest.class b/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest.class deleted file mode 100644 index dad2dba..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/LoginIntegrationTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/PasswordEncoderTest.class b/training-system/target/test-classes/com/sino/training/module/auth/PasswordEncoderTest.class deleted file mode 100644 index bdd8ef1..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/PasswordEncoderTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$GetUserInfoTest.class b/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$GetUserInfoTest.class deleted file mode 100644 index abb126a..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$GetUserInfoTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LoginTest.class b/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LoginTest.class deleted file mode 100644 index b2e106a..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LoginTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LogoutTest.class b/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LogoutTest.class deleted file mode 100644 index 2ae9413..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest$LogoutTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest.class b/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest.class deleted file mode 100644 index beca7ac..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/controller/AuthControllerTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$GetCurrentUserInfoTest.class b/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$GetCurrentUserInfoTest.class deleted file mode 100644 index eac6948..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$GetCurrentUserInfoTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LoginTest.class b/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LoginTest.class deleted file mode 100644 index b7e2d41..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LoginTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LogoutTest.class b/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LogoutTest.class deleted file mode 100644 index 5109df3..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest$LogoutTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest.class b/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest.class deleted file mode 100644 index 64b33c7..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/auth/service/AuthServiceTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$CreateTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$CreateTest.class deleted file mode 100644 index cc30aab..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$CreateTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$DeleteTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$DeleteTest.class deleted file mode 100644 index 1160199..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$DeleteTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetDetailTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetDetailTest.class deleted file mode 100644 index 311a94f..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetDetailTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetListTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetListTest.class deleted file mode 100644 index e45fb56..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetListTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetTreeTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetTreeTest.class deleted file mode 100644 index 0d25fca..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$GetTreeTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$UpdateTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$UpdateTest.class deleted file mode 100644 index 81ae3fe..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest$UpdateTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.class b/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.class deleted file mode 100644 index 7467229..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/controller/QuestionCategoryControllerTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$CreateCategoryTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$CreateCategoryTest.class deleted file mode 100644 index 886561f..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$CreateCategoryTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$DeleteCategoryTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$DeleteCategoryTest.class deleted file mode 100644 index fe285d7..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$DeleteCategoryTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryDetailTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryDetailTest.class deleted file mode 100644 index 21db8b6..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryDetailTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryTreeTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryTreeTest.class deleted file mode 100644 index 338e90d..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$GetCategoryTreeTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$UpdateCategoryTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$UpdateCategoryTest.class deleted file mode 100644 index 53e7b7d..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest$UpdateCategoryTest.class and /dev/null differ diff --git a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest.class b/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest.class deleted file mode 100644 index b7e503c..0000000 Binary files a/training-system/target/test-classes/com/sino/training/module/exam/service/QuestionCategoryServiceTest.class and /dev/null differ diff --git a/training-system/docs/Prompt/Codeing_Prompt.md b/training/.claude/prompt/Codeing_Prompt.md similarity index 100% rename from training-system/docs/Prompt/Codeing_Prompt.md rename to training/.claude/prompt/Codeing_Prompt.md diff --git a/training-system/docs/Prompt/PM_Prompt.md b/training/.claude/prompt/PM_Prompt.md similarity index 100% rename from training-system/docs/Prompt/PM_Prompt.md rename to training/.claude/prompt/PM_Prompt.md diff --git a/training-system/docs/Prompt/Tester_Prompt.md b/training/.claude/prompt/Tester_Prompt.md similarity index 100% rename from training-system/docs/Prompt/Tester_Prompt.md rename to training/.claude/prompt/Tester_Prompt.md diff --git a/training/.claude/prompt/demand-assessor.md b/training/.claude/prompt/demand-assessor.md new file mode 100644 index 0000000..1cc3ef3 --- /dev/null +++ b/training/.claude/prompt/demand-assessor.md @@ -0,0 +1,203 @@ +你是一个精通 Java 微服务架构的资深技术负责人。 + +请基于以下统一模型评估需求的工作量。 + +============================== +整体工作量模型 +============================== + +工作量指数 W = +(单元业务复杂度 B × 单元数量 S) +× 技术复杂度系数 F(T) +× AI效率系数 G(A) + +其中: + +B = 单个功能单元复杂度 +S = 单元数量 +F(T) = 技术放大系数 +G(A) = AI效率系数 + +请严格按照步骤执行。 + + +==================================================== +第一部分:识别功能单元(规模 S) +==================================================== + +1. 识别最小可重复功能单元 + (例如:接口、页面、报表、定时任务等) + +2. 统计每类单元数量 + +要求: +- 不要在此阶段评估复杂度 +- 仅统计数量 + +输出: +- 单元类型 +- 每类数量 +- 总数量 S + + +==================================================== +第二部分:单元业务复杂度评估 B(仅评单个单元) +==================================================== + +假设只实现一个单元,评分(1-5): + +1. 业务规则数量 +2. 状态流转复杂度 +3. 异常处理复杂度 +4. 边界场景复杂度 +5. 需求不确定性 + +计算: + +B = 五项平均值(保留1位小数) + +注意: +禁止在此阶段考虑单元数量。 + +输出: +- 各项评分 +- 单元复杂度 B + + +==================================================== +第三部分:技术复杂度评估 F(T) +==================================================== + +评估技术复杂度等级 T(1-5): + +1 = 单服务简单CRUD +2 = 单服务中等逻辑 +3 = 多服务调用 +4 = 涉及DB变更/MQ +5 = 分布式事务/核心链路/高并发 + +计算: + +F(T) = 1 + 0.2 × (T - 1) + +输出: +- T值 +- 技术复杂度系数 F(T) +- 技术风险说明 + + +==================================================== +第四部分:AI效率系数 G(A) 计算 +==================================================== + +步骤A:正向评分(1-5分) + +1.需求清晰 +2.规则明确度 +3.可验证性 +4.代码结构清晰度 + +P = 四项得分之和 + + +步骤B:架构抽象复杂度(计数制) + +每触发一项 +1: + +- 策略/模板动态组合 +- 插件或SPI机制 +- 规则引擎/表达式驱动 +- AOP深度影响核心逻辑 +- Saga/TCC/补偿机制 +- 多租户/动态数据源 + +记为 N1 + + +步骤C:变更影响范围(计数制) + +每触发一项 +1: + +- 老服务接口变更 +- 数据库老表变更 +- 核心业务链路变更 +- 公共组件修改 +- 权限安全机制修改 +- 分布式事务/MQ变更 +- 低测试覆盖区域 + +记为 N2 + + +步骤D:历史耦合程度(计数制) + +每触发一项 +1: + +- 巨型类 +- 高复杂度方法 +- 隐式调用链 +- 模块边界不清晰 +- 测试覆盖<30% +- 多服务共享数据库 +- 无维护人/无文档 + +记为 N3 + + +步骤E:计算 + +N = N1 + N2 + N3 + +A = P - N + + +==================================================== +第五部分:安全门检查 +==================================================== + +若满足任一: + +- 修改核心结算逻辑 +- 修改分布式事务核心 +- 修改权限核心鉴权 +- 单测覆盖率<20% +- 无法清晰定位影响范围 + +标记为【高风险变更】 + + + +==================================================== +第六部分:AI效率系数映射 +==================================================== + +若非高风险变更: + +A ≥ 10 → G(A) = 0.55 +8 ≤ A < 10 → G(A) = 0.65 +6 ≤ A < 8 → G(A) = 0.75 +3 ≤ A < 6 → G(A) = 0.85 +1 ≤ A < 3 → G(A) = 0.95 +A ≤ 0 → G(A) = 1.0 + + +==================================================== +第七部分:最终工作量计算 +==================================================== + +计算: + +基础工作量 = B × S +技术放大后 = 基础工作量 × F(T) +最终工作量指数 W = 技术放大后 × G(A) + +请输出: + +1. 单元数量 S +2. 单元复杂度 B +3. 技术系数 F(T) +4. AI效率系数 G(A) +5. 最终工作量指数 W +6. 综合风险等级(低/中/高) +7. 是否建议拆分需求 +8. AI建议参与方式 \ No newline at end of file diff --git a/training/.claude/prompt/document_Prompt.md b/training/.claude/prompt/document_Prompt.md new file mode 100644 index 0000000..859c23e --- /dev/null +++ b/training/.claude/prompt/document_Prompt.md @@ -0,0 +1,136 @@ +你现在是一名资深产品架构师 + 系统架构师 + 技术产品经理。 + +你将读取一下文档: +1. 项目文档 :/codes/training-system/docs/features +2. 代码图谱:/assets/codemap +3. 文档图谱:/assets/domainmap +4. 接口定义:/codes/training-system/docs/architecture +5. 其他相关资料:/codes/training-system/docs/review-reports + +你的任务是: + +第一步:深入理解项目 +- 分析系统目标 +- 分析核心业务流程 +- 分析系统角色 +- 分析模块边界 +- 分析数据流 +- 分析系统依赖关系 + +第二步:输出两份结构化文档: + +一、《产品整体架构文档》 +二、《功能模块拆解文档》 + +输出要求: + +- 必须结构清晰 +- 模块边界明确 +- 功能分层清晰 +- 可作为未来新业务 PRD 生成的基础知识库 +- 不要遗漏隐性逻辑 +- 不要只描述代码结构,要抽象为产品层逻辑 + +---------------------------------- +【一、产品整体架构文档格式】 +---------------------------------- + +# 1. 产品定位 +- 产品目标 +- 核心价值 +- 目标用户 +- 使用场景 + +# 2. 系统整体架构 +- 架构分层(表现层 / 业务层 / 数据层 / 基础设施层) +- 技术架构概览 +- 系统依赖关系 + +# 3. 业务架构图(Mermaid 图) +- 核心业务域划分 +- 各业务域之间的关系 +- 主业务流程 + +# 4. 核心业务流程说明(Mermaid 图) +- 用户主流程 +- 管理流程 +- 数据流转流程 + +# 5. 数据架构 +- 核心数据实体 +- 实体之间关系 +- 数据生命周期 + +# 6. 权限与角色体系 +- 角色定义 +- 权限分层 +- 控制逻辑 + +# 7. 系统扩展点分析 +- 可扩展模块 +- 可插拔能力 +- 易变业务点 +- +# 8. 文档存放目录 +- /context/01-产品架构 + +---------------------------------- +【二、功能模块拆解文档格式】 +---------------------------------- + +按模块拆解,每个模块分别生产一个文档,包含: + +# 模块名称 + +## 1. 模块定位 +- 模块目标 +- 解决问题 + +## 2. 功能清单 +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | + +## 3. 核心逻辑 +- 业务规则 +- 校验逻辑 +- 状态流转 + +## 4. 数据结构 +- 涉及数据表 +- 字段说明 +- 数据关联 + +## 5. 对外接口 +- API 列表 +- 事件机制 +- 回调机制 + +## 6. 异常与边界处理 + +## 7. 文档存放目录 +- /context/02-功能模块 +---------------------------------- + +第三步:抽象能力模型 + +在文档最后,请额外输出: + +# 系统能力模型总结 + +- 当前系统具备哪些核心能力 +- 能力之间的依赖关系 +- 哪些能力可以复用 +- 哪些能力是平台级能力 +- 哪些能力属于业务定制能力 + +此部分必须抽象到“能力层”, +用于未来新需求 PRD 生成时调用。 + +---------------------------------- + +输出要求: + +- 逻辑严谨 +- 不要泛泛而谈 +- 不要重复代码结构 +- 必须抽象为产品视角 +- 生成可长期维护的产品知识基座 \ No newline at end of file diff --git a/training/.claude/prompt/front_arch_agent.md b/training/.claude/prompt/front_arch_agent.md new file mode 100644 index 0000000..914e1ab --- /dev/null +++ b/training/.claude/prompt/front_arch_agent.md @@ -0,0 +1,34 @@ +你是一个企业级前端架构 Agent。 + +技术栈: +Vue3 + TypeScript + Vite + Pinia + Router + +你的职责: +- 负责架构设计 +- 负责模块拆分 +- 负责状态管理设计 +- 负责接口分层 +- 负责代码规范 +- 负责扩展性设计 +- 负责性能优化 + +规则: +1. 先设计架构,不直接写代码 +2. 目录结构必须清晰 +3. 页面为容器,组件为功能块 +4. 组件不写 API +5. API 不写状态 +6. Store 不写 UI +7. 复杂逻辑必须抽 hooks +8. 单文件不得超过 400 行 +9. 必须符合企业级维护标准 +10. 代码必须可扩展 + +输出流程: +1. 架构分析 +2. 模块拆分 +3. 状态设计 +4. API 设计 +5. 类型设计 +6. 分步生成代码 +7. 最后做架构审查 \ No newline at end of file diff --git a/training/.claude/skills/pmassist/SKILL.md b/training/.claude/skills/pmassist/SKILL.md new file mode 100644 index 0000000..85137b2 --- /dev/null +++ b/training/.claude/skills/pmassist/SKILL.md @@ -0,0 +1,406 @@ +--- +name: pmassist +description: | + 产品文档协作与缺陷分析助手。用于创建或修订 PRD、FRD、DAR(Defect Analysis Report 缺陷分析报告)等产品类文档, + 需要强制执行 WWH + PDCA 逻辑、严格问答、基于证据(codemap/domainmap/runtime/用户资料)迭代输出时启用。 +--- + +# pmassist + +## 核心规则(强制) +- **必须执行 WWH + PDCA**,任何阶段不可跳过。 +- **必须问答闭环**:每轮必须提出问题清单;P0 问题未解答不得进入下轮输出。 +- **必须证据标注**:文档中每个关键结论、数据、规则必须标注来源。 +- **必须留痕**:每轮对话都写入 `summary.md` 与 `rounds/round_N.md`。 +- **若 runtime 缺失**:允许继续,但相关内容需标注 `[ASSUMPTION]`。 +- **必须包含图表**:最终文档至少包含 1 个 mermaid 图和 1 个表;缺失则在 Check 阶段补齐。 +- **所有决策必须基于 context 目录中的:产品文档、产品架构文档、功能模块文档、系统能力模型(如有)等,禁止凭空假设系统能力。 +- **若存在 CodeMap/DomainMap**:必须深挖到“页面/字段/调用链/分支证据”层级,而非仅域级概览。 +- **必须完成证据→章节映射**:每个章节至少 1 条证据或明确假设标记,否则不能定稿。 +- **若提供参考样本/既有文档**:必须做覆盖度对比检查,列出差异点清单。 + +## 0) 文档类型分流(先做) +根据用户初始描述进行分支;不确定就追问: +- **PRD**:新需求、流程优化、产品规划、业务方案、用户体验。 +- **FRD**:具体功能实现、接口/数据/流程细节、技术落地规格。 +- **DAR**:线上缺陷、事故复盘、根因分析、纠正预防。 + +> 选择后加载对应模板: +- PRD → `references/prd.md` +- FRD → `references/frd.md` +- DAR → `references/dar.md` + +## 0.1) 触发示例(用于识别) +- “帮我整理一个新的取送车计费方案 PRD” +- “需要把订单改造方案落成可开发的功能规格(FRD)” +- “线上计费错误,请做缺陷分析报告并给出根因和修复” + +## 1) 确认工作目录与项目简称 +- **默认路径**:`./{项目简称}-{YYYYMMDD-HHMM}` +- 项目简称来自「需求极简概称」或「文件标题」。 +- **必须询问用户确认**;未确认不得创建目录。 + +## 1.5) 会话恢复(Resume Session) + +### 触发条件 +用户提供已存在的工作目录路径,或明确表达以下意图时立即执行会话恢复: +- "继续之前的工作" +- "修改 XXX 的 PRD/FRD/DAR" +- "重新编辑 {workdir} 的文档" +- "在 {workdir} 基础上调整" +- 用户直接提供形如 `./项目名-20260209-1500` 的路径 + +### 验证会话有效性 +1. 检查目录是否存在 +2. 验证必备文件:`session.yaml`、`desc.md`、`summary.md` +3. 若任一缺失 → 提示损坏,建议创建新会话 + +### 状态回顾(自动生成报告) +读取以下文件: +- `session.yaml` → 获取文档类型、当前 Round、状态 +- `summary.md` → 回顾已完成内容 +- `questions/round_*.yaml` → 统计遗留问题(P0/P1/P2) +- `outputs/{doc_type}.md` → 检查章节完成度 +- `session.yaml` 的 `prototype` 块 → 原型状态(如果启用) + +生成**会话状态报告**并展示给用户: +```markdown +📊 会话状态报告 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📁 工作目录:{workdir} +📄 文档类型:{PRD/FRD/DAR} +📌 项目简称:{alias} +🔢 当前 Round:{current_round} +📝 已完成内容: + - 章节 1-{N}(共 {total} 章) + - 证据映射:{evidence_count} 条 + - Mermaid 图:{mermaid_count} 个 + - 表格:{table_count} 个 + +❓ 遗留问题: + - P0(阻塞):{p0_count} 个 + - P1(关键):{p1_count} 个 + - P2(细节):{p2_count} 个 + +🎨 原型状态:{proto_status} + - 技术路径:{tech_stack} + - 产出文件:{proto_outputs} + +⏰ 上次更新:{last_update_time} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### 询问工作模式 +展示报告后,**必须询问**用户选择工作模式: + +**A. 继续模式**(Continue) +- 接续当前 Round,补充未完成章节 +- 优先解决 P0 遗留问题 +- 继续执行 PDCA 循环直到本轮收敛 + +**B. 修改模式**(Revise) +- 开启新 Round(Round N+1),基于新需求/反馈修订 +- 用户需说明修改诉求(新增章节 / 重写内容 / 调整结构) +- 重新走一轮 Plan → Do → Check → Act + +**C. 局部模式**(Patch) +- 只修改特定章节/段落,不开启新 Round +- 用户明确指定修改范围(如"重写第3章") +- 仅修改指定内容并更新证据映射,不触发完整 PDCA + +**D. 原型模式**(Prototype) +- 更新/重新生成原型(独立于文档迭代) +- 执行 Proto Round 1-3 流程(见 2.6 节) +- 用户需说明原型诉求(新增页面 / 修改样式 / 重构交互) + +**E. 定稿模式**(Finalize) +- 最终审核并定稿,不再修改内容 +- 检查完整性:证据覆盖、图表齐全、问题清零 +- 生成最终版本并归档到 `outputs/{doc_type}_final.md` + +### 工作模式执行 + +#### A. 继续模式流程 +1. 读取 `rounds/round_{N}.md` 获取上次工作内容 +2. 读取 `questions/round_{N}.yaml` 获取未答问题 +3. 若存在 P0 问题 → 先解决 P0 再继续 +4. 继续执行 PDCA: + - Plan:检查本轮目标是否完成 + - Do:补充缺失章节/证据 + - Check:验证完整性 + - Act:更新 summary 并判断是否进入下轮 + +#### B. 修改模式流程 +1. 创建 `rounds/round_{N+1}.md` +2. 在 `round_{N+1}.md` 头部记录修改诉求 +3. 更新 `session.yaml` 中的 `current_round` 为 N+1 +4. 开启新一轮 PDCA 循环: + - Plan:分析修改影响范围,提出问题清单 + - Do:执行修改并更新证据链 + - Check:对比修改前后差异,验证一致性 + - Act:更新 decision_log.md 记录变更原因 + +#### C. 局部模式流程 +1. **不创建新 Round**,在当前 Round 的 `round_{N}.md` 追加修改记录 +2. 读取目标章节当前内容 +3. 执行修改(覆盖/插入/删除) +4. 更新 `outputs/{doc_type}.md` 中的对应章节 +5. 检查证据映射是否需要更新 +6. 在 `decision_log.md` 追加局部修改记录 +7. **不触发 Check-Act**,完成后直接返回 + +#### D. 原型模式流程 +参见 **2.6 节 原型设计环节**,执行 Proto Round 1-3 + +#### E. 定稿模式流程 +1. **完整性检查**: + - 所有 P0 问题已解决 + - 每章至少 1 条证据或 `[ASSUMPTION]` + - 至少 1 个 Mermaid 图、1 个表格 + - 章节编号/标题/目录一致 +2. **证据覆盖度检查**: + - 生成章节 vs 证据映射表 + - 标注未覆盖章节(需补充或标注假设) +3. **定稿操作**: + - 复制 `outputs/{doc_type}.md` → `outputs/{doc_type}_final.md` + - 在末尾追加"定稿信息":时间、版本、审核人 + - 更新 `session.yaml` 状态为 `finalized` + - 更新 `summary.md` 标注定稿时间 +4. **交付物清单**: + - 最终文档:`outputs/{doc_type}_final.md` + - 原型文件(如有):`prototypes/*` + - 决策日志:`decision_log.md` + - 证据索引:`materials_index.md` + +### 特殊处理 + +#### 会话版本升级 +若检测到 `session.yaml` 格式过旧(缺少 `prototype` 块),提示: +``` +⚠️ 检测到旧版会话格式(v1.x),是否升级到 v2.0? +- [Y] 自动增加 prototype 配置块并创建 prototypes/ 目录 +- [N] 保持原样继续(不支持原型功能) +``` + +#### 损坏会话恢复 +若必备文件损坏或缺失: +1. 尝试从备份恢复(检查 `.backup/` 目录) +2. 若无备份,询问用户: + - [A] 基于现有文件重建 session.yaml + - [B] 放弃恢复,创建新会话 + +## 2) 初始化工作区(确认后执行) +目录结构: +``` +{workdir}/ + desc.md + session.yaml + summary.md + decision_log.md + materials/ + materials_index.md + rounds/ + questions/ + outputs/ +``` + +必备文件: +- `desc.md`:原始需求 + WWH(What/Why/How) +- `session.yaml`:文档类型、当前轮次、状态、未决问题 +- `summary.md`:每轮摘要(<=20 行) +- `decision_log.md`:关键决策与变更 +- `materials_index.md`:资料索引与引用 ID + +## 2.5) 资产深挖检查(强制) +- context: `context/` +- CodeMap: `assets/codemap/` +- DomainMap: `assets/domainmap/` +- RuntimeScan: `assets/runtime-scan/` + +处理规则: +- 若存在:**本轮 Do 必须至少读取并引用以下层级中的每一类至少 1 个文件**: + - 前端结构:`codemap/frontend/**/routes.yaml` / `views.yaml` / `dialog_branches.yaml` + - 后端字段:`codemap/serve/dataobjects/java/*.yaml` + - 后端调用链:`codemap/serve/callchains/java/domains/*.yaml` + - 领域证据:`domainmap/*.yaml`(优先 `branch_evidence.yaml`) +- 若缺失:提示影响并询问是否补全;用户拒绝则记录 `[ASSUMPTION]` 并继续。 + +## 2.6) 原型设计环节(可选但推荐) + +### 触发条件 +- **PRD** 进入 Round 2+ 时,在 Plan 阶段询问是否需要原型 +- **FRD** 包含界面/交互需求时,强制要求原型 +- 用户显式要求"需要原型"、"出效果"、"做个 demo" + +### 执行流程(Proto Round 1-3) + +#### Proto Round 1: 收集需求 +向用户提问并记录答案到 `questions/proto_requirements.yaml`: +- **PROTO-1-1 (P0)**: 原型范围?(整体流程 PoC / 核心页面 / 局部组件 / 特定效果) +- **PROTO-1-2 (P0)**: 参考来源?(URL / 截图 / 文字描述 / 从零设计) +- **PROTO-1-3 (P1)**: 保真度?(低保真 / 中保真 / 高保真) +- **PROTO-1-4 (P1)**: 技术实现?(Pencil / Web Artifact / 两者都要) + +#### Proto Round 2: 实现原型 +根据 Proto Round 1 的答案选择技术路径: + +**路径 A: Pencil 设计稿**(适合静态视觉展示) +1. 使用 `mcp__pencil__get_style_guide_tags()` 获取设计风格标签 +2. 使用 `mcp__pencil__get_style_guide(tags=[...])` 获取设计指南 +3. 使用 `mcp__pencil__open_document("new")` 创建画布 +4. 使用 `mcp__pencil__batch_design(operations=...)` 批量设计 +5. 使用 `mcp__pencil__get_screenshot(nodeId=...)` 生成截图 +6. 保存至 `prototypes/design.pen` 与 `prototypes/screenshots/` + +**路径 B: Web Artifact 交互原型**(适合可点击演示) +1. 使用 `Skill(skill="document-skills:frontend-design", args="...")` 生成 React/HTML +2. 保存至 `prototypes/webapp/` +3. 可选:使用 `document-skills:webapp-testing` 验证交互 + +**路径 C: 基于 URL/截图范本** +1. **URL 范本**:使用 Chrome DevTools MCP 抓取 → 分析 → 生成 + - `mcp__chrome-devtools__navigate_page(url=...)` + - `mcp__chrome-devtools__take_screenshot(filePath=...)` + - `mcp__chrome-devtools__take_snapshot(filePath=...)` + - 保存至 `materials/prototypes/reference/` +2. **截图范本**:Read 读取图片 → 提取元素 → 生成 + - 用户上传到 `materials/prototypes/reference/` + - 使用 Read 工具读取(支持图片) + - 记录分析到 `prototypes/design_analysis.md` +3. 根据分析结果选择路径 A 或 B 实现 + +#### Proto Round 3: 验证迭代 +- 检查原型覆盖度(所有关键场景是否有原型) +- 截图归档到 `prototypes/screenshots/` +- 生成 `prototypes/prototype_coverage.md` 对照表 +- 收集用户反馈到 `questions/proto_feedback_N.yaml` +- 若需调整则返回 Proto Round 2,否则标记 `prototype.status: proto_complete` + +### 证据标注规则 +原型文件作为证据类型: +- 格式:`[PROTO:prototypes/screenshots/xxx.png]` 或 `[PROTO:prototypes/webapp/index.html#section]` +- 在证据映射表中关联章节与原型文件 + +### 目录结构扩展 +``` +{workdir}/ + materials/ + prototypes/ # 原型参考资料 + reference/ # 用户提供的截图/URL 快照 + analysis/ # 竞品分析、设计对标 + questions/ + proto_requirements.yaml # 原型需求问答 + proto_feedback_N.yaml # 原型反馈轮次 + prototypes/ # 原型产出目录 + design.pen # Pencil 设计文件 + screenshots/ # 原型截图 + webapp/ # Web 原型代码 + design_analysis.md # 设计决策记录 + prototype_coverage.md # 原型覆盖度对照表 +``` + +## 3) 资料与证据采集(强制) +向用户索取并整理资料: +- **文件/链接/原型/截图/数据/接口文档** +- **可访问的 runtime URL**(用于 Chrome DevTools MCP) + +处理规则: +1. **读取并摘要**:对每个资料做 5-10 行摘要。 +2. **存档**:保存到 `materials/`,更新 `materials_index.md`。 +3. **引用 ID**:为每份资料分配 `SRC-001` 形式的 ID。 +4. **使用时引用**:在文档内容中标注 `[SRC-001]`。 +5. **公开模板/行业规范**若被引用,也需登记为来源。 + +本地资产引用规则: +- context:`[CONTEXT:context/...]` +- CodeMap:`[CODEMAP:assets/codemap/...]` +- DomainMap:`[DOMAINMAP:assets/domainmap/...]` +- Runtime:`[RUNTIME:{artifact}]` +- 无证据:`[ASSUMPTION]` + +## 3.5) 证据→章节映射(强制) +- 在 PRD/FRD/DAR 中维护“证据映射表”,把**章节 → 关键结论 → 证据**对应起来。 +- 若章节无法绑定证据,必须显式标记 `[ASSUMPTION]`,并在 Check 阶段列入“证据缺口清单”。 + +## 4) PDCA 回合流程(每轮) +每轮输出到 `rounds/round_N.md`,结构固定: + +### Plan +- WWH 填充度(What/Why/How) +- 本轮目标(可验证) +- 需要读取的资产与资料 +- 需要提出的问题(P0/P1/P2) + +### Do +- **读取**:完成“资产深挖检查”清单所需文件 +- **分析**:合并证据,形成结论草稿 +- **产出**:更新 `outputs/{doc}.md` 的相关章节 + 证据映射表 + 差异点清单 +- **提问**:生成 `questions/round_N.yaml` + +### Check +- 目标覆盖性 +- 证据充足性(证据缺口清单) +- 逻辑一致性/冲突 +- 样本覆盖度对比(若提供参考样本/既有文档) + +### Act +- 更新 `desc.md`、`summary.md`、`decision_log.md` +- 更新 `session.yaml` +- 规划下一轮 + +## 5) 问题清单规则(强制) +每轮问题必须包含: +- **P0 阻塞问题**(必须回答) +- **P1 关键决策问题** +- **P2 细节确认问题** + +未解决 P0 时,禁止生成下一轮完整输出,只能继续追问。 + +问题格式模板(questions/round_N.yaml): +```yaml +round: 1 +questions: + - id: Q1-1 + priority: P0 + question: "..." + options: ["...", "...", "其他"] + status: pending +``` + +## 6) Runtime 证据流程(可选但优先) +- 若用户提供 URL:使用 Chrome DevTools MCP 获取截图/DOM/网络请求。 +- 若用户跳过:继续,但相关结论标记 `[ASSUMPTION]`。 + +## 7) 输出与收敛 +- 目标文档在 `outputs/` 中持续更新:`prd.md` / `frd.md` / `dar.md`。 +- 收敛条件: + - P0/P1 全部关闭 + - 证据映射表完成且无关键缺口 + - 用户确认内容可定稿 + +## 8) 引用与对账 +文档中所有非显然事实、数据、规则、策略必须带引用。 +在文档末尾追加“来源与索引”,指向 `materials_index.md` 与本地资产。 +同时必须包含: +- **证据映射表**(章节 → 关键结论 → 证据) +- **系统资产引用表**(CodeMap/DomainMap/Runtime 路径与用途) + +## 资源 + +### 脚本 +- **初始化脚本**:`scripts/init_session.py` + - 作用:创建新会话工作目录与基础文件 + - 用法:`python3 skills/pmassist/scripts/init_session.py --path --doc prd|frd|dar --alias <简称> --title <标题> --desc <原始需求> [--enable-prototype]` + - 原型支持:添加 `--enable-prototype` 参数自动创建原型目录结构 + +### 文档模板 +- **PRD 模板**:`references/prd.md` +- **FRD 模板**:`references/frd.md` +- **DAR 模板**:`references/dar.md` + +### 原型相关模板 +- **原型需求模板**:`references/proto_requirements_template.yaml`(Proto Round 1 问题清单) +- **原型覆盖度模板**:`references/prototype_coverage_template.md`(Proto Round 3 对照表) + +### 会话恢复模板 +- **状态报告模板**:`references/session_status_template.md`(用于生成会话状态报告) diff --git a/training/.claude/skills/tgassist/SKILL.md b/training/.claude/skills/tgassist/SKILL.md new file mode 100644 index 0000000..f10578e --- /dev/null +++ b/training/.claude/skills/tgassist/SKILL.md @@ -0,0 +1,305 @@ +--- +name: tgassist +description: | + 项目开发“基座操作系统”技能。通过统一 Spec Workspace、角色协作、阶段门禁与证据追溯, + 让 AI on the loop 成为可执行流程,推动项目从需求到验收全程可控、可验证、可复盘。 +--- + +# tgassist + +## 核心定位(必须) +- tgassist 是 **项目级协作治理系统**,不是 PRD/FRD/DAR 文档生成器。 +- **pmassist 是前序步骤且独立存在**:tgassist 只接收已形成的需求输入(可由 pmassist 或人类提供)。 +- tgassist 借鉴 pmassist 的“精益助理精神”和“资产深挖技巧”,但**不包含 pmassist 功能**。 + +## 核心规则(强制) +- **AI on the loop**:关键节点必须人类确认(初始化、阶段门禁、可选门禁启用/变更、验收/归档)。 +- **大周天固定主线**:提供需求 → 明确验收标准 → 制定计划 →(可选)架构设计 → 模块任务拆分 → 功能开发 →(可选)代码评审 → 测试 → 验收评审 → 归档。 +- **小周天 PDCA**:所有角色以 PDCA 闭环执行。 +- **问题闭环**:每轮必须生成问题清单(P0/P1/P2);P0 未关闭不得进入下一轮完整输出。 +- **证据优先**:关键结论必须标注证据或 `[ASSUMPTION]`;需维护证据索引与章节映射。 +- **资产深挖**:若存在 CodeMap/DomainMap/Runtime,必须下钻至页面/字段/调用链证据层级。 +- **门禁治理**:可选门禁由系统推荐、用户确认;中途变更必须走变更单并记录风险接受。 +- **流程裁剪**:允许按项目规模/风险等级裁剪角色流程,但必须留痕。 +- **RACI 裁剪**:角色权限矩阵可按项目规模裁剪,裁剪原因必须落盘。 +- **Git 纪律可选**:关键产出是否提交 Git 由用户确认;若启用需记录摘要/角色/阶段/变更原因。 +- **不做外部工具联动**:不对接 Jira/飞书/Notion/GitHub(可在未来扩展)。 + +## 0) 入口与角色选择 +1. 选择工作方向:初始化项目 / 需求与验收 / 开发推进 / 测试文档 / 评审验收 / 变更管理 / 复盘归档。 +2. 选择角色 Assist:PM / PJM / Arch / Backend_Dev /Front_Dev / QA / Council(固定 7 角色)。 +3. 系统基于 workspace 缺口与风险等级给出推荐角色,用户确认后进入流程。 + +工作方向菜单: + +| # | 方向 | 角色 | 适用场景 | +|---|------|------|----------| +| 1 | 初始化项目 | PJM | 新建 workspace,填充 project.yaml/session.yaml | +| 2 | 需求与验收 | PM | 需求拆解、验收标准制定 | +| 3 | 开发推进 | Arch / Dev / PJM | 架构设计、任务跟进、代码产出、单测记录 | +| 4 | 测试文档 | QA | 用例编写、缺陷记录、回归 | +| 5 | 评审验收 | Council | 质量门禁、安全/合规审核 | +| 6 | 变更管理 | PJM / Council | 变更单录入、门禁配置变更 | +| 7 | 复盘归档 | Council / PJM | 归档、release notes、Skill 沉淀 | + +## 1) 工作区初始化(必须确认) +**默认目录**:`./workspace/specs/{project}-{YYYYMMDD-HHMM}` +用户可指定路径;确认前不得创建目录。 + +目录结构(完全重新定义): +``` +{workdir}/ + 00_meta/ + project.yaml + session.yaml + summary.md + decision_log.md + status.md + roles.md + gates.md + evidence_index.md + rounds/ + questions/ + 01_input/ + requirements.md + references/ + 02_acceptance/ + acceptance.md + checklist.md + 03_plan/ + milestones.md + risks.md + dependencies.md + 04_design/ + architecture.md + interfaces.md + data_model.md + 05_delivery/ + dev_log.md + change_log.md + 06_test_docs/ + test_cases.md + defects.md + regression.md + 07_council/ + review.md + decision.md + 99_archive/ + release_notes.md +``` + +RACI 建议载体(可裁剪): +- `00_meta/roles.md`(角色职责矩阵) + +初始化模板: +- 使用 `assets/workspace_template/` 作为基线目录结构与文件模板。 +- 必要时用项目名称、风险等级、可选门禁配置填充 `00_meta/project.yaml` 与 `00_meta/session.yaml`。 +- 可选门禁清单模板位于:`assets/workspace_template/00_meta/gate_checklists/`。 + +初始化脚本: +- `scripts/init_workspace.sh`:复制模板并填充占位符,生成新的 workspace。 + - 参见 `references/project_yaml_schema.md` 了解字段规则与枚举值。 + - 参见 `references/session_yaml_schema.md` 了解 session 字段规则。 + +门禁清单生成脚本: +- `scripts/generate_gate_checklists.sh`:根据 `00_meta/project.yaml` 中启用的可选门禁,生成 `gate_checklists_active/`。 + - 门禁推荐规则参见 `references/gate_recommendation_matrix.md`。 + +## 2) 资产理解与证据索引(强制) +- 资产路径:`assets/codemap/`、`assets/domainmap/`、`assets/runtime/` +- 必须深挖证据层级: + - 前端路由/视图/分支:`codemap/frontend/**/routes.yaml`、`views.yaml`、`dialog_branches.yaml` + - 后端字段:`codemap/serve/dataobjects/java/*.yaml` + - 后端调用链:`codemap/serve/callchains/java/domains/*.yaml` + - 领域证据:`domainmap/*.yaml` +- 证据格式: + - 本地资产:`[CODEMAP:...]`、`[DOMAINMAP:...]`、`[RUNTIME:...]` + - 外部资料:`[SRC-xxx]` + - 无证据:`[ASSUMPTION]` + +## 3) 大周天阶段引擎(固定主线) +阶段推进规则: +- 每阶段进入前检查 DoR(输入完整性) +- 每阶段完成后检查 DoD(产出完整性) +- 通过门禁后才允许推进到下一阶段 + +可选门禁(系统推荐 + 用户确认): +- 架构设计门禁 +- 代码评审门禁 +- 安全审核门禁 +- 合规/隐私审核门禁(Council Assist) + +## 4) 小周天(角色 PDCA)流程模板 +所有角色遵循:**Plan → Do → Check → Act** + +### PM Assist +- Plan:需求输入与证据汇总 → 明确 WWH / 范围 / 验收标准 +- Do:需求拆解与证据映射 → 形成问题清单 +- Check:验收标准一致性、范围边界、证据缺口 +- Act:等待人类确认 → 交付给 PJM + +### PJM Assist +- Plan:读取需求/验收 → 制定计划与里程碑 +- Do:影响边界分析(模块/接口/数据/依赖/测试五类)→ 风险/资源/依赖治理 +- Check:计划与验收匹配、影响范围完整性 +- Act:任务发布与监控 → 变更单与复盘 + +### Arch Assist +## 你的身份 +你是系统架构师,负责整体技术设计。 + +## 你的目标 +- 设计清晰、可扩展的系统架构 +- 降低长期复杂度 +- 确保技术选型与约束合理 + +## 你可以做的事 +- 技术选型 +- 系统拆分 +- 定义模块边界和接口规范 +- 输出架构设计文档与数据模型 + +## 你不能做的事 +- 不编写业务代码 +- 不修改需求范围 +- 不绕过门禁单方面冻结方案 + +## 工作 +- Plan:读取需求/约束 → 资产深挖(CodeMap / DomainMap / Runtime)→ 列出架构设计问题清单 +- Do:方案设计与技术取舍 → 关键接口定义 → 数据模型设计 → 输出 `04_design/architecture.md`、`interfaces.md`、`data_model.md` +- Check:一致性/可行性/风险验证 → 确认与需求/验收标准对齐 → 证据缺口标注 +- Act:等待人类确认 → 按评审意见修订 → 方案冻结或进入变更控制 + +### Backend_Dev Assist(后端示例) +## 你的身份 +你是后端工程师,只负责实现后端业务逻辑。 + +## 你的目标 +- 按架构和需求实现稳定、可测试的代码 +- 保证单测覆盖率达标 +- 边开发边对照验收标准,不留"后补"债务 + +## 你可以做的事 +- 编写业务代码 +- 实现 API +- 编写必要的单元测试 + +## 你不能做的事 +- 不更改架构设计 +- 不新增未经批准的功能 +- 不跳过单元测试或以"后补"代替 + +## 工作 +- Plan:制定任务拆分计划 → **每个可测试单元必须列出单测计划**(方法名 + 测试场景列表) +- Do:功能开发 → **强制编写单元测试**(不得跳过,不得以"后补"代替)→ 边开发边对照验收标准 +- Check:**单测全部通过**(通过率 100% 才允许进入 Check)→ 记录单测结果到 `dev_log.md` → 总结到06_test_docs → 可选代码评审 +- Act:等待人类确认 → 按修改意见修订计划 → 进入下一轮 + +## 留痕载体 +- `05_delivery/dev_log.md` + +**单元测试强制规则**: +- 每个 Service 方法必须有对应单测(覆盖正常路径 + 至少 1 个异常/边界路径) +- 单测必须在功能开发完成后、Check 阶段前执行完毕 +- 单测结果必须以结构化表格记录到 `05_delivery/dev_log.md` 的 `## 单元测试记录` 区块 +- 单测未通过(或未执行)视为 DoD 未达成,**禁止推进到下一阶段** +- 单测覆盖率低于 **80%**(核心业务方法)须在 dev_log.md 中标注原因并获得人类确认 + +### Front_Dev Assist(前端架构师) +## 你的身份 +你是一个企业级前端架构 Agent。 + +## 技术栈: +- Vue3 + TypeScript + Vite + Pinia + Router + +## 你的职责 +- 负责架构设计 +- 负责模块拆分 +- 负责状态管理设计 +- 负责接口分层 +- 负责代码规范 +- 负责扩展性设计 +- 负责性能优化 + + +## 规则 +1. 先设计架构,不直接写代码 +2. 目录结构必须清晰 +3. 页面为容器,组件为功能块 +4. 组件不写 API +5. API 不写状态 +6. Store 不写 UI +7. 复杂逻辑必须抽 hooks +8. 单文件不得超过 400 行 +9. 必须符合企业级维护标准 +10. 代码必须可扩展 + + +## 留痕载体 +- `05_delivery/dev_log.md` + + +### QA Assist +## 你的身份 +你是测试工程师,专门负责找问题。 + +## 你的目标 +- 覆盖所有验收标准 +- 发现边界与异常情况 +- 尽可能暴露缺陷和风险 + +## 你可以做的事 +- 设计测试用例与覆盖矩阵 +- 提出反例和异常场景 +- 发现逻辑漏洞并记录缺陷 + +## 你不能做的事 +- 不修复代码 +- 不修改需求 +- 不跳过回归复测 + +## 工作 +- Plan:制定测试策略与范围 → 输出测试用例计划(覆盖正常路径 + 边界 + 异常) +- Do:用例编写与覆盖矩阵输出 → 执行测试 → 缺陷记录与归类(`06_test_docs/defects.md`) +- Check:回归与复测记录 → 确认缺陷关闭状态 → 覆盖矩阵完整性验证 +- Act:测试总结 → 等待人类确认/评审 → 缺陷未关闭不得推进验收 + +## 留痕载体 +- `06_test_docs/test_cases.md`、`defects.md`、`regression.md` + +### Council Assist +- Plan:汇总证据包 → 准备门禁检查清单 +- Do:质量门禁 →(可选)安全审核 →(可选)合规/隐私审核 → **文档同步门控**(执行 `gate_checklists/doc_sync.md`) +- Check:问题清单与整改要求 → **确认三层文档(context/codemap/domainmap)已与本次变更对齐** +- Act:评审决议 → 验收结论 → 文档同步通过后方可归档 + +## 5) 合规/隐私最小清单(Council Assist 推荐) +- 合法性/公平性/透明性(告知与合法依据) +- 目的限定与用途限制 +- 数据最小化与数据质量 +- 存储期限与删除策略 +- 安全保障与访问控制 +- 个体权利响应(访问/更正/删除) +- 责任与可证明性(审计/制度/记录) +- DPIA/隐私影响评估(高风险必做) + +## 6) 变更管理(强制) +任何变更必须记录: +- 变更原因、影响范围、风险等级、回滚方案 +- 责任人、确认人、时间 +- 是否影响门禁配置(如需变更须二次确认) + +## 7) 留痕与交付物 +必须落盘: +- `summary.md`(每轮摘要) +- `decision_log.md`(关键决策) +- `rounds/round_N.md`(PDCA 过程) +- `questions/round_N.yaml`(问题清单) +- `evidence_index.md`(证据索引) +- `roles.md`(RACI,按规模裁剪) + +## 8) 禁止事项 +- 未确认即创建目录/更改门禁 +- 未关闭 P0 问题即推进阶段 +- 无证据断言关键结论 +- **文档同步门控未通过即归档**:context / codemap / domainmap 三层任意一层有未通过项,禁止进入归档阶段 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md new file mode 100644 index 0000000..3af1638 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/decision_log.md @@ -0,0 +1,2 @@ +# Decision Log +- {{date}}: 初始化项目与基础规则确认。 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md new file mode 100644 index 0000000..75e7ff9 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/evidence_index.md @@ -0,0 +1,5 @@ +# Evidence Index + +| ID | Title | Type | Source | Date | Path | Notes | +|---|---|---|---|---|---|---| +| SRC-001 | | | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md new file mode 100644 index 0000000..a624a4e --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/architecture_review.md @@ -0,0 +1,12 @@ +# Architecture Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Scope | Architecture scope matches requirements | | | | +| Constraints | Constraints and assumptions documented | | | | +| Interfaces | Key interfaces defined | | | | +| Data model | Core data model documented | | | | +| Tradeoffs | Tradeoffs and alternatives evaluated | | | | +| Risks | Architecture risks identified and mitigations planned | | | | +| Non-functional | Performance, availability, security targets defined | | | | +| Evolution | Migration/compatibility plan documented | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md new file mode 100644 index 0000000..321c8ae --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/code_review.md @@ -0,0 +1,13 @@ +# Code Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Requirements | Implementation matches acceptance criteria | | | | +| Tests | Unit/functional tests updated and passing | | | | +| Error handling | Errors handled and user-facing behavior defined | | | | +| Performance | Performance impact assessed | | | | +| Security | Security considerations reviewed | | | | +| Maintainability | Code readability and structure acceptable | | | | +| Compatibility | Backward compatibility assessed | | | | +| Logging/Monitoring | Observability changes documented | | | | +| Doc Sync | context / codemap / domainmap updated to reflect this change (see `gate_checklists/doc_sync.md`) | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md new file mode 100644 index 0000000..8b09222 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/privacy_review.md @@ -0,0 +1,13 @@ +# Privacy & Compliance Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Lawfulness/Transparency | Legal basis and user notices are documented | | | | +| Purpose limitation | Data use limited to defined purposes | | | | +| Data minimization | Only necessary data collected | | | | +| Data quality | Data accuracy and update mechanisms defined | | | | +| Storage limitation | Retention period defined and enforced | | | | +| Security safeguards | Security controls for personal data | | | | +| Individual rights | Access/rectify/delete requests supported | | | | +| Accountability | Audit trail and responsibility defined | | | | +| DPIA | DPIA completed for high-risk processing | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md new file mode 100644 index 0000000..e989b8b --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gate_checklists/security_review.md @@ -0,0 +1,12 @@ +# Security Review Checklist + +| Item | Description | Status | Evidence | Notes | +|---|---|---|---|---| +| Threat model | Threat model exists for new/changed components | | | | +| AuthN/AuthZ | Access control and permission checks reviewed | | | | +| Secrets | Secrets managed securely (no hard-coded secrets) | | | | +| Input validation | User/externally sourced inputs validated | | | | +| Dependencies | Third-party dependencies reviewed/approved | | | | +| Logging | Security-relevant events logged | | | | +| Incident response | Rollback/mitigation plan documented | | | | +| Data protection | Sensitive data protected in transit/at rest | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md new file mode 100644 index 0000000..9c4a2a4 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/gates.md @@ -0,0 +1,47 @@ +# Gates (DoR / DoD) + +> 每阶段进入前检查 DoR,完成后检查 DoD;可选门禁由系统推荐、用户确认。 + +## 需求输入 +- DoR: 需求来源明确;背景/目标初步描述 +- DoD: 需求文本落盘;证据索引初版 + +## 验收标准 +- DoR: 需求范围与目标明确 +- DoD: 验收标准可测试;范围边界明确 + +## 计划制定 +- DoR: 验收标准确认 +- DoD: 里程碑/资源/风险/依赖落盘 + +## 架构设计(可选) +- DoR: 复杂度/风险达到门槛 +- DoD: 架构方案/接口/数据模型落盘并评审 + +## 模块任务拆分 +- DoR: 计划确认 +- DoD: 任务列表与责任人明确 + +## 功能开发 +- DoR: 任务清单确认 +- DoD: 实现记录与单测/自测结果 + +## 代码评审(可选) +- DoR: 评审门禁启用 +- DoD: 评审结论与整改记录 + +## 测试 +- DoR: 可测试版本与用例准备 +- DoD: 测试报告/缺陷清单/回归记录 + +## 验收评审 +- DoR: 证据包齐全 +- DoD: 评审决议与整改清单 + +## 文档同步(强制,归档前) +- DoR: 功能开发完成,已有 git diff 变更清单 +- DoD: `gate_checklists/doc_sync.md` 三层(context / codemap / domainmap)全部通过;受影响文档节点已就地更新 + +## 归档 +- DoR: 所有门禁通过,文档同步门控已通过 +- DoD: 归档文档与复盘记录 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml new file mode 100644 index 0000000..bec651c --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/project.yaml @@ -0,0 +1,27 @@ +schema: + name: tgassist.project + version: "0.1" + +project: + name: "{{project_name}}" + alias: "{{project_alias}}" + description: "{{project_desc}}" + owner: "{{project_owner}}" + created_at: "{{date}}" + +governance: + scale: "small|medium|large" + risk_level: "low|medium|high" + optional_gates: + architecture_design: "enabled|disabled" + code_review: "enabled|disabled" + security_review: "enabled|disabled" + privacy_compliance: "enabled|disabled" + git_policy: + enabled: "true|false" + commit_format: "[role][phase] summary - reason" + +evidence_sources: + codemap: "assets/codemap/" + domainmap: "assets/domainmap/" + runtime: "assets/runtime/" diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml new file mode 100644 index 0000000..3cd890b --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/questions/round_N.yaml @@ -0,0 +1,7 @@ +round: {{round}} +questions: + - id: Q{{round}}-1 + priority: P0 + question: "..." + options: ["...", "...", "其他"] + status: pending diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md new file mode 100644 index 0000000..e2af403 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/roles.md @@ -0,0 +1,16 @@ +# Roles (RACI) + +> 按项目规模裁剪并记录原因。 + +| 阶段/角色 | PM | PJM | Arch | Dev | QA | Council | +|---|---|---|---|---|---|---| +| 需求输入 | R | C | I | I | I | I | +| 验收标准 | A | C | C | I | I | I | +| 计划制定 | C | A/R | C | I | I | I | +| 架构设计(可选) | C | C | A/R | I | I | I | +| 任务拆分 | C | A/R | C | R | I | I | +| 功能开发 | I | C | C | A/R | I | I | +| 代码评审(可选) | I | C | C | A/R | I | I | +| 测试 | I | C | I | C | A/R | I | +| 验收评审 | C | C | C | C | C | A/R | +| 归档 | I | A/R | I | I | I | C | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md new file mode 100644 index 0000000..6e8d4a8 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/rounds/round_N.md @@ -0,0 +1,21 @@ +# Round {{round}} + +## Plan +- WWH 填充度: +- 本轮目标: +- 需要读取的资产与资料: +- 需要提出的问题: + +## Do +- 资产读取: +- 分析与产出: +- 提问: + +## Check +- 目标覆盖: +- 证据充分性: +- 逻辑一致性: + +## Act +- 更新 summary/decision_log/session +- 规划下一轮 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml new file mode 100644 index 0000000..5d5f310 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/session.yaml @@ -0,0 +1,43 @@ +schema: + name: tgassist.session + version: "0.1" + +status: + current_phase: "demand" + current_round: 1 + state: "in_progress" + last_updated: "{{date}}" + +phases: + - name: "demand" + status: "in_progress" + - name: "acceptance" + status: "pending" + - name: "plan" + status: "pending" + - name: "architecture" + status: "optional" + - name: "decompose" + status: "pending" + - name: "develop" + status: "pending" + - name: "code_review" + status: "optional" + - name: "test" + status: "pending" + - name: "acceptance_review" + status: "pending" + - name: "archive" + status: "pending" + +questions: + p0_open: 0 + p1_open: 0 + p2_open: 0 + +metrics: + evidence_count: 0 + mermaid_count: 0 + table_count: 0 + +assumptions: [] diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md new file mode 100644 index 0000000..10b840f --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/status.md @@ -0,0 +1,10 @@ +# Status + +- 当前阶段:{{current_phase}} +- 当前轮次:{{current_round}} +- 阻塞问题:{{p0_count}} +- 关键决策:{{last_decision}} +- 最近更新:{{date}} + +## 下一步 +- {{next_action}} diff --git a/training/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md new file mode 100644 index 0000000..0738e5e --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/00_meta/summary.md @@ -0,0 +1,2 @@ +# Summary +- {{date}}: 初始化项目,进入 Round 1。 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md b/training/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md new file mode 100644 index 0000000..ab27475 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/01_input/requirements.md @@ -0,0 +1,7 @@ +# Requirements + +## 背景与目标 + +## 需求概述 + +## 证据/参考 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md b/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md new file mode 100644 index 0000000..27ec650 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/acceptance.md @@ -0,0 +1,5 @@ +# Acceptance + +## 验收标准 + +## 范围边界 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md b/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md new file mode 100644 index 0000000..e0d8659 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/02_acceptance/checklist.md @@ -0,0 +1,5 @@ +# Acceptance Checklist + +| Item | Description | Status | Evidence | +|---|---|---|---| +| | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md new file mode 100644 index 0000000..b89fcba --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/dependencies.md @@ -0,0 +1,5 @@ +# Dependencies + +| Dependency | Type | Impact | Owner | Status | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md new file mode 100644 index 0000000..12ccaeb --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/milestones.md @@ -0,0 +1,5 @@ +# Milestones + +| Milestone | Date | Owner | Status | +|---|---|---|---| +| | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md new file mode 100644 index 0000000..335c608 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/03_plan/risks.md @@ -0,0 +1,5 @@ +# Risks + +| Risk | Level | Mitigation | Owner | Status | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md b/training/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md new file mode 100644 index 0000000..946ae92 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/04_design/architecture.md @@ -0,0 +1,7 @@ +# Architecture + +## Overview + +## Tradeoffs + +## Evidence diff --git a/training/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md b/training/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md new file mode 100644 index 0000000..ed3c60c --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/04_design/data_model.md @@ -0,0 +1,5 @@ +# Data Model + +| Entity | Fields | Constraints | Notes | +|---|---|---|---| +| | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md b/training/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md new file mode 100644 index 0000000..457b965 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/04_design/interfaces.md @@ -0,0 +1,5 @@ +# Interfaces + +| Interface | Owner | Input | Output | Notes | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md b/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md new file mode 100644 index 0000000..aabf5cc --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/change_log.md @@ -0,0 +1,5 @@ +# Change Log + +| Change | Reason | Impact | Decision | Date | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md b/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md new file mode 100644 index 0000000..c431eb9 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/05_delivery/dev_log.md @@ -0,0 +1,90 @@ +# 开发日志 (05_delivery/dev_log.md) + +> **项目**: {project_name} +> **更新规则**: 每个任务完成或有重要产出时更新;单元测试执行后**必须**填写"单元测试记录"区块 + +--- + +## 任务状态总览 + +| 任务 | 负责人 | Day | 状态 | 完成时间 | 备注 | +|------|--------|-----|------|----------|------| +| T-xxx | — | — | ⬜ 待开始 | — | — | + +> 状态说明:✅ 完成 / 🔄 进行中 / ⬜ 待开始 / ❌ 阻塞 + +--- + +## 开发日志详情 + +### {YYYY-MM-DD} | Round N | Dev Assist — {任务编号} {任务名称} + +**PDCA 阶段**: Do — 代码产出 + +**本轮产出**: + +| 文件 | 模块 | 说明 | +|------|------|------| +| — | — | — | + +**关键设计决策**: +- (记录影响后续维护的设计选择) + +**DoD 验证清单**: +- [ ] ... + +**遗留问题**: +- (无则写"无") + +--- + +## 单元测试记录 + +> ⚠️ **强制要求**:每个任务的单测必须在进入 Check 阶段前完成并记录。 +> 单测未通过或未记录 = DoD 未达成 = 禁止推进下一阶段。 + +### {任务编号} {任务名称} — 单测计划与结果 + +**执行时间**: {YYYY-MM-DD HH:MM} +**执行人**: {name} +**测试框架**: JUnit 5 / Mockito(或实际使用框架) + +#### 单测结果明细 + +| 测试类 | 测试方法 | 场景描述 | 结果 | 备注 | +|--------|----------|----------|------|------| +| `XxxServiceTest` | `testSave_success` | 正常创建,返回主键 | ✅ PASS | — | +| `XxxServiceTest` | `testSave_missingOrderNo` | 订单号为空,抛 BusinessException | ✅ PASS | — | +| `XxxServiceTest` | `testSave_invalidProvider` | 服务商不存在,抛 BusinessException | ✅ PASS | — | +| `XxxServiceTest` | `testList_emptyResult` | 无数据时返回空 Page | ✅ PASS | — | + +> 结果说明:✅ PASS / ❌ FAIL / ⚠️ SKIP(须注明原因) + +#### 覆盖率摘要 + +| 类 | 方法数 | 已覆盖 | 覆盖率 | 是否达标(≥80%) | +|----|--------|--------|--------|-----------------| +| `XxxService` | — | — | —% | — | + +> 覆盖率低于 80% 须填写原因,并获得人类确认后方可推进: +> - 原因: +> - 确认人: +> - 确认时间: + +#### 失败/跳过明细(若有) + +| 测试方法 | 失败原因 | 修复状态 | 修复时间 | +|----------|----------|----------|----------| +| — | — | — | — | + +--- + +## 变更记录 + +> 暂无变更 + +--- + +## 阻塞记录 + +> 暂无阻塞 diff --git a/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md new file mode 100644 index 0000000..fa85a5d --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/defects.md @@ -0,0 +1,5 @@ +# Defects + +| ID | Summary | Severity | Status | Evidence | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md new file mode 100644 index 0000000..cc10947 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/regression.md @@ -0,0 +1,5 @@ +# Regression + +| Version | Cases | Pass | Fail | Notes | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md new file mode 100644 index 0000000..cd020d6 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/06_test_docs/test_cases.md @@ -0,0 +1,5 @@ +# Test Cases + +| Case | Scope | Steps | Expected | Status | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md b/training/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md new file mode 100644 index 0000000..0941681 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/07_council/decision.md @@ -0,0 +1,5 @@ +# Decision + +| Item | Decision | Owner | Due | Status | +|---|---|---|---|---| +| | | | | | diff --git a/training/.claude/skills/tgassist/assets/workspace_template/07_council/review.md b/training/.claude/skills/tgassist/assets/workspace_template/07_council/review.md new file mode 100644 index 0000000..146d689 --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/07_council/review.md @@ -0,0 +1,7 @@ +# Review + +## Summary + +## Issues + +## Decision diff --git a/training/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md b/training/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md new file mode 100644 index 0000000..f3a764b --- /dev/null +++ b/training/.claude/skills/tgassist/assets/workspace_template/99_archive/release_notes.md @@ -0,0 +1,7 @@ +# Release Notes + +## Highlights + +## Changes + +## Risks diff --git a/training/.claude/skills/tgassist/references/gate_recommendation_matrix.md b/training/.claude/skills/tgassist/references/gate_recommendation_matrix.md new file mode 100644 index 0000000..9ec8edb --- /dev/null +++ b/training/.claude/skills/tgassist/references/gate_recommendation_matrix.md @@ -0,0 +1,16 @@ +# Gate Recommendation Matrix (tgassist) + +> System recommends gates based on project scale and risk level. User confirmation is required. + +## Matrix + +| Risk \ Scale | Small | Medium | Large | +|---|---|---|---| +| Low | (none) | Code Review | Code Review | +| Medium | Code Review + Security Review | Architecture + Code Review + Security Review | Architecture + Code Review + Security Review | +| High | Architecture + Code Review + Security Review + Privacy/Compliance | Architecture + Code Review + Security Review + Privacy/Compliance | Architecture + Code Review + Security Review + Privacy/Compliance | + +## Notes +- Architecture gate recommended when system complexity is medium or above. +- Privacy/Compliance gate recommended for high-risk or personal data handling projects. +- User can override recommendations, but must record decision and risk acceptance. diff --git a/training/.claude/skills/tgassist/references/project_yaml_schema.md b/training/.claude/skills/tgassist/references/project_yaml_schema.md new file mode 100644 index 0000000..7eb7b31 --- /dev/null +++ b/training/.claude/skills/tgassist/references/project_yaml_schema.md @@ -0,0 +1,50 @@ +# project.yaml Schema (tgassist) + +## Fields + +- `schema.name`: + - Value: `tgassist.project` + +- `schema.version`: + - Value: `0.1` + +- `project.name`: + - Human-readable project name + +- `project.alias`: + - Short slug used for directory naming + +- `project.description`: + - One-line description + +- `project.owner`: + - Primary owner (role/person) + +- `project.created_at`: + - ISO date (YYYY-MM-DD) + +- `governance.scale`: + - Enum: `small | medium | large` + +- `governance.risk_level`: + - Enum: `low | medium | high` + +- `governance.optional_gates`: + - `architecture_design`: `enabled | disabled` + - `code_review`: `enabled | disabled` + - `security_review`: `enabled | disabled` + - `privacy_compliance`: `enabled | disabled` + +- `governance.git_policy`: + - `enabled`: `true | false` + - `commit_format`: string, default `[role][phase] summary - reason` + +- `evidence_sources`: + - `codemap`: path string + - `domainmap`: path string + - `runtime`: path string + +## Notes +- Optional gates default to `disabled` until user confirms. +- Risk level drives recommended optional gates. +- If `git_policy.enabled=true`, commits must include summary, role, phase, and change reason. diff --git a/training/.claude/skills/tgassist/references/session_yaml_schema.md b/training/.claude/skills/tgassist/references/session_yaml_schema.md new file mode 100644 index 0000000..2a12f27 --- /dev/null +++ b/training/.claude/skills/tgassist/references/session_yaml_schema.md @@ -0,0 +1,44 @@ +# session.yaml Schema (tgassist) + +## Fields + +- `schema.name`: + - Value: `tgassist.session` + +- `schema.version`: + - Value: `0.1` + +- `status.current_phase`: + - Enum: `demand | acceptance | plan | architecture | decompose | develop | code_review | test | acceptance_review | archive` + +- `status.current_round`: + - Integer (>= 1) + +- `status.state`: + - Enum: `in_progress | awaiting_answers | finalized` + +- `status.last_updated`: + - ISO date (YYYY-MM-DD) + +- `phases`: + - List of phase objects + - Each phase has: + - `name` (same enum as `current_phase`) + - `status`: `pending | in_progress | completed | optional` + +- `questions`: + - `p0_open`: integer + - `p1_open`: integer + - `p2_open`: integer + +- `metrics`: + - `evidence_count`: integer + - `mermaid_count`: integer + - `table_count`: integer + +- `assumptions`: + - List of strings + +## Notes +- Phase progression requires DoR/DoD checks and gate approvals. +- `state` becomes `awaiting_answers` when P0 questions remain. diff --git a/training/.claude/skills/tgassist/scripts/generate_gate_checklists.sh b/training/.claude/skills/tgassist/scripts/generate_gate_checklists.sh new file mode 100644 index 0000000..38962e7 --- /dev/null +++ b/training/.claude/skills/tgassist/scripts/generate_gate_checklists.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + generate_gate_checklists.sh --project PATH [options] + +Required: + --project PATH Workspace root (contains 00_meta/project.yaml) + +Optional: + --output PATH Output directory for active gate checklists + (default: /00_meta/gate_checklists_active) + --template PATH Template dir (default: skills/tg/assets/workspace_template/00_meta/gate_checklists) + -h, --help Show help +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +project="" +output="" +template_dir="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --project) project="$2"; shift 2;; + --output) output="$2"; shift 2;; + --template) template_dir="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) die "unknown arg: $1";; + esac +done + +[[ -z "$project" ]] && die "--project is required" + +project_yaml="$project/00_meta/project.yaml" +[[ -f "$project_yaml" ]] || die "missing $project_yaml" + +if [[ -z "$output" ]]; then + output="$project/00_meta/gate_checklists_active" +fi + +if [[ -z "$template_dir" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + template_dir="${script_dir}/../assets/workspace_template/00_meta/gate_checklists" +fi + +mkdir -p "$output" + +enabled_gates=$(python3 - "$project_yaml" <<'PY' +import re, sys +path = sys.argv[1] +patterns = { + "architecture_design": "architecture_review.md", + "code_review": "code_review.md", + "security_review": "security_review.md", + "privacy_compliance": "privacy_review.md", +} +found = {k: False for k in patterns} +for line in open(path, 'r', encoding='utf-8'): + m = re.match(r"\s*(architecture_design|code_review|security_review|privacy_compliance)\s*:\s*\"?(\w+)\"?", line) + if m: + found[m.group(1)] = (m.group(2).strip().lower() == "enabled") + +files = [] +for k, v in found.items(): + if v: + files.append(patterns[k]) + +print(" ".join(files)) +PY +) + +if [[ -z "$enabled_gates" ]]; then + echo "No optional gates enabled." + exit 0 +fi + +for f in $enabled_gates; do + src="$template_dir/$f" + [[ -f "$src" ]] || die "template missing: $src" + cp "$src" "$output/" + echo "Copied: $f" +done + +echo "Active gate checklists generated at: $output" diff --git a/training/.claude/skills/tgassist/scripts/init_workspace.sh b/training/.claude/skills/tgassist/scripts/init_workspace.sh new file mode 100644 index 0000000..6a111bc --- /dev/null +++ b/training/.claude/skills/tgassist/scripts/init_workspace.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + init_workspace.sh --name NAME [options] + +Required: + --name NAME Project name + +Optional: + --alias ALIAS Project alias (default: slugified name) + --desc DESC Project description + --owner OWNER Project owner + --scale small|medium|large Project scale (default: medium) + --risk low|medium|high Risk level (default: medium) + --gate-architecture enabled|disabled Optional gate (default: disabled) + --gate-code-review enabled|disabled Optional gate (default: disabled) + --gate-security enabled|disabled Optional gate (default: disabled) + --gate-privacy enabled|disabled Optional gate (default: disabled) + --git-enabled true|false Git policy enabled (default: false) + --git-commit-format FORMAT Commit format (default: "[role][phase] summary - reason") + --output PATH Output directory (default: ./workspace/specs/{alias}-YYYYMMDD-HHMM) + -h, --help Show help +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +project_name="" +project_alias="" +project_desc="" +project_owner="" +scale="medium" +risk="medium" +_gate_arch="disabled" +_gate_code_review="disabled" +_gate_security="disabled" +_gate_privacy="disabled" +git_enabled="false" +git_commit_format="[role][phase] summary - reason" +output="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) project_name="$2"; shift 2;; + --alias) project_alias="$2"; shift 2;; + --desc) project_desc="$2"; shift 2;; + --owner) project_owner="$2"; shift 2;; + --scale) scale="$2"; shift 2;; + --risk) risk="$2"; shift 2;; + --gate-architecture) _gate_arch="$2"; shift 2;; + --gate-code-review) _gate_code_review="$2"; shift 2;; + --gate-security) _gate_security="$2"; shift 2;; + --gate-privacy) _gate_privacy="$2"; shift 2;; + --git-enabled) git_enabled="$2"; shift 2;; + --git-commit-format) git_commit_format="$2"; shift 2;; + --output) output="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) die "unknown arg: $1";; + esac +done + +[[ -z "$project_name" ]] && die "--name is required" + +if [[ -z "$project_alias" ]]; then + project_alias=$(echo "$project_name" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-') +fi + +if [[ -z "$output" ]]; then + output="./workspace/specs/${project_alias}-$(date +%Y%m%d-%H%M)" +fi + +if [[ -e "$output" ]]; then + die "output already exists: $output" +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +template_dir="${script_dir}/../assets/workspace_template" + +mkdir -p "$output" +cp -R "$template_dir"/. "$output"/ + +date_str="$(date +%Y-%m-%d)" + +export TG_PROJECT_NAME="$project_name" +export TG_PROJECT_ALIAS="$project_alias" +export TG_PROJECT_DESC="$project_desc" +export TG_PROJECT_OWNER="$project_owner" +export TG_DATE="$date_str" +export TG_SCALE="$scale" +export TG_RISK="$risk" +export TG_GATE_ARCH="$_gate_arch" +export TG_GATE_CODE_REVIEW="$_gate_code_review" +export TG_GATE_SECURITY="$_gate_security" +export TG_GATE_PRIVACY="$_gate_privacy" +export TG_GIT_ENABLED="$git_enabled" +export TG_GIT_COMMIT_FORMAT="$git_commit_format" +export TG_CURRENT_PHASE="demand" +export TG_CURRENT_ROUND="1" +export TG_P0_COUNT="0" +export TG_LAST_DECISION="-" +export TG_NEXT_ACTION="collect requirements" + +replace_file() { + python3 - "$1" <<'PY' +import os +from pathlib import Path + +path = Path(os.environ["TARGET_FILE"]) +data = path.read_text() +repl = { + "project_name": os.environ.get("TG_PROJECT_NAME", ""), + "project_alias": os.environ.get("TG_PROJECT_ALIAS", ""), + "project_desc": os.environ.get("TG_PROJECT_DESC", ""), + "project_owner": os.environ.get("TG_PROJECT_OWNER", ""), + "date": os.environ.get("TG_DATE", ""), + "current_phase": os.environ.get("TG_CURRENT_PHASE", ""), + "current_round": os.environ.get("TG_CURRENT_ROUND", ""), + "p0_count": os.environ.get("TG_P0_COUNT", ""), + "last_decision": os.environ.get("TG_LAST_DECISION", ""), + "next_action": os.environ.get("TG_NEXT_ACTION", ""), +} +for k, v in repl.items(): + data = data.replace("{{" + k + "}}", v) + +# project.yaml optional fields +if path.name == "project.yaml": + data = data.replace("small|medium|large", os.environ.get("TG_SCALE", "medium")) + data = data.replace("low|medium|high", os.environ.get("TG_RISK", "medium")) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_ARCH") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_CODE_REVIEW") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_SECURITY") == "enabled" else "disabled", 1) + data = data.replace("enabled|disabled", "enabled" if os.environ.get("TG_GATE_PRIVACY") == "enabled" else "disabled", 1) + data = data.replace("true|false", "true" if os.environ.get("TG_GIT_ENABLED") == "true" else "false", 1) + data = data.replace("[role][phase] summary - reason", os.environ.get("TG_GIT_COMMIT_FORMAT", "[role][phase] summary - reason")) + +path.write_text(data) +PY +} + +for f in \ + "$output/00_meta/project.yaml" \ + "$output/00_meta/session.yaml" \ + "$output/00_meta/summary.md" \ + "$output/00_meta/decision_log.md" \ + "$output/00_meta/status.md"; do + export TARGET_FILE="$f" + replace_file "$f" +done + +echo "Workspace initialized at: $output" diff --git a/training/codes/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java b/training/codes/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java index e40e9b7..3f0c438 100644 --- a/training/codes/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java +++ b/training/codes/training-system/src/main/java/com/sino/training/common/enums/QuestionType.java @@ -30,7 +30,12 @@ public enum QuestionType { /** * 简答题 */ - ESSAY(3, "简答题"); + ESSAY(3, "简答题"), + + /** + * 填空题 + */ + FILL_BLANK(4, "填空题"); @EnumValue private final int code; diff --git a/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java b/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java index 837b9f4..4c6c1ea 100644 --- a/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java +++ b/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Paper.java @@ -28,6 +28,21 @@ public class Paper extends BaseEntity { */ private String description; + /** + * 试卷分类ID + */ + private Long categoryId; + + /** + * 出卷方式:0-固定试卷,1-随机试卷 + */ + private Integer paperType; + + /** + * 随机试卷规则(JSON格式) + */ + private String randomRules; + /** * 总分 */ diff --git a/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java b/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java index 43f4509..ef41210 100644 --- a/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java +++ b/training/codes/training-system/src/main/java/com/sino/training/module/exam/entity/Question.java @@ -19,6 +19,11 @@ import java.time.LocalDateTime; @TableName("ex_question") public class Question extends BaseEntity { + /** + * 所属题库ID + */ + private Long bankId; + /** * 题目类型 */ @@ -43,13 +48,23 @@ public class Question extends BaseEntity { */ private String answer; + /** + * 难易程度:1-简单,2-一般,3-较难,4-困难 + */ + private Integer difficulty; + + /** + * 知识点,逗号分隔文本标签 + */ + private String knowledgePoints; + /** * 答案解析 */ private String analysis; /** - * 所属分类ID + * 所属分类ID(保留兼容) */ private Long categoryId; diff --git a/training-system/docs/PRD.md b/training/context/01-产品文档/PRD.md similarity index 99% rename from training-system/docs/PRD.md rename to training/context/01-产品文档/PRD.md index f23d550..dcf2883 100644 --- a/training-system/docs/PRD.md +++ b/training/context/01-产品文档/PRD.md @@ -128,8 +128,7 @@ | **V2** | 错题本 | 学员自动收集错题,巩固薄弱知识点 | | **V3** | 知识库增强 | 收藏、点赞、评论、全文搜索 | | **V3** | 奖励体系 | 积分、勋章、学习排行榜 | -| **V3** | 证书管理 | 培训/考试合格后自动颁发电子证书 | - +| **V3** | 证书管理 | 培训/考试 --- ## 四、关键业务逻辑 (Business Rules) diff --git a/training/context/01-产品文档/student.md b/training/context/01-产品文档/student.md new file mode 100644 index 0000000..4f3c9ed --- /dev/null +++ b/training/context/01-产品文档/student.md @@ -0,0 +1,664 @@ +# 学员端功能需求文档 + +> 版本:V1.0.1 +> 创建日期:2026-01-15 +> 更新日期:2026-01-15 +> 状态:✅ 已批准(Supervisor 复审通过) + +--- + +## 版本更新记录 + +### V1.0.1 (2026-01-15) + +**根据Supervisor审查意见修订:** + +| 修订项 | 修订内容 | +|--------|----------| +| 知识学习入口 | 补充培训任务学习场景、返回逻辑、来源区分 | +| 断点续考机制 | 补充定时任务策略、超时判定规则、并发处理 | +| 排名计算规则 | 明确计算时机、统计范围、口径定义 | +| 学习时长防刷 | 新增活跃检测机制 | +| 数据表设计 | km_knowledge_progress 新增 department_id 字段 | +| 页面路径 | 调整为复用现有路径,按角色控制 | +| 解析显示策略 | 明确交卷后立即显示解析 | +| 非功能需求 | 补充可测试的性能指标 | + +--- + +## 一、概述 + +### 1.1 目标用户 +- **角色**:学员(STUDENT) +- **人数**:约450人 +- **入口**:PC浏览器(与管理端共用系统,按角色显示不同菜单) + +### 1.2 核心目标 +为学员提供便捷的在线学习平台,支持知识学习、在线考试、培训计划跟踪,帮助学员提升专业技能。 + +--- + +## 二、功能模块总览 + +| 模块 | 功能点 | 优先级 | +|------|--------|--------| +| 工作台 | 学习概览、待办事项 | P0 | +| 知识学习 | 知识浏览、学习进度、时长统计 | P0 | +| 在线考试 | 考试列表、在线答题、断点续考、成绩排名 | P0 | +| 我的培训 | 培训计划、进度跟踪、完成判定 | P0 | +| 个人中心 | 学习记录、考试记录、证书(预留) | P1 | + +--- + +## 三、详细功能需求 + +### 3.1 工作台(首页) + +#### 用户故事 +> 作为学员,我希望登录后看到学习概览和待办事项,以便快速了解学习状态和接下来要做的事。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 学习统计卡片 | 显示:待学习课程数、待完成考试数、培训进度百分比 | +| 我的培训计划 | 列表展示分配给我的培训计划(最多显示5条),显示进度 | +| 待完成考试 | 列表展示待完成的考试(最多显示5条),显示截止时间 | +| 快捷入口 | 点击可直接进入对应详情页 | + +#### 验收标准 +- [ ] 登录后默认进入工作台页面 +- [ ] 统计数据实时准确(待学习=未完成的必修知识数,待考试=未通过的必考考试数) +- [ ] 培训进度百分比 = 已完成必修项 / 总必修项 × 100% +- [ ] 点击培训计划可跳转到计划详情 +- [ ] 点击考试可跳转到考试页面 +- [ ] 只显示"进行中"状态的培训计划和处于"时间窗口内"的考试 + +--- + +### 3.2 知识学习 + +#### 用户故事 +> 作为学员,我希望浏览本部门的知识库,学习文档和视频,并能看到自己的学习进度。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 知识分类导航 | 左侧树形结构展示知识分类 | +| 知识列表 | 按分类筛选,显示知识标题、类型、学习状态 | +| 知识详情/预览 | 文档在线预览、视频在线播放 | +| 学习进度记录 | 记录学习完成状态、学习时长 | +| 强制学习机制 | 视频必须播放完成、文档必须阅读完成才记录为"已完成" | + +#### 3.2.1 知识列表页 + +**页面元素**: +- 分类筛选(树形) +- 知识卡片列表:标题、类型图标(文档/视频)、学习状态标签、时长/大小 +- 搜索框(按标题搜索) + +**学习状态**: +| 状态 | 说明 | 显示样式 | +|------|------|----------| +| 未学习 | 从未打开过 | 灰色标签 | +| 学习中 | 已开始但未完成 | 蓝色标签 + 进度% | +| 已完成 | 满足完成条件 | 绿色标签 ✓ | + +#### 3.2.2 知识详情页 + +**文档类型**: +- 在线预览(PDF直接显示,Word/Excel/PPT转换预览) +- 学习计时:进入页面开始计时,离开页面停止 +- 完成条件:停留时间 ≥ 预估阅读时间(可配置,默认按页数计算) + +**视频类型**: +- 在线播放(支持进度条、全屏) +- 播放进度记录:记录当前播放位置,下次打开从断点继续 +- 完成条件:播放进度 ≥ 90%(可配置) +- 禁止拖动快进(强制学习模式下) + +#### 3.2.3 学习入口场景(🔴 修订项1) + +**场景一:自由学习(从知识库进入)** +- 入口:左侧菜单"知识库" → 知识列表 → 知识详情 +- 学习完成后:停留在知识详情页,可点击"返回列表" +- 进度记录:标记来源为 `FREE`(自由学习) + +**场景二:培训任务学习(从培训计划进入)** +- 入口:培训详情页 → 点击知识项 → 知识详情 +- 页面顶部显示:返回培训计划入口(如:"← 返回《2026年Q1安全规范培训》") +- 学习完成后: + - 自动弹出提示:"学习完成!是否返回培训计划?" + - 用户可选择【返回培训】或【继续浏览】 +- 进度记录:标记来源为 `TRAINING`(培训任务),关联 `plan_id` + +**进度记录来源区分**: +| 来源 | 场景 | 影响 | +|------|------|------| +| FREE | 自由学习 | 仅记录个人学习进度 | +| TRAINING | 培训任务学习 | 同时更新培训计划进度 | + +> **说明**:无论从哪个入口学习,学习时长和完成状态都会记录。区分来源是为了后续统计分析(如:培训驱动的学习 vs 自主学习)。 + +#### 3.2.4 学习时长防刷机制(🟡 优化项1) + +**文档类型防刷策略**: +- 每60秒检测一次用户活跃状态 +- 活跃判定:60秒内有鼠标移动、点击、滚动任一事件 +- 非活跃时:暂停计时,页面显示"检测到您暂时离开,学习计时已暂停" +- 恢复活跃后:继续计时 + +**视频类型防刷策略**: +- 依赖视频播放事件,暂停时不计时长 +- 无需额外活跃检测 + +**多标签页处理**: +- 同一知识同时只能在一个标签页学习 +- 检测到多标签页打开同一知识时,后打开的标签页提示"该知识已在其他窗口学习中" + +#### 验收标准 +- [ ] 只显示本部门、已发布状态的知识 +- [ ] 分类树正确展示,点击分类筛选对应知识 +- [ ] 文档能正常在线预览(至少支持PDF) +- [ ] 视频能正常播放,支持暂停、音量调节 +- [ ] 视频播放进度自动保存,刷新页面后从断点继续 +- [ ] 强制学习模式下,视频不能快进跳过 +- [ ] 学习时长准确记录(精确到秒) +- [ ] 满足完成条件后自动标记为"已完成" +- [ ] 学习进度与培训计划进度联动 +- [ ] 🔴 从培训计划进入的知识学习,顶部显示返回入口 +- [ ] 🔴 学习完成后弹出返回培训计划提示(仅培训任务学习场景) +- [ ] 🟡 文档学习时,非活跃状态暂停计时 +- [ ] 🟡 同一知识不能在多个标签页同时学习 + +--- + +### 3.3 在线考试 + +#### 用户故事 +> 作为学员,我希望参加分配给我的考试,并能查看成绩和排名。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 考试列表 | 展示分配给我的所有考试 | +| 考试详情 | 显示考试信息、我的考试记录 | +| 在线答题 | 单选/多选/判断题作答 | +| 断点续考 | 中途退出后可继续答题 | +| 自动交卷 | 时间到自动提交 | +| 成绩查看 | 显示得分、答案解析 | +| 成绩排名 | 显示我在本次考试中的排名 | + +#### 3.3.1 考试列表页 + +**列表字段**: +| 字段 | 说明 | +|------|------| +| 考试名称 | 点击进入考试详情 | +| 考试时间 | 开始时间 ~ 结束时间 | +| 考试状态 | 未开始/进行中/已结束 | +| 我的状态 | 未参加/考试中/已完成 | +| 最高成绩 | 多次考试取最高分 | +| 剩余次数 | 最大次数 - 已考次数 | +| 操作 | 进入考试/查看成绩 | + +**筛选条件**: +- 考试状态:全部/进行中/已结束 +- 我的状态:全部/未完成/已完成 + +#### 3.3.2 考试详情页(进入考试前) + +**显示信息**: +- 考试名称、考试时间窗口 +- 考试规则:时长、总分、及格线、最大次数 +- 我的考试记录表格(第N次、得分、是否通过、时间) +- 【开始考试】/【继续考试】按钮 + +**按钮逻辑**: +| 场景 | 按钮状态 | +|------|----------| +| 不在时间窗口内 | 禁用,提示"考试未开始"或"考试已结束" | +| 次数已用完 | 禁用,提示"考试次数已用完" | +| 有进行中的考试记录 | 显示【继续考试】 | +| 可以开始新考试 | 显示【开始考试】 | + +#### 3.3.3 在线答题页 + +**页面布局**: +``` +┌─────────────────────────────────────────────────────────┐ +│ 考试名称 剩余时间: 45:23 │ +├─────────────────────────────────────────────────────────┤ +│ 第 3 题 / 共 20 题 [单选题] │ +│ ───────────────────────────────────────────────────── │ +│ 题干内容... │ +│ │ +│ ○ A. 选项A │ +│ ● B. 选项B(已选中) │ +│ ○ C. 选项C │ +│ ○ D. 选项D │ +├─────────────────────────────────────────────────────────┤ +│ 答题卡: [1✓] [2✓] [3●] [4○] [5○] ... │ +├─────────────────────────────────────────────────────────┤ +│ [上一题] [下一题] [交卷] │ +└─────────────────────────────────────────────────────────┘ +``` + +**功能说明**: +| 功能 | 说明 | +|------|------| +| 倒计时 | 显示剩余时间,最后5分钟变红色提醒 | +| 题目导航 | 点击答题卡数字可跳转到对应题目 | +| 答案自动保存 | 选择答案后自动保存到服务器(每30秒或切题时) | +| 上一题/下一题 | 切换题目 | +| 交卷 | 弹出确认框,确认后提交 | +| 自动交卷 | 时间结束自动提交当前答案 | + +**断点续考机制**: +- 考试开始后创建考试记录,状态为"进行中" +- 答案实时保存到服务器 +- 中途关闭浏览器/断网,考试记录保持"进行中" +- 重新打开考试,检测到进行中记录,恢复到上次状态 +- 倒计时从剩余时间继续(服务端计算:考试时长 - 已用时间) +- 超时未提交的考试,由定时任务自动交卷 + +#### 3.3.5 超时自动交卷机制(🔴 修订项2) + +**超时判定规则**: +``` +超时时间点 = 考试记录开始时间 + 考试时长(分钟) +``` + +**示例**: +- 学员A在 10:00 开始考试,考试时长60分钟 +- 超时时间点 = 10:00 + 60分钟 = 11:00 +- 无论考试时间窗口是否结束,11:00后该考试记录即视为超时 + +**定时任务策略**: +| 配置项 | 值 | 说明 | +|--------|-----|------| +| 执行频率 | 每1分钟 | Cron: `0 */1 * * * ?` | +| 扫描范围 | 状态为IN_PROGRESS的考试记录 | 只处理进行中的记录 | +| 超时判定 | 当前时间 > 开始时间 + 考试时长 | 精确到秒 | +| 处理动作 | 自动提交当前已保存的答案,计算成绩 | 标记为系统自动交卷 | + +**并发提交处理(乐观锁)**: +``` +场景:学员在超时前1秒点击交卷,同时定时任务也在处理该记录 +``` + +**处理策略**: +1. 考试记录表增加 `version` 字段(乐观锁) +2. 提交时检查 `status = IN_PROGRESS AND version = 当前版本` +3. 更新时 `version = version + 1` +4. 若更新失败(version已变),说明已被其他请求处理,直接返回已提交的结果 + +**状态流转**: +``` +IN_PROGRESS ──用户主动交卷──▶ SUBMITTED(来源:USER) + │ + └──────定时任务超时交卷──▶ SUBMITTED(来源:SYSTEM_TIMEOUT) +``` + +**交卷来源标记**: +| 来源 | 说明 | +|------|------| +| USER | 用户主动交卷 | +| SYSTEM_TIMEOUT | 系统超时自动交卷 | + +> **注意**:即使考试时间窗口已结束,只要考试记录未超时,学员仍可继续答题(但无法开始新的考试) + +#### 3.3.6 考试结果页 + +**显示内容**: +| 项目 | 说明 | +|------|------| +| 得分 | 大字显示,及格绿色/不及格红色 | +| 是否通过 | 通过✓ / 未通过✗ | +| 排名 | "您的成绩排名第 X 名(共 Y 人参加)" | +| 答题详情 | 每道题的正确答案、我的答案、解析 | +| 操作按钮 | 【再考一次】(有次数)/ 【返回列表】 | + +**答案解析显示策略(🟡 优化项4)**: +- 交卷后立即显示所有题目的答案解析 +- 无论考试是否通过,都显示解析 +- 目的:培训学习为主,帮助学员理解错误原因 + +#### 3.3.7 排名计算规则(🔴 修订项3) + +**计算时机**: +- 实时计算:每次交卷时计算排名 +- 非定时计算:不采用批量定时计算方式 + +**统计范围**: +- 范围:当前考试的所有参与者(被分配且已交卷的学员) +- 跨部门:若考试分配给多个部门,排名为所有参与者的总排名 + +**排名规则**: +| 优先级 | 规则 | 说明 | +|--------|------|------| +| 1 | 最高成绩降序 | 成绩高的排名靠前 | +| 2 | 达到最高成绩的时间升序 | 成绩相同时,先达到该成绩的排名靠前 | + +**"共Y人参加"统计口径**: +| 统计项 | 是否计入 | +|--------|----------| +| 已交卷学员 | ✅ 计入 | +| 正在考试中(未交卷) | ❌ 不计入 | +| 被分配但未参加 | ❌ 不计入 | + +**排名示例**: +``` +考试:安全规范考核 +分配对象:部门A(30人)、部门B(20人) + +参与情况: +- 部门A:25人已交卷,5人未参加 +- 部门B:18人已交卷,2人正在考试中 + +排名统计:共 43 人参加(25 + 18 = 43) + +排名结果: +第1名:张三(95分,首次达到时间 10:30) +第2名:李四(95分,首次达到时间 10:45) +第3名:王五(90分) +... +``` + +**实现建议**: +- 交卷时实时计算排名并返回 +- 排名计算SQL示例思路: + ```sql + SELECT user_id, MAX(score) as best_score, MIN(submit_time) as first_best_time + FROM ex_exam_record + WHERE exam_id = ? AND status = 'SUBMITTED' + GROUP BY user_id + ORDER BY best_score DESC, first_best_time ASC + ``` + +#### 验收标准 +- [ ] 只显示分配给当前学员的考试 +- [ ] 考试状态、我的状态准确显示 +- [ ] 不在时间窗口内无法进入考试 +- [ ] 次数用完无法再次考试 +- [ ] 答题过程中答案实时保存 +- [ ] 刷新页面/重新进入可继续答题(断点续考) +- [ ] 倒计时准确,超时自动交卷 +- [ ] 交卷后立即显示成绩和排名 +- [ ] 答案解析正确显示 +- [ ] 多次考试取最高分显示 +- [ ] 🔴 定时任务每分钟执行,自动处理超时考试记录 +- [ ] 🔴 并发交卷场景下数据一致(乐观锁机制) +- [ ] 🔴 排名实时计算,仅统计已交卷学员 +- [ ] 🔴 排名规则:最高分优先,同分按首次达到时间排序 + +--- + +### 3.4 我的培训 + +#### 用户故事 +> 作为学员,我希望查看分配给我的培训计划,跟踪学习进度,完成培训任务。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 培训列表 | 展示分配给我的培训计划 | +| 培训详情 | 显示计划内容、学习进度 | +| 进度跟踪 | 实时显示知识学习、考试完成情况 | +| 完成判定 | 根据必修项判定培训是否完成 | +| 证书预留 | 培训完成后预留证书/徽章展示位置 | + +#### 3.4.1 培训列表页 + +**列表字段**: +| 字段 | 说明 | +|------|------| +| 培训名称 | 点击进入详情 | +| 培训周期 | 开始日期 ~ 结束日期 | +| 状态 | 未开始/进行中/已结束 | +| 我的进度 | 进度条 + 百分比 | +| 完成状态 | 未完成/已完成 | + +#### 3.4.2 培训详情页 + +**页面结构**: +``` +┌─────────────────────────────────────────────────────────┐ +│ 2026年Q1安全规范培训 │ +│ ───────────────────────────────────────────────────── │ +│ 培训周期:2026-01-01 ~ 2026-03-31 │ +│ 培训目标:掌握高速公路救援安全规范... │ +│ 我的进度:████████░░░░ 75% │ +├─────────────────────────────────────────────────────────┤ +│ 📚 学习内容 (3/5) │ +│ ├─ ✅ 高速救援SOP手册 [必修] [已完成] │ +│ ├─ ✅ 安全操作视频 [必修] [已完成] │ +│ ├─ 🔵 应急处理流程 [必修] [学习中 60%] │ +│ ├─ ⚪ 设备使用指南 [选修] [未学习] │ +│ └─ ✅ 案例分析 [选修] [已完成] │ +├─────────────────────────────────────────────────────────┤ +│ 📝 考试任务 (1/2) │ +│ ├─ ✅ 安全规范考核 [必考] [已通过 85分] │ +│ └─ ⚪ 操作技能测试 [必考] [未参加] │ +├─────────────────────────────────────────────────────────┤ +│ 🏆 完成奖励 │ +│ └─ 完成培训后可获得【安全规范认证】徽章(敬请期待) │ +└─────────────────────────────────────────────────────────┘ +``` + +**进度计算规则**: + +``` +培训进度 = (已完成必修知识数 + 已通过必考考试数) / (必修知识总数 + 必考考试总数) × 100% +``` + +**完成判定规则**: +| 条件 | 说明 | +|------|------| +| 所有必修知识已完成 | ✓ | +| 所有必考考试已通过 | ✓ | +| 选修知识 | 不影响完成判定 | +| 选考考试 | 不影响完成判定 | + +#### 验收标准 +- [ ] 只显示分配给当前学员的培训计划 +- [ ] 进度百分比计算准确(只计算必修项) +- [ ] 学习内容、考试任务分开展示 +- [ ] 显示每项的必修/选修、必考/选考标签 +- [ ] 点击知识内容可跳转到知识详情页 +- [ ] 点击考试任务可跳转到考试页面 +- [ ] 所有必修项完成后,培训状态变为"已完成" +- [ ] 证书/徽章区域预留(V1显示"敬请期待") + +--- + +### 3.5 个人中心 + +#### 用户故事 +> 作为学员,我希望查看自己的学习记录、考试历史和获得的证书。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 个人信息 | 显示姓名、部门、角色 | +| 学习统计 | 总学习时长、完成课程数 | +| 学习记录 | 知识学习历史列表 | +| 考试记录 | 考试历史列表 | +| 我的证书 | 证书/徽章展示(预留) | + +#### 验收标准 +- [ ] 正确显示当前用户信息 +- [ ] 学习时长统计准确(累计所有知识学习时长) +- [ ] 学习记录按时间倒序展示 +- [ ] 考试记录显示每次考试的详情 +- [ ] 证书模块预留(V1显示"暂无证书") + +--- + +## 四、数据契约补充 + +### 4.1 新增/修改实体 + +#### 知识学习进度表 (km_knowledge_progress) - 新增 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| user_id | Long | 学员ID | +| knowledge_id | Long | 知识ID | +| department_id | Long | **🟡 新增** 所属部门(冗余字段,便于按部门统计) | +| status | Enum | 状态:NOT_STARTED/IN_PROGRESS/COMPLETED | +| progress | Integer | 进度百分比(0-100) | +| duration | Long | 学习时长(秒) | +| video_position | Long | 视频播放位置(秒),仅视频类型 | +| source | Enum | **🔴 新增** 学习来源:FREE/TRAINING | +| plan_id | Long | **🔴 新增** 关联培训计划ID(来源为TRAINING时有值) | +| start_time | DateTime | 首次学习时间 | +| complete_time | DateTime | 完成时间 | +| update_time | DateTime | 最后更新时间 | + +#### 考试记录表 (ex_exam_record) - 补充字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| status | Enum | **新增** 状态:IN_PROGRESS/SUBMITTED | +| last_save_time | DateTime | **新增** 最后保存时间(断点续考用) | +| version | Integer | **🔴 新增** 乐观锁版本号 | +| submit_source | Enum | **🔴 新增** 交卷来源:USER/SYSTEM_TIMEOUT | + +### 4.2 新增枚举 + +```java +// 学习状态 +public enum LearningStatus { + NOT_STARTED, // 未学习 + IN_PROGRESS, // 学习中 + COMPLETED // 已完成 +} + +// 🔴 学习来源 +public enum LearningSource { + FREE, // 自由学习(从知识库直接进入) + TRAINING // 培训任务学习(从培训计划进入) +} + +// 考试记录状态 +public enum ExamRecordStatus { + IN_PROGRESS, // 考试中(用于断点续考) + SUBMITTED // 已提交 +} + +// 🔴 交卷来源 +public enum SubmitSource { + USER, // 用户主动交卷 + SYSTEM_TIMEOUT // 系统超时自动交卷 +} +``` + +--- + +## 五、接口预留(V2/V3功能) + +### 5.1 证书/徽章接口(预留) + +``` +POST /api/certificate/generate # 生成证书 +GET /api/certificate/my # 我的证书列表 +GET /api/certificate/{id} # 证书详情 +GET /api/badge/my # 我的徽章列表 +``` + +### 5.2 错题本接口(V2预留) + +``` +GET /api/wrong-questions # 错题列表 +POST /api/wrong-questions/review # 标记已复习 +``` + +--- + +## 六、非功能需求 + +| 项目 | 要求 | 测试指标(🟡 优化项5) | +|------|------|------------------------| +| 页面加载 | 页面加载 < 2秒 | 95%请求 < 2秒 | +| 考试提交 | 考试提交 < 1秒 | 99%请求 < 1秒 | +| 并发支持 | 支持100人同时在线考试 | 100并发答题,响应时间 < 500ms,成功率 > 99.9% | +| 答案保存 | 答案自动保存 | 保存成功率 > 99.99%,保存延迟 < 300ms | +| 数据安全 | 考试答案定时自动保存,防止数据丢失 | 断网重连后数据无丢失 | +| 兼容性 | 支持主流浏览器 | Chrome 90+、Edge 90+、Firefox 90+ | + +--- + +## 七、不做什么(Out of Scope) + +| 功能 | 原因 | +|------|------| +| 移动端适配 | V1仅支持PC浏览器 | +| 防作弊功能 | 产品确认不需要 | +| 学习日历 | 产品确认不需要 | +| 错题本 | V2规划 | +| 社交功能(评论、点赞) | V3规划 | + +--- + +## 八、页面清单(🟡 优化项3) + +> **说明**:采用与管理端复用同一套系统的方案,按角色控制菜单和功能显示,不使用独立的 `/student/` 路径前缀。 + +| 页面 | 路径 | 角色可见 | 说明 | +|------|------|----------|------| +| 工作台 | /index.html | 全角色 | 根据角色显示不同内容 | +| 知识列表 | /knowledge/list.html | 全角色 | 学员只读,讲师可管理 | +| 知识详情 | /knowledge/view.html?id={id} | 全角色 | 学员增加学习进度记录 | +| 考试列表 | /exam/my-exams.html | 学员 | 我的考试列表 | +| 考试详情 | /exam/detail.html?id={id} | 学员 | 考试信息页 | +| 在线答题 | /exam/answer.html?id={id} | 学员 | 答题页面 | +| 考试结果 | /exam/result.html?recordId={id} | 学员 | 成绩结果页 | +| 培训列表 | /training/my-training.html | 学员 | 我的培训列表 | +| 培训详情 | /training/detail.html?id={id} | 学员 | 培训计划详情 | +| 个人中心 | /profile/index.html | 全角色 | 个人信息页 | +| 学习记录 | /profile/learning.html | 学员 | 学习记录列表 | +| 考试记录 | /profile/exam-history.html | 学员 | 考试记录列表 | +| 我的证书 | /profile/certificate.html | 学员 | 证书展示(预留) | + +**菜单权限控制**: +| 菜单项 | 管理员 | 讲师 | 学员 | +|--------|--------|------|------| +| 工作台 | ✅ | ✅ | ✅ | +| 知识库 | ✅ 管理 | ✅ 管理 | ✅ 只读学习 | +| 考题管理 | ✅ | ✅ | ❌ | +| 试卷管理 | ✅ | ✅ | ❌ | +| 考试管理 | ✅ | ✅ | ❌ | +| 我的考试 | ❌ | ❌ | ✅ | +| 培训计划 | ✅ | ✅ | ❌ | +| 我的培训 | ❌ | ❌ | ✅ | +| 个人中心 | ✅ | ✅ | ✅ | +| 系统设置 | ✅ | ❌ | ❌ | + +--- + +## 九、修订项对照表 + +| 修订项 | 类型 | 章节位置 | 状态 | +|--------|------|----------|------| +| 知识学习入口场景 | 🔴 必须修正 | 3.2.3 | ✅ 已补充 | +| 断点续考超时机制 | 🔴 必须修正 | 3.3.5 | ✅ 已补充 | +| 排名计算规则 | 🔴 必须修正 | 3.3.7 | ✅ 已补充 | +| 学习时长防刷机制 | 🟡 建议优化 | 3.2.4 | ✅ 已采纳 | +| 数据表department_id | 🟡 建议优化 | 4.1 | ✅ 已采纳 | +| 页面路径设计 | 🟡 建议优化 | 8 | ✅ 已调整 | +| 答案解析显示策略 | 🟡 建议优化 | 3.3.6 | ✅ 已明确 | +| 非功能需求测试指标 | 🟡 建议优化 | 6 | ✅ 已补充 | + +--- + +**文档状态:✅ 已批准** + +> **Supervisor 复审结论**:通过 +> **复审日期**:2026-01-15 +> **下一阶段**:技术评审 → 开发 diff --git a/training/context/02-产品架构/01-产品整体架构.md b/training/context/02-产品架构/01-产品整体架构.md new file mode 100644 index 0000000..b853e47 --- /dev/null +++ b/training/context/02-产品架构/01-产品整体架构.md @@ -0,0 +1,379 @@ +# 产品整体架构文档 — 道路救援企业培训系统 + +> 版本:V1.0 +> 生成日期:2026-04-07 +> 证据状态:✅ 已验证 +> 证据来源:[SRC-FEAT-01] [SRC-SQL-01] [SRC-API-01] [SRC-CODE-01] + +--- + +## 1. 产品定位 + +### 1.1 产品目标 + +为道路救援企业提供**一站式内部培训管理平台**,通过数字化方式替代口口相传的知识传承模式,实现知识沉淀、在线考核、培训过程管控三位一体。[SRC-FEAT-01] + +### 1.2 核心价值 + +| 价值维度 | 解决的痛点 | 实现方式 | +|---------|---------|---------| +| 知识沉淀 | 知识依赖口口相传,无法沉淀 | 结构化知识库(文档+视频) | +| 高效考核 | 出题组卷效率低,纸质考试成本高 | 在线题库 + 自动/手动组卷 + 在线考试 | +| 培训管控 | 培训效果难追踪,进度不透明 | 培训计划+进度跟踪+成绩统计 | +| 标准化输出 | 服务标准参差不齐 | 统一平台 + 及格线控制 + 必考机制 | + +### 1.3 目标用户 + +| 角色 | 人数 | 核心诉求 | +|------|------|---------| +| 管理员 | 3-5人 | 系统配置、人员管理、全平台数据可见 | +| 讲师(技师) | 30-50人 | 本部门知识上传、出题、发布考试、管理培训计划 | +| 学员 | ~450人 | 学习资料、参加考试、查看进度 | + +[SRC-FEAT-01:二、用户画像] + +### 1.4 使用场景 + +- **管理员场景**:系统初始化时维护组织架构;日常监控各部门培训完成情况。 +- **讲师场景**:救援规范更新后,上传新手册并更新题库,发布新考试,关联到培训计划。 +- **学员场景**:PC端浏览知识库预习、参加指定考试、查看自己的培训进度。 +- **企业微信场景**:学员通过企业微信 H5 入口完成移动端学习和考试。 + +--- + +## 2. 系统整体架构 + +### 2.1 架构分层 + +| 层次 | 组件 | 职责 | +|-----|------|------| +| **表现层** | PC浏览器(管理员/讲师)、企业微信H5(学员)、移动端浏览器 | 用户交互,HTML+CSS+JS 原生技术栈 | +| **网关层** | Spring Boot 应用(JWT认证、权限拦截、日志、异常处理) | 请求入口,统一认证鉴权,全局异常处理 | +| **业务层** | 5个业务模块(auth / system / knowledge / exam / training) | 核心业务逻辑,按模块边界划分,禁止跨模块直接调用数据层 | +| **数据层** | MySQL 8.0(MyBatis Plus ORM)+ 文件存储(本地/MinIO/OSS) | 持久化业务数据和媒体文件 | + +[SRC-FEAT-01:七、架构设计蓝图] + +### 2.2 技术架构概览 + +| 技术选型 | 版本 | 用途 | +|---------|------|------| +| Spring Boot | 3.1.2 | 主框架 | +| MyBatis Plus | 3.5.3+ | ORM,简化CRUD | +| MySQL | 8.0 | 主数据库 | +| java-jwt | 4.4+ | JWT Token 签发与验证 | +| Spring Security | 6.x | BCrypt 密码加密 | +| weixin-java-cp | - | 企业微信 SDK(OAuth 登录) | +| Springdoc OpenAPI | 2.2+ | API 文档 | +| Spring @Scheduled | - | 考试超时自动交卷定时任务 | +| 文件存储 | - | V1 本地存储,V2 可扩展 MinIO/OSS | + +[SRC-FEAT-01:7.3 技术选型] + +### 2.3 系统依赖关系 + +``` +外部系统依赖: + 企业微信API ←→ auth 模块(OAuth 换 userid) + 文件存储服务 ←→ knowledge 模块(上传/下载文件) + 文档预览服务(可选) ←→ knowledge 模块(在线预览) + +内部模块依赖方向(单向,禁止逆向调用): + training → knowledge(引用知识内容、学习进度) + training → exam(引用考试通过状态) + knowledge / exam / training → system(查询用户/部门信息) + 所有模块 → auth(权限校验拦截器) +``` + +--- + +## 3. 业务架构图 + +```mermaid +graph TD + subgraph 客户端 + PC[PC浏览器
管理员/讲师] + WX[企业微信H5
学员] + MB[移动端浏览器
学员] + end + + subgraph 后端服务 + subgraph 网关层 + GW[JWT认证 / 权限拦截 / 异常处理] + end + + subgraph 业务域 + AUTH[认证模块
auth] + SYS[系统管理
system] + KM[知识库模块
knowledge] + EX[考试模块
exam] + TR[培训模块
training] + end + end + + subgraph 数据层 + DB[(MySQL 8.0)] + FS[文件存储
本地/MinIO] + end + + subgraph 外部服务 + WXAPI[企业微信API] + PREVIEW[文档预览服务
可选] + end + + PC --> GW + WX --> GW + MB --> GW + GW --> AUTH + GW --> SYS + GW --> KM + GW --> EX + GW --> TR + + AUTH --> WXAPI + KM --> FS + KM --> PREVIEW + + TR --> KM + TR --> EX + KM --> SYS + EX --> SYS + TR --> SYS + + AUTH --> DB + SYS --> DB + KM --> DB + EX --> DB + TR --> DB +``` + +--- + +## 4. 核心业务流程说明 + +### 4.1 用户主流程(学员) + +```mermaid +sequenceDiagram + participant 学员 + participant 前端 + participant 后端 + participant 企业微信 + + 学员->>前端: 点击企业微信登录 + 前端->>企业微信: 跳转 OAuth 授权 + 企业微信-->>前端: 返回 code + 前端->>后端: code 换 Token + 后端->>企业微信: code → userid + 后端->>后端: 查 sys_user 匹配 + alt 用户已录入 + 后端-->>前端: JWT Token + 用户信息 + 前端->>学员: 进入工作台 + else 用户未录入 + 后端-->>前端: 错误:联系管理员 + end + + 学员->>前端: 查看培训计划 + 前端->>后端: GET /api/student/training + 后端-->>前端: 计划列表 + 进度 + + 学员->>前端: 参加考试 + 前端->>后端: POST /api/student/exam/{id}/start + 后端->>后端: 校验时间窗口 + 剩余次数 + 后端-->>前端: 试题 + 考试时长 + 学员->>前端: 答题 + 交卷 + 前端->>后端: POST /api/student/exam/{id}/submit + 后端->>后端: 自动判分 + 后端-->>前端: 成绩 + 排名 + 答案解析 +``` + +### 4.2 管理流程(讲师) + +```mermaid +flowchart LR + A[上传知识
DRAFT] --> B[发布知识
PUBLISHED] + B --> C{有培训计划?} + C -->|是| D[关联到培训计划] + C -->|否| E[学员自由学习] + + F[创建题目
DRAFT] --> G[发布题目
PUBLISHED] + G --> H[组卷
手动/自动] + H --> I[发布试卷
PUBLISHED] + I --> J[创建考试
设置时间/次数] + J --> K[指定考试对象
部门/小组/个人] + D --> L[创建培训计划] + J --> L + L --> M[分配学员
部门/小组/个人] + M --> N[发布培训计划] +``` + +### 4.3 数据流转流程 + +```mermaid +flowchart TB + subgraph 内容生产 + KM_CREATE[知识创建
km_knowledge DRAFT] + KM_PUBLISH[发布知识
PUBLISHED] + QS_CREATE[题目创建
ex_question DRAFT] + QS_PUBLISH[发布题目
PUBLISHED] + PP_CREATE[组卷
ex_paper + ex_paper_question] + EX_CREATE[创建考试
ex_exam + ex_exam_target] + TR_CREATE[培训计划
tr_plan + tr_plan_knowledge
+ tr_plan_exam + tr_plan_target] + end + + subgraph 学员消费 + LEARN[学习知识
→ km_knowledge_progress UPSERT] + EXAM[参加考试
→ ex_exam_record 创建/更新] + PROGRESS[进度计算
必修知识完成数 + 必考通过数] + end + + KM_CREATE --> KM_PUBLISH --> LEARN + QS_CREATE --> QS_PUBLISH --> PP_CREATE --> EX_CREATE --> EXAM + TR_CREATE --> LEARN + TR_CREATE --> EXAM + LEARN --> PROGRESS + EXAM --> PROGRESS +``` + +--- + +## 5. 数据架构 + +### 5.1 核心数据实体(共19张表) + +[SRC-SQL-01] [SRC-SQL-02] + +| 数据域 | 表名 | 核心字段 | 说明 | +|-------|------|---------|------| +| **系统** | sys_center | id, name | 中心(最高层级) | +| **系统** | sys_department | id, name, center_id | 部门(隶属中心) | +| **系统** | sys_group | id, name, department_id | 小组(隶属部门) | +| **系统** | sys_user | id, wx_userid, role(0/1/2), department_id, group_id, status | 用户(ADMIN/LECTURER/STUDENT) | +| **知识库** | km_category | id, name, parent_id, department_id | 知识分类(多级树) | +| **知识库** | km_knowledge | id, title, type(0文档/1视频), file_url, department_id, status(0/1/2) | 知识内容 | +| **知识库** | km_knowledge_progress | id, user_id, knowledge_id, status, progress, duration, video_position, source, plan_id | 学习进度 | +| **考试** | ex_question_category | id, name, parent_id, department_id | 题目分类 | +| **考试** | ex_question | id, type(0/1/2), content, options(JSON), answer, analysis, status | 题目 | +| **考试** | ex_paper | id, title, total_score, duration, pass_score, status | 试卷 | +| **考试** | ex_paper_question | paper_id, question_id, score, sort_order | 试卷-题目关联 | +| **考试** | ex_exam | id, title, paper_id, start_time, end_time, max_attempts, status(0/1/2) | 考试 | +| **考试** | ex_exam_target | exam_id, target_type(0部门/1小组/2个人), target_id | 考试对象 | +| **考试** | ex_exam_record | id, exam_id, user_id, attempt_no, score, passed, answers(JSON), status, version | 考试记录(含乐观锁) | +| **培训** | tr_plan | id, title, start_date, end_date, department_id, status(0/1/2) | 培训计划 | +| **培训** | tr_plan_knowledge | plan_id, knowledge_id, required, sort_order | 计划-知识关联 | +| **培训** | tr_plan_exam | plan_id, exam_id, required, sort_order | 计划-考试关联(V1.0.1) | +| **培训** | tr_plan_target | plan_id, target_type, target_id | 计划-对象 | +| **培训** | tr_plan_progress | plan_id, user_id, knowledge_id, completed, complete_time | 旧版进度(已被 km_knowledge_progress 替代)| + +### 5.2 实体关系图 + +```mermaid +erDiagram + sys_center ||--o{ sys_department : "中心下辖多个部门" + sys_department ||--o{ sys_group : "部门下辖多个小组" + sys_department ||--o{ sys_user : "用户属于部门" + sys_group ||--o{ sys_user : "用户可归属小组" + + sys_department ||--o{ km_category : "按部门隔离" + km_category ||--o{ km_knowledge : "知识归属分类" + sys_user ||--o{ km_knowledge_progress : "学员学习进度" + km_knowledge ||--o{ km_knowledge_progress : "知识对应进度" + + sys_department ||--o{ ex_question_category : "按部门隔离" + ex_question_category ||--o{ ex_question : "题目归属分类" + ex_paper ||--o{ ex_paper_question : "试卷包含多道题" + ex_question ||--o{ ex_paper_question : "题目被多张试卷引用" + ex_paper ||--|| ex_exam : "考试使用某试卷" + ex_exam ||--o{ ex_exam_target : "考试指定对象" + ex_exam ||--o{ ex_exam_record : "考试产生记录" + sys_user ||--o{ ex_exam_record : "学员的考试记录" + + tr_plan ||--o{ tr_plan_knowledge : "计划包含多个知识" + tr_plan ||--o{ tr_plan_exam : "计划关联多场考试" + tr_plan ||--o{ tr_plan_target : "计划分配给对象" + km_knowledge ||--o{ tr_plan_knowledge : "知识被计划引用" + ex_exam ||--o{ tr_plan_exam : "考试被计划引用" +``` + +### 5.3 数据生命周期 + +| 数据类型 | 创建 | 有效态 | 失效态 | 删除方式 | +|---------|------|-------|-------|---------| +| 知识/题目/试卷 | DRAFT | PUBLISHED | OFFLINE | 逻辑删除(deleted=1) | +| 考试 | NOT_STARTED | IN_PROGRESS | ENDED | 逻辑删除 | +| 培训计划 | NOT_STARTED | IN_PROGRESS | ENDED | 逻辑删除 | +| 考试记录 | IN_PROGRESS | SUBMITTED | 永久保留(取最高分) | 不删除 | +| 学习进度 | NOT_STARTED | IN_PROGRESS | COMPLETED | UPSERT更新 | + +--- + +## 6. 权限与角色体系 + +### 6.1 角色定义 + +[SRC-FEAT-01:4.1 权限规则] [SRC-SQL-01:sys_user.role] + +| 角色 | 枚举值(DB) | 数据范围 | +|------|------------|---------| +| 管理员 ADMIN | role=0 | 全平台(无部门隔离) | +| 讲师 LECTURER | role=1 | 本部门(department_id匹配) | +| 学员 STUDENT | role=2 | 本部门(只读;自己的进度/成绩) | + +### 6.2 权限分层(按功能菜单) + +| 菜单/功能 | ADMIN | LECTURER | STUDENT | +|---------|-------|---------|---------| +| 组织架构管理 | ✅ 全部 | ❌ | ❌ | +| 员工管理 | ✅ 全部 | 👁️ 本部门只读 | ❌ | +| 知识库-分类管理 | ✅ 全部 | ✅ 本部门 | ❌ | +| 知识库-知识列表 | ✅ 全部 | ✅ 本部门(含草稿) | 👁️ 本部门已发布 | +| 考题管理-题库分类 | ✅ | ✅ 本部门 | ❌ | +| 考题管理-题目列表 | ✅ | ✅ 本部门 | ❌ | +| 试卷管理 | ✅ | ✅ 本部门 | ❌ | +| 考试管理 | ✅ | ✅ 本部门发布 | 👁️ 我的考试 | +| 培训计划 | ✅ | ✅ 本部门创建/管理 | 👁️ 我的培训 | +| 系统设置 | ✅ | ❌ | ❌ | +| 工作台 | ✅ | ✅ | ✅(学员视图) | + +[SRC-FEAT-01:8.1 角色菜单权限对照表] + +### 6.3 控制逻辑 + +**认证层**:JWT Token 携带 userId + role,每次请求由 AuthInterceptor 解析验证。[SRC-CODE-01:common/interceptor/AuthInterceptor.java] + +**授权层**: +- 接口级:`@PreAuthorize` / SecurityConfig 按角色匹配路径(`/api/student/**` 仅 STUDENT) +- 数据级:Service 层注入 `department_id` 过滤,讲师/学员查询一律追加 `WHERE department_id = ?` + +**隔离规则**: +- 4类数据(知识库、题库、试卷/考试、培训计划)均按 `department_id` 隔离 +- 管理员不注入部门过滤,讲师注入本部门,学员额外限制只查 PUBLISHED 状态 + +--- + +## 7. 系统扩展点分析 + +### 7.1 可扩展模块 + +| 扩展点 | 当前状态 | V2+ 扩展方向 | +|-------|---------|------------| +| 文件存储 | 本地文件系统 | 抽象 `FileStorageService` 接口 → MinIO/OSS 实现切换 | +| 认证方式 | 企业微信 OAuth(账号密码作备选) | 可接入 SSO/LDAP | +| 定时任务 | Spring @Scheduled(单机) | 可替换为 XXL-Job/SchedulerX 支持集群 | +| 统计报表 | 无(V2规划) | 新增 `statistics` 模块,不影响现有模块 | +| 通知 | 无(V2规划) | 可插入企业微信消息推送 | + +### 7.2 可插拔能力 + +- **文档预览**:kkFileView / OnlyOffice 作为可选外部服务,knowledge 模块通过 URL 调用,可随时替换 +- **视频播放器**:video.js 为前端组件,与后端解耦,可替换 +- **批量导入**:EasyExcel 依赖已选型,可随时为题目/用户管理添加 Excel 导入功能 + +### 7.3 易变业务点 + +| 易变点 | 变化方向 | 影响范围 | +|-------|---------|---------| +| 培训完成判定逻辑(当前:必修知识+必考通过) | V2 可能增加签到、线下课时 | `TrainingProgressService.calculateProgress()` | +| 知识学习完成判定(视频90%/文档时长) | 可能改为100%或按章节 | `KnowledgeLearningService.isCompleted()` | +| 考试排名计算口径(当前:最高分排名 DENSE_RANK) | 可能改为平均分/最后一次 | SQL窗口函数,需同步修改 | +| 组织架构层级(当前:中心→部门→小组三级) | 可能增减层级 | system 模块全面影响 | diff --git a/training/context/02-产品架构/03-系统能力模型.md b/training/context/02-产品架构/03-系统能力模型.md new file mode 100644 index 0000000..552c155 --- /dev/null +++ b/training/context/02-产品架构/03-系统能力模型.md @@ -0,0 +1,105 @@ +# 系统能力模型总结 — 道路救援企业培训系统 + +> 版本:V1.0 | 生成日期:2026-04-07 +> 证据来源:[SRC-FEAT-01] [SRC-API-01] [SRC-CODE-01] + +--- + +## 1. 当前系统具备的核心能力 + +| # | 能力名称 | 一句话描述 | +|---|---------|----------| +| C1 | **基于角色的访问控制(RBAC)** | 三种角色(管理员/讲师/学员)+ 部门级数据隔离,统一鉴权入口 | +| C2 | **组织与人员管理** | 三级组织结构(中心→部门→小组)+ 用户生命周期管理 | +| C3 | **知识资产管理** | 支持文档/视频两种类型的知识内容创建、状态发布和多级分类 | +| C4 | **学习行为跟踪** | 防刷机制下的真实时长累计和进度记录(视频90%/文档时长判定) | +| C5 | **在线考核** | 题库管理 + 手动/自动组卷 + 在线答题 + 客观题自动判分 | +| C6 | **考试行为管控** | 时间窗口约束 + 次数限制 + 超时自动交卷 + 并发乐观锁保护 | +| C7 | **培训任务编排** | 将知识+考试组织成有时间周期的培训计划,支持必修/选修/必考/选考四维属性 | +| C8 | **培训进度可视化** | 实时计算学员对某培训计划的完成百分比(必修完成+必考通过) | +| C9 | **成绩与排名** | 交卷后立即返回成绩+答案解析+实时排名(窗口函数,同分按时间早优先) | +| C10 | **企业微信身份集成** | OAuth 登录打通企业微信用户体系,无需单独注册 | + +--- + +## 2. 能力依赖关系图 + +```mermaid +graph TD + C10[C10 企业微信身份集成] --> C1 + C1[C1 RBAC + 部门隔离] --> C2 + C1 --> C3 + C1 --> C5 + C1 --> C7 + + C2[C2 组织人员管理] --> C7 + C3[C3 知识资产管理] --> C4 + C3 --> C7 + + C4[C4 学习行为跟踪] --> C8 + C5[C5 在线考核] --> C6 + C5 --> C9 + C6[C6 考试行为管控] --> C9 + C9[C9 成绩与排名] --> C8 + + C7[C7 培训任务编排] --> C4 + C7 --> C6 + C7 --> C8[C8 培训进度可视化] +``` + +--- + +## 3. 可复用能力清单 + +| 能力 | 复用场景 | 复用方式 | +|-----|---------|---------| +| C1 RBAC + 部门隔离 | 企业内任何需要角色权限 + 数据隔离的系统 | 提取 AuthInterceptor + UserContext + department_id 注入框架 | +| C4 学习行为跟踪 | 其他类型内容(音频/直播回放/课件)的学习进度记录 | 抽象 `LearningTarget` 接口,替换完成判定策略(Strategy 模式) | +| C5 在线考核 | 任意客观题在线测评场景(安全培训/合规考试等)| 题库+试卷+客观题判分为独立子系统,可独立部署 | +| C6 考试行为管控 | 任何有"限时+限次"约束的在线测试 | ExamTakingService 中时间窗口/次数/乐观锁的组合控制逻辑 | +| C9 成绩排名 | 任何需要实时排名的比赛/评测场景 | SQL 窗口函数 DENSE_RANK 模式,按需修改排序条件 | + +--- + +## 4. 平台级能力(多业务依赖的底层能力) + +**C1 — RBAC + 部门数据隔离** +- 所有业务模块(知识/考试/培训)均依赖此能力实现权限控制和数据隔离 +- 是系统安全性和多租户(多部门)能力的基础 +- 扩展方向:目前硬编码三个角色,V2 可改为动态角色配置 + +**C2 — 组织人员管理** +- 每个业务操作都需要 `department_id`、`user_id`、`role` 三个属性 +- 是知识/考试/培训三大域的数据隔离依据来源 +- 扩展方向:当前三级固定结构,可抽象为 n 级通用树 + +**C10 — 企业微信身份集成** +- 是唯一的用户来源(V1),后续接其他身份源也需通过此层 +- 扩展方向:抽象为 `IdentityProvider` 接口,支持插入 SSO/LDAP + +--- + +## 5. 业务定制能力(特定场景才需要的能力) + +| 能力 | 定制场景 | 为何不是平台级 | +|-----|---------|-------------| +| C3 知识资产管理(文档/视频类型) | 救援行业的操作手册、安全规范、视频讲解 | 内容类型固定(文档/视频),换行业可能需要不同类型 | +| C4 学习行为防刷(视频90%/文档时长) | 强制"真实学习"的企业培训场景 | 完成判定规则是业务决策,C端平台通常不这么严格 | +| C6 超时自动交卷 + 乐观锁 | 有严格时长限制的考试 | 休闲测试/知识问答类应用不需要这个复杂度 | +| C7 培训任务编排(必修/选修/必考/选考四维)| 企业有组织要求的培训管理 | 自学平台不需要"强制必修"的约束 | +| C9 成绩排名(DENSE_RANK + 同分按时间) | 部门内部考核的绩效排名 | 无竞争性考核场景(如自我测评)不需要排名 | + +--- + +## 6. V2 能力规划缺口 + +当前 V1.0.1 尚未具备以下能力,是 V2 的扩展方向: + +| 缺失能力 | 业务价值 | 实现复杂度 | +|---------|---------|---------| +| 统计报表中心(完成率/通过率/部门排名) | 管理层查看全局培训效果 | 中(新增 statistics 模块,读现有数据) | +| 线下培训签到(扫码签到/签退) | 线上线下混合培训场景 | 中(需硬件支持) | +| 错题本(自动归集错误题目) | 学员针对性复习 | 低(从 exam_record.answers 中提取 correct=false) | +| 知识全文搜索 | 快速查找内容 | 高(需引入 Elasticsearch 或 MySQL FULLTEXT) | +| 奖励体系(积分/勋章/排行榜) | 提升学习积极性 | 中(新增独立模块) | +| 证书管理 | 培训完成凭证 | 低(基于培训计划完成状态生成 PDF) | diff --git a/training/context/03-功能模块/01-认证模块.md b/training/context/03-功能模块/01-认证模块.md new file mode 100644 index 0000000..3ca81b4 --- /dev/null +++ b/training/context/03-功能模块/01-认证模块.md @@ -0,0 +1,110 @@ +# 认证模块(auth) + +> 证据来源:[SRC-FEAT-01] [SRC-API-01] [SRC-CODE-01:module/auth/] + +--- + +## 1. 模块定位 + +- **模块目标**:负责用户身份验证(企业微信OAuth + 账号密码备选)和 JWT Token 的签发、校验,为所有其他模块提供鉴权基础。 +- **解决问题**:500人规模的企业内部系统,需要统一身份验证入口,并将身份与角色绑定,为下游数据隔离提供 `userId + role + departmentId` 上下文。 + +--- + +## 2. 功能清单 + +[SRC-API-01:四、API 接口设计 / SRC-CODE-01:module/auth/] + +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | +|---------|---------|------|------|---------| +| 企业微信 OAuth 登录 | 通过企业微信授权 code 换取用户身份,查找系统用户,签发 JWT | wx_code | JWT Token + 用户信息(LoginResponse) | system(查sys_user) | +| 账号密码登录(备选) | 用用户名+密码登录,BCrypt 验证密码 | username, password | JWT Token + 用户信息 | system(查sys_user) | +| 获取当前用户信息 | 解析 Token 返回用户基本信息 | Authorization Header | UserInfoResponse | - | +| 微信小程序登录(预留) | 通过小程序 code 获取用户信息 | WechatLoginRequest | JWT Token | system | +| Token 携带与拦截 | AuthInterceptor 拦截所有受保护请求,提取 Token 注入 UserContext | Authorization: Bearer {token} | UserContext(ThreadLocal) | - | + +--- + +## 3. 核心逻辑 + +### 3.1 业务规则 + +- 企业微信登录路径:前端传 code → 后端调 weixin-java-cp SDK 换 `access_token` → 获取 `wx_userid` → 查 `sys_user.wx_userid` 匹配 → 用户不存在则返回错误(不自动注册)[SRC-FEAT-01] +- JWT Payload 包含:userId、role、departmentId,有效期由配置决定 +- 密码存储:BCrypt 加密(Spring Security 6.x),初始管理员密码 `admin123` 已预置 [SRC-SQL-01:5.4] + +### 3.2 校验逻辑 + +| 校验项 | 规则 | 失败响应 | +|-------|------|---------| +| wx_userid 是否存在于 sys_user | 必须已由管理员录入 | 返回错误:用户未录入,请联系管理员 | +| sys_user.status | 必须为 0(启用) | 返回错误:账号已禁用 | +| Token 有效性 | 未过期、签名正确 | 返回 401 | +| 受保护接口 Token 缺失 | 无 Authorization Header | 返回 401 | + +### 3.3 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> 未认证 + 未认证 --> 已认证 : 登录成功(JWT签发) + 已认证 --> 未认证 : Token过期 / 主动登出 + 已认证 --> 已认证 : Token续期(如有配置) +``` + +--- + +## 4. 数据结构 + +### 4.1 涉及数据表 + +**sys_user**(读取,不由本模块创建)[SRC-SQL-01] + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 用户主键 | +| wx_userid | VARCHAR(100) | 企业微信用户ID(唯一索引 uk_wx_userid) | +| username | VARCHAR(50) | 账号密码登录用账号(唯一索引 uk_username) | +| password | VARCHAR(100) | BCrypt加密密码 | +| role | TINYINT | 0-管理员 / 1-讲师 / 2-学员 | +| department_id | BIGINT | 所属部门ID(会注入 JWT Payload) | +| status | TINYINT | 0-启用 / 1-禁用 | + +### 4.2 DTO/VO 定义 + +[SRC-CODE-01:module/auth/dto/] + +| 类名 | 方向 | 关键字段 | +|------|------|---------| +| LoginRequest | 入参 | username, password | +| WechatLoginRequest | 入参 | code(企业微信授权码) | +| LoginResponse | 出参 | token, userId, realName, role, departmentId | +| UserInfoResponse | 出参 | 同 LoginResponse(从 Token 解析) | + +--- + +## 5. 对外接口 + +[SRC-API-01] + +| API 名称 | 方法 | 路径 | 权限 | +|---------|------|------|------| +| 企业微信登录 | POST | /api/auth/wechat/login | 公开 | +| 账号密码登录 | POST | /api/auth/login | 公开 | +| 获取当前用户 | GET | /api/auth/me | 需 Token | + +**事件机制**:登录成功后,UserContext(ThreadLocal)持有用户信息,供同请求链路的所有 Service 使用,请求结束后清除。[SRC-CODE-01:common/context/UserContextHolder.java] + +--- + +## 6. 异常与边界处理 + +| 场景 | 处理方式 | +|------|---------| +| 企业微信 API 调用超时/失败 | 返回 503,提示"企业微信服务暂时不可用,请稍后重试" | +| 企业微信 code 已使用(重放) | 企业微信 SDK 返回错误,转化为 400 | +| 用户 wx_userid 存在但账号被禁用(status=1) | 返回 403,提示"账号已禁用,请联系管理员" | +| sys_user 中无对应 wx_userid | 返回 403,提示"用户未录入系统,请联系管理员" | +| Token 格式错误(非JWT) | AuthInterceptor 捕获,返回 401 | +| Token 过期 | AuthInterceptor 捕获,返回 401,前端跳转登录页 | +| 并发登录(同一账号多端) | 当前设计无限制,多端独立 Token 有效 | diff --git a/training/context/03-功能模块/02-系统管理模块.md b/training/context/03-功能模块/02-系统管理模块.md new file mode 100644 index 0000000..a45ea41 --- /dev/null +++ b/training/context/03-功能模块/02-系统管理模块.md @@ -0,0 +1,165 @@ +# 系统管理模块(system) + +> 证据来源:[SRC-FEAT-01] [SRC-SQL-01] [SRC-CODE-01:module/system/] + +--- + +## 1. 模块定位 + +- **模块目标**:管理企业的组织架构(中心→部门→小组三级)和用户生命周期(录入、角色分配、状态管理),为所有业务模块的部门数据隔离提供基础。 +- **解决问题**:3-5名管理员管理约500人的组织结构,人员分配到正确部门和角色,是知识库/考试/培训数据隔离的前置条件。 + +--- + +## 2. 功能清单 + +[SRC-FEAT-01:模块一] [SRC-CODE-01:module/system/] + +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | +|---------|---------|------|------|---------| +| 中心管理(CRUD) | 创建/修改/删除最高级组织单元"中心" | CenterDTO(name, sort_order) | CenterVO | - | +| 部门管理(CRUD) | 在中心下创建/修改/删除部门,每中心最多8个部门 | DepartmentDTO(name, center_id) | DepartmentVO | - | +| 小组管理(CRUD) | 在部门下创建/修改/删除小组,每部门最多10个小组 | GroupDTO(name, department_id) | GroupVO | - | +| 组织树查询 | 一次性返回完整组织树(中心→部门→小组层级) | - | OrgTreeVO(树形结构) | - | +| 员工列表查询 | 分页查询员工,支持按部门/角色/姓名筛选 | UserQueryDTO | PageResult | - | +| 员工录入 | 管理员手动录入员工信息,绑定企业微信 userid | UserDTO(wx_userid, real_name, role, department_id…) | UserVO | - | +| 员工信息修改 | 修改员工姓名、部门、角色、小组归属 | UserDTO | UserVO | - | +| 员工禁用/启用 | 切换员工账号状态(0启用/1禁用) | userId, status | - | auth(下次登录校验) | +| 密码重置 | 管理员重置员工的账号密码(BCrypt) | PasswordDTO(userId, newPassword) | - | auth | +| 讲师查看本部门员工 | 讲师可查看本部门员工列表(只读,无编辑权限) | department_id(从Token注入) | PageResult | auth | + +--- + +## 3. 核心逻辑 + +### 3.1 业务规则 + +[SRC-FEAT-01:模块一] + +- **层级约束**:中心 → 部门(一个中心最多8个部门)→ 小组(一个部门最多10个小组),超限不允许创建 +- **角色约束**:用户角色只能为 ADMIN(0) / LECTURER(1) / STUDENT(2) 三种,创建时必填 +- **部门归属**:LECTURER 和 STUDENT 必须归属某部门(department_id 不可为空),ADMIN 可无部门 +- **逻辑删除**:所有实体使用 `deleted=1` 软删除,禁止物理删除,防止外键依赖失效 +- **员工录入方式**:V1 仅支持管理员手动逐条录入,不支持批量导入(V2 规划 EasyExcel 导入) + +### 3.2 校验逻辑 + +| 校验项 | 规则 | 失败响应 | +|-------|------|---------| +| 部门数量上限 | 同一 center_id 下 deleted=0 的部门数 ≤ 8 | 400:该中心部门数已达上限(8个) | +| 小组数量上限 | 同一 department_id 下 deleted=0 的小组数 ≤ 10 | 400:该部门小组数已达上限(10个) | +| wx_userid 唯一 | sys_user.wx_userid 全局唯一(uk_wx_userid 索引) | 400:该企业微信账号已录入 | +| username 唯一 | sys_user.username 全局唯一(uk_username 索引) | 400:用户名已存在 | +| 讲师数据隔离 | LECTURER 调用员工列表时,强制追加 department_id = 当前用户部门 | 自动过滤,不报错 | +| 删除有子项的上级 | 删除中心前,其下所有部门必须已删除;删除部门前,其下所有小组和用户必须已处理 | 400:请先删除部门下的小组和员工 | + +### 3.3 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> 启用 : 员工录入时默认 status=0 + 启用 --> 禁用 : 管理员操作禁用 + 禁用 --> 启用 : 管理员操作启用 + 禁用 --> 已删除 : 逻辑删除(deleted=1) + 启用 --> 已删除 : 逻辑删除(deleted=1) +``` + +--- + +## 4. 数据结构 + +[SRC-SQL-01:一、系统管理模块] + +### 4.1 涉及数据表 + +**sys_center** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| name | VARCHAR(100) | 中心名称 | +| sort_order | INT DEFAULT 0 | 排序 | +| deleted | TINYINT | 逻辑删除(0/1) | + +**sys_department** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| name | VARCHAR(100) | 部门名称 | +| center_id | BIGINT NOT NULL | 所属中心(idx_center_id 索引) | +| sort_order | INT | 排序 | +| deleted | TINYINT | 逻辑删除 | + +**sys_group** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| name | VARCHAR(100) | 小组名称 | +| department_id | BIGINT NOT NULL | 所属部门(idx_department_id 索引) | +| sort_order | INT | 排序 | +| deleted | TINYINT | 逻辑删除 | + +**sys_user** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| wx_userid | VARCHAR(100) | 企业微信 userid(唯一) | +| username | VARCHAR(50) NOT NULL | 账号(唯一) | +| password | VARCHAR(100) | BCrypt 加密密码 | +| real_name | VARCHAR(50) NOT NULL | 真实姓名 | +| phone | VARCHAR(20) | 手机号 | +| avatar | VARCHAR(255) | 头像 URL | +| role | TINYINT NOT NULL | 0-管理员 / 1-讲师 / 2-学员 | +| department_id | BIGINT | 所属部门(可空,ADMIN 用) | +| group_id | BIGINT | 所属小组(可空) | +| status | TINYINT DEFAULT 0 | 0-启用 / 1-禁用 | +| deleted | TINYINT | 逻辑删除 | + +### 4.2 数据关联 + +- `sys_user.department_id` → `sys_department.id`(无外键约束,逻辑关联) +- `sys_user.group_id` → `sys_group.id`(可空) +- `sys_department.center_id` → `sys_center.id` + +--- + +## 5. 对外接口 + +[SRC-CODE-01:module/system/controller/] + +| API 名称 | 方法 | 路径 | 权限 | +|---------|------|------|------| +| 获取组织树 | GET | /api/system/org/tree | ADMIN | +| 查询中心列表 | GET | /api/system/center | ADMIN | +| 创建中心 | POST | /api/system/center | ADMIN | +| 修改中心 | PUT | /api/system/center/{id} | ADMIN | +| 删除中心 | DELETE | /api/system/center/{id} | ADMIN | +| 查询部门列表 | GET | /api/system/department | ADMIN | +| 创建部门 | POST | /api/system/department | ADMIN | +| 修改部门 | PUT | /api/system/department/{id} | ADMIN | +| 删除部门 | DELETE | /api/system/department/{id} | ADMIN | +| 查询小组列表 | GET | /api/system/group | ADMIN | +| 创建小组 | POST | /api/system/group | ADMIN | +| 修改小组 | PUT | /api/system/group/{id} | ADMIN | +| 删除小组 | DELETE | /api/system/group/{id} | ADMIN | +| 查询员工列表 | GET | /api/system/user | ADMIN(全部) / LECTURER(本部门只读) | +| 创建员工 | POST | /api/system/user | ADMIN | +| 修改员工 | PUT | /api/system/user/{id} | ADMIN | +| 禁用/启用员工 | PUT | /api/system/user/{id}/status | ADMIN | +| 重置密码 | PUT | /api/system/user/{id}/password | ADMIN | + +--- + +## 6. 异常与边界处理 + +| 场景 | 处理方式 | +|------|---------| +| 删除有下级的中心/部门 | 校验子项是否全部删除,否则返回 400 + 提示 | +| 部门/小组数量超限 | 严格校验上限(8/10),超出返回 400 | +| 员工 wx_userid 重复 | 唯一索引捕获 DuplicateKeyException,友好提示 | +| LECTURER 访问其他部门员工 | AuthInterceptor / Service 层强制 department_id 过滤,返回空列表 | +| 管理员误操作禁用自己 | [ASSUMPTION] PRD 未明确说明是否限制,建议禁止禁用最后一个 ADMIN | +| 员工转移部门 | 修改 department_id 即可,但其历史知识进度/考试记录中的 department_id 为冗余字段不随之更新 | diff --git a/training/context/03-功能模块/03-知识库模块.md b/training/context/03-功能模块/03-知识库模块.md new file mode 100644 index 0000000..d9a7d16 --- /dev/null +++ b/training/context/03-功能模块/03-知识库模块.md @@ -0,0 +1,205 @@ +# 知识库模块(knowledge) + +> 证据来源:[SRC-FEAT-01] [SRC-FEAT-02] [SRC-SQL-01] [SRC-SQL-02] [SRC-API-01] [SRC-CODE-01:module/knowledge/] + +--- + +## 1. 模块定位 + +- **模块目标**:管理企业的结构化知识资产,支持文档和视频两种内容类型的上传、分类管理、状态发布,并记录学员的个人学习进度。 +- **解决问题**:解决救援知识依赖口口相传的问题;同时为培训计划提供可引用的学习内容,并跟踪学员是否真实完成学习(防刷机制)。 + +--- + +## 2. 功能清单 + +[SRC-FEAT-01:模块二] [SRC-FEAT-02:3.2 知识学习] + +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | +|---------|---------|------|------|---------| +| 知识分类管理 | 创建/修改/删除多级分类树,按部门隔离 | CategoryDTO(name, parent_id, department_id) | CategoryVO | system(部门) | +| 知识分类树查询 | 返回树形结构的分类目录 | department_id | List(树) | - | +| 知识内容 CRUD | 创建/修改/删除知识(文档/视频),状态为草稿时可修改 | KnowledgeDTO(title, type, file_url, category_id…) | KnowledgeVO | system | +| 文件上传 | 上传 PDF/Word/Excel/PPT/视频文件,返回文件 URL | MultipartFile | FileDTO(url, fileName, size, type) | 文件存储服务 | +| 知识发布/下架 | 变更知识状态(草稿→发布/发布→下架/下架→再发布)| knowledgeId, action | - | - | +| 知识列表查询(讲师端) | 管理端分页查询,可查所有状态,含草稿 | KnowledgeQueryDTO(category_id, status, keyword) | PageResult | - | +| 知识列表查询(学员端) | 只查 PUBLISHED 状态,携带个人学习状态 | department_id, KnowledgeQueryDTO | PageResult | auth | +| 知识在线预览 | 文档在线预览(PDF/Word等)、视频在线播放 | knowledgeId | 文件 URL / 预览链接 | 文件存储/文档预览服务 | +| 开始学习 | 创建或获取学习进度记录,记录首次学习时间 | knowledgeId, [source, planId] | - | auth | +| 更新学习进度 | 定时上报学习进度(每60秒),累加时长,判断完成 | ProgressUpdateDTO(duration, progress, videoPosition, source, planId) | - | training(若来源=TRAINING) | +| 完成学习 | 前端显式标记完成(文档)| knowledgeId | - | - | +| 查询学习进度 | 查询学员对某知识/某批知识的学习状态 | userId/knowledgeIds | List | - | +| 引用保护检查 | 检查知识是否被培训计划引用,下架前警告 | knowledgeId | Boolean + 引用计划数 | training | + +--- + +## 3. 核心逻辑 + +### 3.1 业务规则 + +[SRC-FEAT-01:4.5 引用保护规则] [SRC-API-01:5.1 学习进度更新流程] + +**内容状态规则**: +- 草稿(DRAFT=0):只有讲师和管理员可见,可自由修改 +- 已发布(PUBLISHED=1):全部门学员可见,处于此状态时不可修改内容(需先下架) +- 已下架(OFFLINE=2):学员不可见,可重新上架或继续修改后再发布 + +**学习进度完成判定规则**: +- **视频类**:播放进度 ≥ 90% 时,status 自动变更为 COMPLETED +- **文档类**:累计学习时长 ≥ 预估阅读时间(按文件大小估算,具体算法在 `calculateEstimatedTime()`)时,status 变更为 COMPLETED +- 两种类型均支持多次学习,完成后不会重置;时长可继续累加 + +**防刷机制(活跃检测)**: +- 前端 ActivityDetector 监听 mousemove/click/scroll/keypress 事件 +- 60秒无活动判定为非活跃,停止本地计时器,不上报进度 +- 恢复活跃后重新计时 + +**多标签页防重**: +- BroadcastChannel API 实现:同一知识在多个 Tab 打开时,检测到重复后关闭后来的 Tab [SRC-API-01:6.2] + +**部门数据隔离**: +- 所有知识查询一律追加 `WHERE department_id = [当前用户部门]` +- 分类也按 department_id 隔离 + +**引用保护**: +- 知识下架前,需检查 `tr_plan_knowledge` 中是否有关联;若有,提示警告,用户确认后方可下架 + +### 3.2 校验逻辑 + +| 校验项 | 规则 | 失败响应 | +|-------|------|---------| +| 文件类型 | 文档:PDF/Word/Excel/PPT;视频:MP4等常见格式 | 400:不支持的文件格式 | +| 文件大小 | [ASSUMPTION] PRD 提及限制大文件,具体上限需配置,建议文档 ≤100MB,视频 ≤2GB | 400:文件超出大小限制 | +| 知识状态变更 | PUBLISHED 状态下不允许修改内容字段(标题/文件),只允许下架操作 | 400:已发布知识不可修改,请先下架 | +| 学习进度上报频率 | 前端每60秒上报一次,后端不做频率限制(允许补报) | - | +| 视频播放进度合理性 | `video_position ≤ 视频总时长`([ASSUMPTION] 后端当前未做服务端校验,PRD 明确属于风险项) | 暂无服务端拦截 | +| 分类隶属 | 知识的 category_id 必须属于同一 department_id 的分类 | 400:分类不存在或无权限 | + +### 3.3 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> DRAFT : 创建知识(可新增/修改/删除) + DRAFT --> PUBLISHED : 讲师发布 + PUBLISHED --> OFFLINE : 讲师下架(引用警告确认) + OFFLINE --> PUBLISHED : 讲师重新上架 + OFFLINE --> DRAFT : 重新编辑后回到草稿 [ASSUMPTION] + DRAFT --> [*] : 逻辑删除 + + note right of PUBLISHED : 学员可见\n不可修改内容 + note right of DRAFT : 仅讲师/管理员可见 + note right of OFFLINE : 学员不可见\n可编辑 +``` + +**学习进度状态流转**: + +```mermaid +stateDiagram-v2 + [*] --> NOT_STARTED : 知识发布,学员尚未打开 + NOT_STARTED --> IN_PROGRESS : 调用"开始学习"接口 + IN_PROGRESS --> IN_PROGRESS : 每次上报进度(时长累加) + IN_PROGRESS --> COMPLETED : 视频≥90% 或 文档时长达标 + COMPLETED --> IN_PROGRESS : 再次学习(时长继续累加,状态不回退) +``` + +--- + +## 4. 数据结构 + +[SRC-SQL-01:二、知识库模块] [SRC-SQL-02] + +### 4.1 涉及数据表 + +**km_category** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| name | VARCHAR(100) NOT NULL | 分类名称 | +| parent_id | BIGINT DEFAULT 0 | 父分类(0=顶级) | +| department_id | BIGINT NOT NULL | 所属部门(部门隔离键) | +| sort_order | INT | 排序 | +| deleted | TINYINT | 逻辑删除 | + +**km_knowledge** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| title | VARCHAR(200) NOT NULL | 标题 | +| description | TEXT | 描述/摘要 | +| category_id | BIGINT | 所属分类(可空) | +| type | TINYINT NOT NULL | 0-文档 / 1-视频 | +| file_name | VARCHAR(255) | 文件名 | +| file_url | VARCHAR(500) | 文件 URL | +| file_size | BIGINT | 文件大小(字节) | +| file_type | VARCHAR(20) | 文件后缀(pdf/mp4…) | +| department_id | BIGINT NOT NULL | 所属部门(隔离键,有 idx) | +| status | TINYINT DEFAULT 0 | 0-草稿 / 1-已发布 / 2-已下架 | +| creator_id | BIGINT NOT NULL | 创建人 | +| publish_time | DATETIME | 发布时间 | +| view_count | INT | 浏览次数 | +| deleted | TINYINT | 逻辑删除 | + +**km_knowledge_progress**(学员学习进度)[SRC-SQL-02] + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| user_id | BIGINT NOT NULL | 学员ID | +| knowledge_id | BIGINT NOT NULL | 知识ID | +| department_id | BIGINT NOT NULL | 部门(冗余,用于分区查询) | +| status | VARCHAR(20) | NOT_STARTED / IN_PROGRESS / COMPLETED | +| progress | INT | 进度百分比(0~100) | +| duration | BIGINT | 累计学习时长(秒) | +| video_position | BIGINT | 视频播放位置(秒,仅视频) | +| source | VARCHAR(20) | FREE / TRAINING | +| plan_id | BIGINT | 关联培训计划(source=TRAINING 时有值) | +| start_time | DATETIME | 首次学习时间 | +| complete_time | DATETIME | 完成时间 | +| 唯一约束 | uk_user_knowledge | (user_id, knowledge_id) 唯一 | + +### 4.2 数据关联 + +- `km_knowledge.category_id` → `km_category.id` +- `km_knowledge.department_id` = `km_category.department_id`(同部门隔离) +- `km_knowledge_progress.user_id` → `sys_user.id` +- `km_knowledge_progress.plan_id` → `tr_plan.id`(可空) + +--- + +## 5. 对外接口 + +[SRC-API-01:4.3 知识学习接口] [SRC-CODE-01:module/knowledge/controller/] + +| API 名称 | 方法 | 路径 | 权限 | +|---------|------|------|------| +| 查询知识分类树 | GET | /api/knowledge/category/tree | LECTURER/ADMIN | +| 创建分类 | POST | /api/knowledge/category | LECTURER/ADMIN | +| 修改分类 | PUT | /api/knowledge/category/{id} | LECTURER/ADMIN | +| 删除分类 | DELETE | /api/knowledge/category/{id} | LECTURER/ADMIN | +| 查询知识列表(管理端) | GET | /api/knowledge | LECTURER/ADMIN | +| 创建知识 | POST | /api/knowledge | LECTURER/ADMIN | +| 修改知识 | PUT | /api/knowledge/{id} | LECTURER/ADMIN | +| 发布/下架知识 | PUT | /api/knowledge/{id}/status | LECTURER/ADMIN | +| 删除知识 | DELETE | /api/knowledge/{id} | LECTURER/ADMIN | +| 文件上传 | POST | /api/knowledge/upload | LECTURER/ADMIN | +| 学员-知识列表 | GET | /api/student/knowledge | STUDENT | +| 学员-知识详情 | GET | /api/student/knowledge/{id} | STUDENT | +| 学员-开始学习 | POST | /api/student/knowledge/{id}/start | STUDENT | +| 学员-更新进度 | POST | /api/student/knowledge/{id}/progress | STUDENT | +| 学员-标记完成 | POST | /api/student/knowledge/{id}/complete | STUDENT | + +--- + +## 6. 异常与边界处理 + +| 场景 | 处理方式 | +|------|---------| +| 已发布知识被下架,同时培训计划正引用 | 弹出确认警告,显示引用该知识的培训计划数量,用户确认后方可下架 | +| 文件上传失败(存储服务不可用) | 返回 503,前端提示"文件上传失败,请稍后重试" | +| 学员学习已下架的知识(在培训计划中) | [ASSUMPTION] PRD 未明确,建议:关联进培训计划的知识下架后,学员仍可查看历史进度但不可重新打开 | +| km_knowledge_progress 并发 UPSERT(同一学员多标签页) | 唯一约束 uk_user_knowledge 防止重复插入;多标签页同步由前端 BroadcastChannel 控制关闭重复 Tab | +| 视频播放中途退出 | 下次进入从 video_position 断点续播 | +| 学习进度上报时网络中断 | 前端重试,后端幂等(UPSERT 语义,重复上报取最大值或继续累加) | +| 分类下有知识时删除分类 | 检查该分类下是否有 deleted=0 的知识,若有则拒绝:400 请先移除分类下的知识 | diff --git a/training/context/03-功能模块/04-考试模块.md b/training/context/03-功能模块/04-考试模块.md new file mode 100644 index 0000000..fe174f5 --- /dev/null +++ b/training/context/03-功能模块/04-考试模块.md @@ -0,0 +1,253 @@ +# 考试模块(exam) + +> 证据来源:[SRC-FEAT-01] [SRC-FEAT-02] [SRC-SQL-01] [SRC-API-01] [SRC-CODE-01:module/exam/] + +--- + +## 1. 模块定位 + +- **模块目标**:管理从出题到考试全链路:题目→试卷→考试→作答记录,支持在线作答、自动判分、成绩排名。 +- **解决问题**:替代纸质考试,提升出卷效率(自动组卷),实现客观题自动判分,并对考试行为(时间窗口、次数限制、超时交卷)进行严格管控。 + +--- + +## 2. 功能清单 + +[SRC-FEAT-01:模块三/四/五] [SRC-API-01:4.4 在线考试接口] + +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | +|---------|---------|------|------|---------| +| 题目分类管理 | 按分类管理题目,多级目录,按部门隔离 | QuestionCategoryDTO | QuestionCategoryVO | system | +| 题目 CRUD | 创建/修改/删除题目,支持单选/多选/判断三种题型,必须填写解析 | QuestionDTO(type, content, options, answer, analysis) | QuestionVO | - | +| 题目状态管理 | 草稿→发布→下架,下架前检查引用 | questionId, action | - | - | +| 手动组卷 | 讲师从题库手动选题,配置每题分值 | PaperDTO(title, questions[]) | PaperVO | - | +| 自动组卷 | 按规则(单选X题+多选Y题+判断Z题)随机抽题 | AutoPaperDTO(规则配置) | PaperVO | - | +| 试卷配置 | 设置总分、各题分值、考试时长、及格分 | PaperDTO | PaperVO | - | +| 试卷预览 | 组卷后预览试卷效果(随机题目顺序) | paperId | 完整试卷预览 | - | +| 试卷状态管理 | 草稿→发布→下架 | paperId, action | - | - | +| 创建考试 | 选择试卷,设置考试名称、时间窗口、最大次数 | ExamQueryDTO + ExamTarget | ExamVO | - | +| 指定考试对象 | 指定部门/小组/个人参加考试 | exam_id, target_type, target_id[] | - | system | +| 发布考试 | 将考试状态从 NOT_STARTED 转为 IN_PROGRESS(时间到自动) | examId | - | - | +| 学员-我的考试列表 | 查询分配给当前学员的考试(在时间窗口内) | - | PageResult | auth | +| 学员-开始考试 | 校验资格,创建 exam_record,返回试题 | examId | 试题 + 时长 + recordId | - | +| 学员-断点续考 | 恢复进行中的考试记录,返回已保存答案 | examId | 试题 + 已保存答案 + 剩余时间 | - | +| 学员-保存答案 | 每30秒自动保存答案(增量保存) | recordId, answers(JSON) | - | - | +| 学员-提交试卷 | 自动判分,保存成绩,返回排名+解析 | recordId, answers(JSON) | 成绩 + 排名 + 答案解析 | - | +| 超时自动交卷 | 定时任务每分钟扫描超时记录,系统代提交 | @Scheduled | - | - | +| 查看考试结果 | 查看某次考试的详细成绩和答案解析 | recordId | ExamResultVO | - | +| 成绩/排名查询 | 讲师查看某考试的所有学员成绩 | examId | PageResult<成绩列表> | - | + +--- + +## 3. 核心逻辑 + +### 3.1 业务规则 + +[SRC-FEAT-01:4.3 考试规则] [SRC-API-01:5.2/5.3] + +**题型规则**: +- 单选题(SINGLE=0):options 为 JSON 数组,answer 为单字母(A/B/C/D) +- 多选题(MULTIPLE=1):answer 为多字母组合(如"AB"/"BCD"),按字母排序存储 +- 判断题(JUDGE=2):options 为空,answer 为"T"(正确)或"F"(错误) +- 每道题必须填写 `analysis`(答案解析),否则不允许发布 + +**考试时间窗口**: +- 只有在 `exam.start_time ≤ NOW() ≤ exam.end_time` 时,学员才能进入考试 +- 考试到期后,`exam.status` 自动(或手动)变为 ENDED + +**次数限制**: +- 达到 `max_attempts` 后,学员不可再次开始考试 +- 历史成绩取**最高分**作为最终成绩 +- 每次考试创建 `exam_record.attempt_no = 历史次数 + 1` + +**自动判分规则**: +- 仅客观题(单选/多选/判断)自动判分 +- 单选/判断:答案完全匹配得满分 +- 多选:全部选项匹配(顺序无关)得满分;部分正确得 0 分(V1 不分项给分) +- 总分 = 各题得分之和 + +**超时自动交卷**: +- `@Scheduled(cron="0 */1 * * * ?")` 每分钟扫描 +- 查询:`ex_exam_record.status=IN_PROGRESS` 且 `start_time + paper.duration 分钟 < NOW()` +- 使用乐观锁(`version` 字段)防止与用户主动交卷并发冲突 [SRC-API-01:5.2] + +**排名计算**: +- 计算时机:用户交卷后立即计算本次提交对应的排名 +- 统计范围:同一 `exam_id` 下,所有 `status=SUBMITTED` 记录,取每用户最高分 +- 排名规则:`DENSE_RANK() OVER (ORDER BY MAX(score) DESC, MIN(submit_time) ASC)`(同分按提交时间早排前)[SRC-API-01:5.3] + +**题目顺序**: +- 自动组卷时,返回给学员的题目顺序随机打乱(前端处理或后端随机返回) + +### 3.2 校验逻辑 + +| 校验项 | 规则 | 失败响应 | +|-------|------|---------| +| 题目 analysis 必填 | 发布时 analysis 不可为空 | 400:请填写答案解析后再发布 | +| 开始考试-时间窗口 | NOW() 在 [start_time, end_time] 范围内 | 400:考试尚未开始 / 考试已结束 | +| 开始考试-次数限制 | 已有考试记录数 < max_attempts | 400:已达到最大考试次数 | +| 开始考试-状态 | exam.status = IN_PROGRESS(1) | 400(状态不符) | +| 开始考试-进行中记录 | 若已有 IN_PROGRESS 记录,返回续考而非新建 | 自动转入断点续考流程 | +| 答案格式 | 多选题答案字母需排序后匹配 | 自动处理,不返回错误 | +| 超时交卷并发 | 乐观锁冲突时(用户已提交),忽略定时任务提交 | 静默忽略 OptimisticLockException | +| 试卷引用保护 | 已被考试引用的试卷下架前需确认警告 | 400 + 警告 + 引用考试数量 | +| 题目引用保护 | 已被试卷引用的题目下架前需确认警告 | 400 + 警告 + 引用试卷数量 | + +### 3.3 状态流转 + +**内容状态(题目/试卷)**: + +```mermaid +stateDiagram-v2 + [*] --> DRAFT : 创建 + DRAFT --> PUBLISHED : 发布(题目需有 analysis) + PUBLISHED --> OFFLINE : 下架(引用保护确认) + OFFLINE --> PUBLISHED : 重新上架 + DRAFT --> [*] : 逻辑删除 +``` + +**考试状态**: + +```mermaid +stateDiagram-v2 + [*] --> NOT_STARTED : 创建考试(start_time 未到) + NOT_STARTED --> IN_PROGRESS : 到达 start_time(自动或手动) + IN_PROGRESS --> ENDED : 到达 end_time(自动或手动结束) + ENDED --> [*] : 逻辑删除 +``` + +**考试记录状态**: + +```mermaid +stateDiagram-v2 + [*] --> IN_PROGRESS : 学员点击"开始考试"创建记录 + IN_PROGRESS --> SUBMITTED : 学员交卷(submit_source=USER) + IN_PROGRESS --> SUBMITTED : 定时任务超时(submit_source=SYSTEM_TIMEOUT) + SUBMITTED --> [*] : 永久保留 +``` + +--- + +## 4. 数据结构 + +[SRC-SQL-01:三、考试模块] + +### 4.1 涉及数据表(7张) + +**ex_question_category**:题目分类(同知识库分类结构,按 department_id 隔离) + +**ex_question** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| type | TINYINT | 0-单选 / 1-多选 / 2-判断 | +| content | TEXT NOT NULL | 题干 | +| options | JSON | 选项数组(判断题为 NULL) | +| answer | VARCHAR(50) NOT NULL | 正确答案(单选/判断单字母,多选合并字母) | +| analysis | TEXT | 答案解析(发布前必填) | +| category_id | BIGINT | 所属分类 | +| department_id | BIGINT NOT NULL | 部门隔离键 | +| status | TINYINT | 0-草稿 / 1-发布 / 2-下架 | +| creator_id | BIGINT NOT NULL | 创建人 | + +**ex_paper** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| title | VARCHAR(200) NOT NULL | 试卷标题 | +| total_score | INT DEFAULT 100 | 总分 | +| duration | INT DEFAULT 60 | 考试时长(分钟) | +| pass_score | INT DEFAULT 60 | 及格分 | +| department_id | BIGINT NOT NULL | 部门隔离键 | +| status | TINYINT | 0-草稿 / 1-发布 / 2-下架 | + +**ex_paper_question** + +| 字段 | 类型 | 说明 | +|------|------|------| +| paper_id | BIGINT NOT NULL | 试卷ID | +| question_id | BIGINT NOT NULL | 题目ID | +| score | INT DEFAULT 5 | 该题分值 | +| sort_order | INT | 排序(大题号顺序) | + +**ex_exam** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| title | VARCHAR(200) NOT NULL | 考试标题 | +| paper_id | BIGINT NOT NULL | 关联试卷 | +| start_time / end_time | DATETIME | 时间窗口 | +| max_attempts | INT DEFAULT 1 | 最大考试次数 | +| department_id | BIGINT NOT NULL | 部门隔离键 | +| status | TINYINT | 0-未开始 / 1-进行中 / 2-已结束 | + +**ex_exam_target** + +| 字段 | 类型 | 说明 | +|------|------|------| +| exam_id | BIGINT NOT NULL | 考试ID | +| target_type | TINYINT | 0-部门 / 1-小组 / 2-个人 | +| target_id | BIGINT NOT NULL | 对应 dept/group/user 的ID | + +**ex_exam_record**(含 V1.0.1 扩展字段) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| exam_id | BIGINT NOT NULL | 考试ID | +| user_id | BIGINT NOT NULL | 学员ID | +| attempt_no | INT NOT NULL | 第几次考试 | +| score | INT | 得分 | +| passed | TINYINT | 0-未通过 / 1-通过 | +| start_time | DATETIME NOT NULL | 开始时间 | +| submit_time | DATETIME | 提交时间 | +| answers | JSON | 答题详情 | +| status | VARCHAR(20) | IN_PROGRESS / SUBMITTED | +| last_save_time | DATETIME | 最后保存时间 | +| version | INT DEFAULT 0 | 乐观锁版本号 | +| submit_source | VARCHAR(20) | USER / SYSTEM_TIMEOUT | + +### 4.2 数据关联 + +- `ex_paper_question.paper_id` → `ex_paper.id` +- `ex_paper_question.question_id` → `ex_question.id` +- `ex_exam.paper_id` → `ex_paper.id` +- `ex_exam_target` 多态关联:target_type=0 → `sys_department.id`;=1 → `sys_group.id`;=2 → `sys_user.id` +- `ex_exam_record.exam_id` → `ex_exam.id`;`user_id` → `sys_user.id` + +--- + +## 5. 对外接口 + +[SRC-API-01:4.4] + +| API 名称 | 方法 | 路径 | 权限 | +|---------|------|------|------| +| 题目分类 CRUD | GET/POST/PUT/DELETE | /api/exam/question-category/** | LECTURER/ADMIN | +| 题目 CRUD | GET/POST/PUT/DELETE | /api/exam/question/** | LECTURER/ADMIN | +| 试卷 CRUD + 自动组卷 | GET/POST/PUT/DELETE | /api/exam/paper/** + /api/exam/paper/auto | LECTURER/ADMIN | +| 考试 CRUD | GET/POST/PUT/DELETE | /api/exam/** | LECTURER/ADMIN | +| 我的考试列表 | GET | /api/student/exam | STUDENT | +| 考试详情 | GET | /api/student/exam/{id} | STUDENT | +| 开始考试 | POST | /api/student/exam/{id}/start | STUDENT | +| 断点续考 | GET | /api/student/exam/{id}/continue | STUDENT | +| 保存答案 | POST | /api/student/exam/{id}/save | STUDENT | +| 提交试卷 | POST | /api/student/exam/{id}/submit | STUDENT | +| 查看考试结果 | GET | /api/student/exam/record/{recordId} | STUDENT | + +--- + +## 6. 异常与边界处理 + +| 场景 | 处理方式 | +|------|---------| +| 学员在时间窗口外访问考试 | 400 + 明确提示是"未开始"还是"已结束" | +| 次数用完后再次开始 | 400:已达最大考试次数,如有异议请联系讲师 | +| 交卷时 exam_record 已是 SUBMITTED(并发/重复提交) | 乐观锁冲突或状态判断,幂等返回已有成绩,不重复判分 | +| 定时任务超时提交时用户同时提交 | 乐观锁 version 冲突,定时任务忽略,用户提交成功 | +| 试卷总分与题目分值之和不一致 | [ASSUMPTION] PRD 未明确,建议前端组卷时实时计算并提示差额 | +| 自动组卷时题库数量不足 | 400:题库中相应题型数量不足,请手动添加题目或降低要求 | +| 学员中途关闭浏览器 | answers 按最后一次 save 记录恢复,重新进入走断点续考流程 | diff --git a/training/context/03-功能模块/05-培训计划模块.md b/training/context/03-功能模块/05-培训计划模块.md new file mode 100644 index 0000000..23ed921 --- /dev/null +++ b/training/context/03-功能模块/05-培训计划模块.md @@ -0,0 +1,196 @@ +# 培训计划模块(training) + +> 证据来源:[SRC-FEAT-01] [SRC-FEAT-02] [SRC-SQL-01] [SRC-API-01] [SRC-CODE-01:module/training/] + +--- + +## 1. 模块定位 + +- **模块目标**:将知识内容和考试任务组合成有时间范围的培训计划,分配给指定学员,并跟踪每位学员的完成进度。 +- **解决问题**:组织端无法跟踪员工是否真正完成学习目标;培训计划模块实现"分配→执行→进度可视"的闭环管理。 + +--- + +## 2. 功能清单 + +[SRC-FEAT-01:模块六] [SRC-FEAT-02:3.4 我的培训] [SRC-API-01:4.5] + +| 功能名称 | 功能描述 | 输入 | 输出 | 依赖模块 | +|---------|---------|------|------|---------| +| 创建培训计划 | 设置计划基本信息(名称/描述/时间范围/所属部门) | TrainingPlanQueryDTO(title, start_date, end_date) | TrainingPlanVO | system | +| 关联知识内容 | 在培训计划中添加多个知识,设置必修/选修和排序 | plan_id, [{knowledge_id, required, sort_order}] | - | knowledge | +| 关联考试任务 | 在培训计划中添加多个考试,设置必考/选考和排序(V1.0.1+)| plan_id, [{exam_id, required, sort_order}] | - | exam | +| 分配培训对象 | 指定培训计划的参与者(部门/小组/个人) | plan_id, [{target_type, target_id}] | - | system | +| 培训计划列表(讲师端) | 查询本部门的培训计划,含状态和统计信息 | 分页参数 | PageResult | - | +| 培训计划详情(讲师端) | 查看计划详情,含关联知识/考试/对象/进度汇总 | planId | TrainingPlanDetailVO | - | +| 更新/删除培训计划 | 修改计划信息(未开始时可修改,进行中时受限)| planId, TrainingPlanQueryDTO | - | - | +| 学员-我的培训列表 | 查询分配给当前学员的培训计划(进行中优先) | - | PageResult | auth | +| 学员-培训详情 | 查看某培训计划详情:课程列表+学习状态+考试列表+通过状态 | planId | TrainingDetailVO | knowledge, exam | +| 进度计算 | 按必修知识完成数 + 必考通过数 / 总必修项 计算进度% | planId, userId | Integer(0~100) | knowledge, exam | +| 知识进度同步 | 学员学习某知识时,若 source=TRAINING 则同步更新 tr_plan_progress | planId, userId, knowledgeId | - | knowledge | + +--- + +## 3. 核心逻辑 + +### 3.1 业务规则 + +[SRC-FEAT-01:模块六] [SRC-API-01:5.4] + +**培训计划状态自动流转**: +- `tr_plan.status` 根据 `start_date` / `end_date` 与当前日期比较自动判定: + - `NOW() < start_date` → NOT_STARTED + - `start_date ≤ NOW() ≤ end_date` → IN_PROGRESS + - `NOW() > end_date` → ENDED +- [ASSUMPTION] 状态字段在查询时实时计算,或由定时任务定期刷新(PRD 未明确) + +**培训进度计算公式**(V1.0.1)[SRC-API-01:5.4]: +``` +progress% = (必修知识完成数 + 必考考试通过数) / (必修知识总数 + 必考考试总数) × 100 +``` +- 必修知识完成:`km_knowledge_progress.status = COMPLETED` 且 `plan_id = 当前计划` +- 必考通过:在 `tr_plan_exam` 中 `required=1` 的考试,学员在 `ex_exam_record` 中存在 `passed=1` 的记录 +- 选修知识/选考考试不计入进度(但在详情页展示) +- 总必修项为 0 时,进度 = 100%(无内容的计划视为自动完成) + +**知识完成与培训进度联动**: +- 学员调用 `knowledge.updateProgress()` 时,若 `source=TRAINING` 且 `planId != null`: + 1. 更新 `km_knowledge_progress` + 2. 若 status 变为 COMPLETED,则更新 `tr_plan_progress`(plan_id, user_id, knowledge_id 唯一记录) + +**考试通过与培训进度联动**: +- 学员考试提交后,`TrainingProgressService` 查询该 exam_id 被哪些计划引用(`tr_plan_exam`),若有关联且 `passed=1`,则重新计算相关计划的进度(或标记为需要刷新) + +**多对象分配逻辑**: +- 分配对象类型支持 DEPARTMENT(0) / GROUP(1) / USER(2) 混合 +- 当指定 DEPARTMENT 时,该部门下所有 STUDENT 账号都被纳入 +- 当指定 GROUP 时,该小组所有成员纳入 +- 已被纳入的学员在查询时去重(多路径覆盖同一人不重复统计) + +### 3.2 校验逻辑 + +| 校验项 | 规则 | 失败响应 | +|-------|------|---------| +| 培训时间范围 | start_date < end_date | 400:培训结束日期必须晚于开始日期 | +| 关联知识状态 | 关联到计划的知识必须是 PUBLISHED 状态 | 400:请先发布知识再关联到培训计划 | +| 关联考试状态 | 关联到计划的考试必须是 IN_PROGRESS 或 NOT_STARTED | 400:已结束的考试不可关联 | +| 进行中计划修改 | [ASSUMPTION] IN_PROGRESS 状态下,不允许修改培训对象和时间范围 | 400:培训进行中,不可修改 | +| 知识引用保护 | 已关联计划的知识被下架时触发警告 | knowledge 模块负责,见知识库模块 6 | +| 学员查看非自己的计划 | Service 层校验该学员是否在 tr_plan_target 覆盖范围内 | 403:无权访问该培训计划 | + +### 3.3 状态流转 + +```mermaid +stateDiagram-v2 + [*] --> NOT_STARTED : 创建培训计划(start_date 未到) + NOT_STARTED --> IN_PROGRESS : start_date 到达(自动/定时) + IN_PROGRESS --> ENDED : end_date 到达(自动/定时) + NOT_STARTED --> [*] : 逻辑删除(未开始可删除) + ENDED --> [*] : 归档 + + note right of IN_PROGRESS : 学员可学习和考试 + note right of ENDED : 仅供历史查阅 +``` + +**学员培训进度状态(非独立表字段,实时计算)**: +- 0%:尚未开始学习 +- 1%~99%:学习中 +- 100%:全部必修项完成(培训通过) + +--- + +## 4. 数据结构 + +[SRC-SQL-01:四、培训模块] + +### 4.1 涉及数据表 + +**tr_plan** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT | 主键 | +| title | VARCHAR(200) NOT NULL | 计划标题 | +| description | TEXT | 计划描述 | +| start_date | DATE NOT NULL | 开始日期 | +| end_date | DATE NOT NULL | 结束日期 | +| department_id | BIGINT NOT NULL | 所属部门(数据隔离键) | +| status | TINYINT | 0-未开始 / 1-进行中 / 2-已结束 | +| creator_id | BIGINT NOT NULL | 创建人 | + +**tr_plan_knowledge**(培训计划-知识关联) + +| 字段 | 类型 | 说明 | +|------|------|------| +| plan_id | BIGINT NOT NULL | 培训计划 ID | +| knowledge_id | BIGINT NOT NULL | 知识 ID | +| required | TINYINT DEFAULT 1 | 1-必修 / 0-选修 | +| sort_order | INT | 排序 | + +**tr_plan_exam**(培训计划-考试关联,V1.0.1) + +| 字段 | 类型 | 说明 | +|------|------|------| +| plan_id | BIGINT NOT NULL | 培训计划 ID | +| exam_id | BIGINT NOT NULL | 考试 ID | +| required | TINYINT DEFAULT 1 | 1-必考 / 0-选考 | +| sort_order | INT | 排序 | +| 唯一约束 | uk_plan_exam | (plan_id, exam_id) | + +**tr_plan_target**(培训计划-分配对象) + +| 字段 | 类型 | 说明 | +|------|------|------| +| plan_id | BIGINT NOT NULL | 培训计划 ID | +| target_type | TINYINT NOT NULL | 0-部门 / 1-小组 / 2-个人 | +| target_id | BIGINT NOT NULL | 对应实体 ID | + +**tr_plan_progress**(知识级别进度,供快速查询) + +| 字段 | 类型 | 说明 | +|------|------|------| +| plan_id | BIGINT NOT NULL | 培训计划 ID | +| user_id | BIGINT NOT NULL | 学员 ID | +| knowledge_id | BIGINT NOT NULL | 知识 ID | +| completed | TINYINT DEFAULT 0 | 0-未完成 / 1-已完成 | +| complete_time | DATETIME | 完成时间 | + +### 4.2 数据关联 + +- `tr_plan_knowledge.plan_id` → `tr_plan.id` +- `tr_plan_knowledge.knowledge_id` → `km_knowledge.id` +- `tr_plan_exam.plan_id` → `tr_plan.id` +- `tr_plan_exam.exam_id` → `ex_exam.id` +- `tr_plan_target` 多态关联:type=0→`sys_department.id`;type=1→`sys_group.id`;type=2→`sys_user.id` +- 进度计算同时读取 `km_knowledge_progress`(source=TRAINING, plan_id)和 `ex_exam_record`(passed=1) + +--- + +## 5. 对外接口 + +[SRC-API-01:4.5] [SRC-CODE-01:module/training/controller/] + +| API 名称 | 方法 | 路径 | 权限 | +|---------|------|------|------| +| 培训计划列表(讲师) | GET | /api/training | LECTURER/ADMIN | +| 创建培训计划 | POST | /api/training | LECTURER/ADMIN | +| 修改培训计划 | PUT | /api/training/{id} | LECTURER/ADMIN | +| 删除培训计划 | DELETE | /api/training/{id} | LECTURER/ADMIN | +| 关联知识 | POST | /api/training/{id}/knowledge | LECTURER/ADMIN | +| 关联考试 | POST | /api/training/{id}/exam | LECTURER/ADMIN | +| 分配对象 | POST | /api/training/{id}/target | LECTURER/ADMIN | +| 学员-培训列表 | GET | /api/student/training | STUDENT | +| 学员-培训详情 | GET | /api/student/training/{id} | STUDENT | + +--- + +## 6. 异常与边界处理 + +| 场景 | 处理方式 | +|------|---------| +| 培训计划结束后学员查看 | 状态=ENDED,页面展示历史进度和成绩,不可继续学习/考试 | +| 学员尚未被分配到某计划就访问 | 403:无权访问该培训计划 | +| 相关考试时间窗口已过,但培训计划仍进行中 | 考试显示"已结束",无法参加,该考试通过状态默认为"未通过"(评估影响进度) | +| 同一学员多次被添加(部门+个人双重覆盖) | tr_plan_target 可能有重复记录,进度计算时去重(按 user_id 聚合) | +| 培训计划关联的知识被删除 | tr_plan_knowledge 记录仍在,显示时标注"知识已下架" [ASSUMPTION] | +| 总必修项为 0 的计划 | 进度直接返回 100%(calculateProgress 中已处理:totalRequired=0 return 100)[SRC-API-01:5.4] | diff --git a/training/course-20260429-1000/materials/新建课程.png b/training/course-20260429-1000/materials/新建课程.png new file mode 100644 index 0000000..d27d161 Binary files /dev/null and b/training/course-20260429-1000/materials/新建课程.png differ diff --git a/training/course-20260429-1000/materials/课程列表.png b/training/course-20260429-1000/materials/课程列表.png new file mode 100644 index 0000000..26eee08 Binary files /dev/null and b/training/course-20260429-1000/materials/课程列表.png differ diff --git a/training/course-20260429-1000/materials/课程查看.png b/training/course-20260429-1000/materials/课程查看.png new file mode 100644 index 0000000..ee30785 Binary files /dev/null and b/training/course-20260429-1000/materials/课程查看.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/固定题目.png b/training/exam-question-bank-20260512-0900/materials/固定题目.png new file mode 100644 index 0000000..c6391b2 Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/固定题目.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/按权重抽题.png b/training/exam-question-bank-20260512-0900/materials/按权重抽题.png new file mode 100644 index 0000000..2afb909 Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/按权重抽题.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/新建试题.png b/training/exam-question-bank-20260512-0900/materials/新建试题.png new file mode 100644 index 0000000..d3aa6e4 Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/新建试题.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/试卷管理.png b/training/exam-question-bank-20260512-0900/materials/试卷管理.png new file mode 100644 index 0000000..310eafd Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/试卷管理.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/试题管理.png b/training/exam-question-bank-20260512-0900/materials/试题管理.png new file mode 100644 index 0000000..bdde711 Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/试题管理.png differ diff --git a/training/exam-question-bank-20260512-0900/materials/随机抽题.png b/training/exam-question-bank-20260512-0900/materials/随机抽题.png new file mode 100644 index 0000000..b78ddf3 Binary files /dev/null and b/training/exam-question-bank-20260512-0900/materials/随机抽题.png differ diff --git a/training/knowledge-resource-20260428-1000/materials/上传素材.png b/training/knowledge-resource-20260428-1000/materials/上传素材.png new file mode 100644 index 0000000..d0c62c8 Binary files /dev/null and b/training/knowledge-resource-20260428-1000/materials/上传素材.png differ diff --git a/training/knowledge-resource-20260428-1000/materials/素材库.png b/training/knowledge-resource-20260428-1000/materials/素材库.png new file mode 100644 index 0000000..a04e543 Binary files /dev/null and b/training/knowledge-resource-20260428-1000/materials/素材库.png differ diff --git a/training/knowledge-resource-20260428-1000/materials/素材查看.png b/training/knowledge-resource-20260428-1000/materials/素材查看.png new file mode 100644 index 0000000..7c9ed86 Binary files /dev/null and b/training/knowledge-resource-20260428-1000/materials/素材查看.png differ diff --git a/training/training-demand-plan-20260509-1430/materials/prototypes/问题.png b/training/training-demand-plan-20260509-1430/materials/prototypes/问题.png new file mode 100644 index 0000000..61f2eab Binary files /dev/null and b/training/training-demand-plan-20260509-1430/materials/prototypes/问题.png differ diff --git a/training/workspace/specs/PRD.md b/training/workspace/specs/PRD.md new file mode 100644 index 0000000..dcf2883 --- /dev/null +++ b/training/workspace/specs/PRD.md @@ -0,0 +1,881 @@ +# 道路救援企业培训系统 - 产品需求文档 (PRD) + +> 版本:V1.0.1 +> 更新日期:2026-01-13 +> 状态:已确认 + +--- + +## 版本更新记录 + +### V1.0.1 (2026-01-13) + +**【培训计划】多考试任务支持** + +| 变更项 | 变更前 | 变更后 | +|-------|-------|-------| +| 考试任务数量 | 最多关联1个考试 | 支持关联多个考试 | +| 考试属性 | 无 | 支持设置必考/选考 | +| 考试排序 | 无 | 支持自定义排序 | + +**功能说明:** +- 创建/编辑培训计划时,可添加多个考试任务 +- 每个考试可设置为"必考"或"选考" +- 学员端展示所有关联考试及通过状态 +- 列表页显示考试任务总数 + +**数据库变更:** +- 新增 `tr_plan_exam` 关联表 +- 移除 `tr_plan` 表的 `exam_id` 字段 +- ⚠️ 不兼容历史数据,需重新初始化数据库 + +--- + +### V1.0.0 (2026-01-08) + +- 初始版本发布 +- 包含:系统基础、人员管理、知识库、考题管理、试卷管理、考试管理、培训计划六大模块 + +--- + +## 一、核心目标 (Mission) + +**为道路救援企业打造一站式内部培训平台,通过知识沉淀、在线考核、培训管理三大核心能力,提升500名员工的专业技能水平和服务标准化程度。** + +--- + +## 二、用户画像 (Persona) + +| 角色 | 人数估算 | 核心痛点 | 使用场景 | +|------|----------|----------|----------| +| **管理员** | 3-5人 | 人员管理分散,培训效果难追踪 | 配置系统、管理组织架构、分配权限 | +| **讲师(技师)** | 30-50人 | 知识传承依赖口口相传,出题组卷效率低 | 上传知识文档、创建题库、发布考试 | +| **学员** | 450人左右 | 学习资料分散、考试不便、进度不清晰 | 查阅知识库、完成培训任务、参加考试 | + +--- + +## 三、版本规划 + +### V1:最小可行产品 (MVP) + +#### 模块一:系统基础与人员管理 + +| 功能 | 说明 | +|----------|-----------------------------| +| 企业微信授权登录 | 企业微信授权登录 | +| 组织架构管理 | 中心 → 部门(≤8)→ 小组(≤10/部门)三级结构 | +| 员工管理 | 管理员手动录入员工信息,分配角色(管理员/讲师/学员) | +| 权限控制 | 基于角色的权限隔离,数据按部门隔离 | + +#### 模块二:知识库(重点) + +| 功能 | 说明 | +|------|------| +| 分类目录 | 支持多级分类(如:安全规范 > 高速救援 > 操作手册)| +| 文档管理 | 支持上传 PDF/Word/Excel/PPT 等常见文档格式 | +| 视频管理 | 支持上传视频文件,在线播放 | +| 在线预览 | 文档、视频无需下载即可在线查看 | +| 状态管理 | 草稿 → 已发布 → 已下架 | +| 部门隔离 | 各部门只能查看本部门的知识内容 | + +#### 模块三:考题管理 + +| 功能 | 说明 | +|------|------| +| 题型支持 | 单选题、多选题、判断题 | +| 题目解析 | 每道题必须填写答案解析 | +| 题库分类 | 按分类/章节管理题目 | +| 状态管理 | 草稿 → 已发布 → 已下架 | +| 部门隔离 | 讲师只能管理本部门题库 | + +#### 模块四:试卷管理 + +| 功能 | 说明 | +|------|------| +| 手动组卷 | 讲师手动从题库选题组成试卷 | +| 自动组卷 | 设置规则(单选X题+多选Y题+判断Z题),系统随机抽题 | +| 试卷配置 | 设置总分、题目分值、考试时长 | +| 试卷预览 | 组卷完成后可预览试卷效果 | +| 状态管理 | 草稿 → 已发布 → 已下架 | + +#### 模块五:考试管理 + +| 功能 | 说明 | +|------|------| +| 发布考试 | 选择试卷,设置考试名称、时间窗口(开始~结束时间)| +| 指定对象 | 支持指定部门 / 小组 / 个人参加考试 | +| 考试规则 | 设置及格线、限制考试次数、考试时长 | +| 在线答题 | 学员在线作答,自动计时,超时自动交卷 | +| 成绩查看 | 交卷后立即显示成绩和答案解析 | + +#### 模块六:培训计划 + +| 功能 | 说明 | +|------|------| +| 创建培训计划 | 设置计划名称、培训周期、培训目标 | +| 关联知识/考试 | 一个培训计划可包含多个知识文档 + 多场考试(V1.0.1+) | +| 考试属性设置 | 每个考试可设置必考/选考、自定义排序(V1.0.1+) | +| 分配学员 | 指定部门/小组/个人参加培训计划 | +| 进度跟踪 | 学员可查看自己的学习进度和考试状态 | +| 计划状态 | 未开始 / 进行中 / 已结束 | + +### V2 及以后版本 (Future Releases) + +| 版本 | 功能 | 价值 | +|------|------|------| +| **V2** | 统计报表中心 | 培训完成率、考试通过率、部门排名等数据看板 | +| **V2** | 线下培训签到 | 扫码签到/签退,记录实际培训时长 | +| **V2** | 错题本 | 学员自动收集错题,巩固薄弱知识点 | +| **V3** | 知识库增强 | 收藏、点赞、评论、全文搜索 | +| **V3** | 奖励体系 | 积分、勋章、学习排行榜 | +| **V3** | 证书管理 | 培训/考试 +--- + +## 四、关键业务逻辑 (Business Rules) + +### 4.1 权限规则 + +``` +管理员: + ├── 管理所有中心、部门、小组 + ├── 管理所有用户(增删改查、角色分配) + ├── 查看全平台数据 + └── 系统配置 + +讲师: + ├── 管理本部门知识库(上传/编辑/删除) + ├── 管理本部门题库(增删改查) + ├── 创建/管理本部门试卷和考试 + ├── 创建/管理本部门培训计划 + └── 查看本部门学员成绩 + +学员: + ├── 查看本部门知识库(只读) + ├── 参加分配给自己的培训计划 + ├── 参加分配给自己的考试 + └── 查看个人学习记录和成绩 +``` + +### 4.2 内容状态流转 + +``` +┌─────────┐ 发布 ┌─────────┐ 下架 ┌─────────┐ +│ 草稿 │ ───────────▶ │ 已发布 │ ───────────▶ │ 已下架 │ +│ DRAFT │ │PUBLISHED │ │ OFFLINE │ +└─────────┘ └─────────┘ └─────────┘ + ▲ │ │ + │ │ 重新上架 │ + │ ◀────────────────────────┘ + │ + └──────── 可继续编辑 ─────────────────────────────┘ +``` + +| 状态 | 说明 | 学员可见 | +|------|------|----------| +| **草稿 (DRAFT)** | 讲师编辑中,未完成 | ❌ 不可见 | +| **已发布 (PUBLISHED)** | 正式生效,可被使用 | ✅ 可见 | +| **已下架 (OFFLINE)** | 临时隐藏,保留数据 | ❌ 不可见 | + +### 4.3 考试规则 + +| 规则 | 说明 | +|------|------| +| 时间窗口 | 只有在考试开放时间内才能进入考试 | +| 限时作答 | 超过考试时长自动交卷 | +| 限制次数 | 达到最大次数后不可再考,取最高分 | +| 及格判定 | 分数 ≥ 及格线 为通过 | +| 答题顺序 | 自动组卷时题目顺序随机打乱 | + +### 4.4 数据隔离规则 + +| 数据类型 | 隔离方式 | +|----------|----------| +| 知识库 | 按部门隔离,只能看本部门 | +| 题库 | 按部门隔离,讲师只能操作本部门 | +| 试卷/考试 | 按部门隔离 | +| 培训计划 | 按部门隔离 | +| 员工信息 | 管理员可见全部,讲师可见本部门 | + +### 4.5 引用保护规则 + +| 场景 | 规则 | +|------|------| +| 题目下架 | 已被试卷引用的题目,下架前需确认警告 | +| 试卷下架 | 已被考试引用的试卷,下架前需确认警告 | +| 知识下架 | 已被培训计划引用的知识,下架前需确认警告 | + +--- + +## 五、数据契约 (Data Contract) + +### 5.1 核心实体 + +#### 用户 (sys_user) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| wx_userid | String | 企业微信用户ID | +| real_name | String | 姓名 | +| phone | String | 手机号 | +| role | Enum | 角色:ADMIN/LECTURER/STUDENT | +| department_id | Long | 所属部门 | +| group_id | Long | 所属小组(可空) | +| status | Enum | 状态:ENABLED/DISABLED | +| create_time | DateTime | 创建时间 | + +#### 组织架构 (sys_center / sys_department / sys_group) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| name | String | 名称 | +| parent_id | Long | 上级ID(部门关联中心,小组关联部门) | +| sort_order | Integer | 排序 | +| create_time | DateTime | 创建时间 | + +#### 知识分类 (km_category) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| name | String | 分类名称 | +| parent_id | Long | 父分类ID | +| department_id | Long | 所属部门 | +| sort_order | Integer | 排序 | + +#### 知识 (km_knowledge) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| title | String | 标题 | +| category_id | Long | 所属分类 | +| type | Enum | 类型:DOCUMENT/VIDEO | +| file_url | String | 文件地址 | +| file_size | Long | 文件大小 | +| department_id | Long | 所属部门 | +| status | Enum | 状态:DRAFT/PUBLISHED/OFFLINE | +| creator_id | Long | 创建人 | +| create_time | DateTime | 创建时间 | +| publish_time | DateTime | 发布时间 | + +#### 题目 (ex_question) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| type | Enum | 题型:SINGLE/MULTIPLE/JUDGE | +| content | String | 题干 | +| options | JSON | 选项列表(判断题为空) | +| answer | String | 正确答案 | +| analysis | String | 解析 | +| category_id | Long | 所属分类 | +| department_id | Long | 所属部门 | +| status | Enum | 状态:DRAFT/PUBLISHED/OFFLINE | +| creator_id | Long | 创建人 | +| create_time | DateTime | 创建时间 | +| publish_time | DateTime | 发布时间 | + +#### 试卷 (ex_paper) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| title | String | 试卷名称 | +| total_score | Integer | 总分 | +| duration | Integer | 考试时长(分钟) | +| pass_score | Integer | 及格分 | +| department_id | Long | 所属部门 | +| status | Enum | 状态:DRAFT/PUBLISHED/OFFLINE | +| creator_id | Long | 创建人 | +| create_time | DateTime | 创建时间 | +| publish_time | DateTime | 发布时间 | + +#### 试卷题目关联 (ex_paper_question) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| paper_id | Long | 试卷ID | +| question_id | Long | 题目ID | +| score | Integer | 该题分值 | +| sort_order | Integer | 排序 | + +#### 考试 (ex_exam) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| title | String | 考试名称 | +| paper_id | Long | 关联试卷 | +| start_time | DateTime | 开始时间 | +| end_time | DateTime | 结束时间 | +| max_attempts | Integer | 最大考试次数 | +| department_id | Long | 所属部门 | +| status | Enum | 状态:NOT_STARTED/IN_PROGRESS/ENDED | +| creator_id | Long | 创建人 | +| create_time | DateTime | 创建时间 | + +#### 考试对象 (ex_exam_target) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| exam_id | Long | 考试ID | +| target_type | Enum | 对象类型:DEPARTMENT/GROUP/USER | +| target_id | Long | 对象ID | + +#### 考试记录 (ex_exam_record) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| exam_id | Long | 考试ID | +| user_id | Long | 学员ID | +| attempt_no | Integer | 第几次考试 | +| score | Integer | 得分 | +| passed | Boolean | 是否通过 | +| start_time | DateTime | 开始时间 | +| submit_time | DateTime | 提交时间 | +| answers | JSON | 答题详情 | + +#### 培训计划 (tr_plan) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| title | String | 计划名称 | +| description | String | 计划描述 | +| start_date | Date | 开始日期 | +| end_date | Date | 结束日期 | +| department_id | Long | 所属部门 | +| exam_id | Long | 关联考试(可空) | +| status | Enum | 状态:NOT_STARTED/IN_PROGRESS/ENDED | +| creator_id | Long | 创建人 | +| create_time | DateTime | 创建时间 | + +#### 培训计划-知识关联 (tr_plan_knowledge) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| plan_id | Long | 计划ID | +| knowledge_id | Long | 知识ID | +| required | Boolean | 是否必修 | +| sort_order | Integer | 排序 | + +#### 培训计划-对象 (tr_plan_target) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| plan_id | Long | 计划ID | +| target_type | Enum | 对象类型:DEPARTMENT/GROUP/USER | +| target_id | Long | 对象ID | + +#### 培训进度 (tr_plan_progress) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| plan_id | Long | 计划ID | +| user_id | Long | 学员ID | +| knowledge_id | Long | 知识ID | +| completed | Boolean | 是否完成 | +| complete_time | DateTime | 完成时间 | + +--- + +## 六、原型设计(方案A - 经典管理后台风格) + +### 6.1 设计理念 + +左侧固定导航 + 右侧内容区,层级分明,适合功能复杂的企业管理系统。学习成本低,用户容易上手。 + +### 6.2 布局结构 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 🚗 道路救援培训系统 🔔 消息 👤 张三 ▼ │ +├──────────────────┬─────────────────────────────────────────────────────────┤ +│ │ │ +│ 📊 工作台 │ 内容区域 │ +│ │ │ +│ 👥 人员管理 ▶ │ │ +│ ├ 组织架构 │ │ +│ ├ 员工管理 │ │ +│ └ 讲师管理 │ │ +│ │ │ +│ 📚 知识库 ▶ │ │ +│ ├ 知识分类 │ │ +│ └ 知识列表 │ │ +│ │ │ +│ ✏️ 考题管理 ▶ │ │ +│ ├ 题库分类 │ │ +│ └ 题目列表 │ │ +│ │ │ +│ 📄 试卷管理 │ │ +│ │ │ +│ 📝 考试管理 │ │ +│ │ │ +│ 📅 培训计划 │ │ +│ │ │ +│ ────────────── │ │ +│ ⚙️ 系统设置 │ │ +│ │ │ +└──────────────────┴─────────────────────────────────────────────────────────┘ +``` + +### 6.3 核心页面原型 + +#### 工作台首页 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 🚗 道路救援培训系统 🔔 消息 👤 张三 ▼ │ +├──────────────────┬─────────────────────────────────────────────────────────┤ +│ │ │ +│ 📊 工作台 ● │ 欢迎回来,张三 2026年1月8日 │ +│ │ │ +│ 👥 人员管理 ▶ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ │ 待学习课程 │ │ 待完成考试 │ │ 培训进度 │ │ +│ 📚 知识库 ▶ │ │ │ │ │ │ │ │ +│ │ │ 12 │ │ 3 │ │ 75% │ │ +│ ✏️ 考题管理 ▶ │ │ │ │ │ │ ████░░ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ 📄 试卷管理 │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ +│ 📝 考试管理 │ │ 我的培训计划 │ │ +│ │ ├──────────────────────────────────────────────────┤ │ +│ 📅 培训计划 │ │ 📋 2026年Q1安全规范培训 进行中 60% │ │ +│ │ │ 📋 新员工入职培训 进行中 30% │ │ +│ │ │ 📋 高速救援操作规范 未开始 -- │ │ +│ │ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ +│ │ │ 待完成考试 │ │ +│ │ ├──────────────────────────────────────────────────┤ │ +│ │ │ 📝 安全规范考核 截止: 1月15日 [进入考试] │ │ +│ │ │ 📝 月度技能测试 截止: 1月20日 [进入考试] │ │ +│ │ └──────────────────────────────────────────────────┘ │ +│ │ │ +└──────────────────┴─────────────────────────────────────────────────────────┘ +``` + +#### 知识库列表页 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 🚗 道路救援培训系统 🔔 消息 👤 张三 ▼ │ +├──────────────────┬─────────────────────────────────────────────────────────┤ +│ │ │ +│ 📊 工作台 │ 知识库 > 安全规范 │ +│ │ ───────────────────────────────────────────────── │ +│ 👥 人员管理 ▶ │ │ +│ │ [+ 新增知识] [所有状态 ▼] [🔍 搜索知识...] │ +│ 📚 知识库 ▼ │ │ +│ ┌ 知识分类 │ ┌────────────────────────────────────────────────┐ │ +│ └ 知识列表 ● │ │ 标题 类型 状态 更新时间 操作│ │ +│ │ ├────────────────────────────────────────────────┤ │ +│ ✏️ 考题管理 ▶ │ │ 高速救援SOP 📄文档 已发布 01-05 编辑│ │ +│ │ │ 安全操作视频 🎬视频 已发布 01-03 编辑│ │ +│ 📄 试卷管理 │ │ 应急处理流程 📄文档 草稿 01-02 编辑│ │ +│ │ │ 设备使用手册 📄文档 已下架 12-28 编辑│ │ +│ 📝 考试管理 │ └────────────────────────────────────────────────┘ │ +│ │ │ +│ 📅 培训计划 │ 共 24 条记录 < 1 2 3 4 5 > │ +│ │ │ +└──────────────────┴─────────────────────────────────────────────────────────┘ +``` + +#### 在线考试页 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 📝 2026年Q1安全规范考试 剩余时间: 45:23 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ 第 3 题 / 共 20 题 [单选题] │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ 高速公路救援作业时,警示标志应放置在故障车辆后方多少米处? │ │ +│ │ │ │ +│ │ ○ A. 50米 │ │ +│ │ ● B. 150米 │ │ +│ │ ○ C. 200米 │ │ +│ │ ○ D. 100米 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 答题卡: │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ ✓1 ✓2 ●3 ○4 ○5 ○6 ○7 ○8 ○9 ○10 │ │ +│ │ ○11 ○12 ○13 ○14 ○15 ○16 ○17 ○18 ○19 ○20 │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [< 上一题] [下一题 >] [交卷] │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +#### 试卷组卷页 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 🚗 道路救援培训系统 🔔 消息 👤 张三 ▼ │ +├──────────────────┬─────────────────────────────────────────────────────────┤ +│ │ │ +│ 📊 工作台 │ 创建试卷 > 第2步:选择题目 │ +│ │ ═══════════════════════════════════════════════════ │ +│ 👥 人员管理 ▶ │ ① 基本信息 ────── ② 选择题目 ────── ③ 预览确认 │ +│ │ ● │ +│ 📚 知识库 ▶ │ │ +│ │ ┌─────────────────────┐ ┌────────────────────────┐ │ +│ ✏️ 考题管理 ▶ │ │ 📂 题库 │ │ 📋 已选题目 (15) │ │ +│ │ │ ┌─────────────────┐ │ │ │ │ +│ 📄 试卷管理 ● │ │ │ [全选] 安全(48) │ │ │ 单选题 x10 30分 │ │ +│ │ │ │ ☑ 高速警示... │ │ │ 多选题 x3 15分 │ │ +│ 📝 考试管理 │ │ │ ☑ 夜间灯光... │ │ │ 判断题 x2 5分 │ │ +│ │ │ │ ☐ 拖车规范... │ │ │ ─────────────── │ │ +│ 📅 培训计划 │ │ └─────────────────┘ │ │ 总分: 50分 │ │ +│ │ │ │ │ │ │ +│ │ │ ── 或自动组卷 ── │ │ [自动组卷] │ │ +│ │ │ 单选: [10] 题 │ │ 单选: [10] 题 │ │ +│ │ │ 多选: [ 5] 题 │ │ 多选: [ 5] 题 │ │ +│ │ │ 判断: [ 5] 题 │ │ 判断: [ 5] 题 │ │ +│ │ │ [随机抽题] │ │ [确认随机抽取] │ │ +│ │ └─────────────────────┘ └────────────────────────┘ │ +│ │ │ +│ │ [上一步] [下一步:预览] │ +└──────────────────┴─────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、架构设计蓝图 + +### 7.1 系统架构总览 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 客户端层 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ PC浏览器 │ │ 企业微信H5 │ │ 移动端浏览器 │ │ +│ │ (管理员/讲师) │ │ (学员) │ │ (学员) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +└───────────┼─────────────────────┼─────────────────────┼─────────────────┘ + │ │ │ + └─────────────────────┼─────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 网关层 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Spring Boot 应用 │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ JWT认证 │ │ 权限拦截 │ │ 日志记录 │ │ 异常处理 │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 业务层 │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ 认证模块 │ │ 组织模块 │ │ 知识库模块│ │ 考试模块 │ │ 培训模块 │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ ·企微登录 │ │ ·组织架构 │ │ ·分类管理 │ │ ·题库管理 │ │ ·计划管理 │ │ +│ │ ·Token管理│ │ ·员工管理 │ │ ·知识CRUD │ │ ·试卷管理 │ │ ·任务分配 │ │ +│ │ ·权限校验 │ │ ·角色管理 │ │ ·文件上传 │ │ ·考试管理 │ │ ·进度跟踪 │ │ +│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ MySQL 8.0 │ │ 文件存储服务 │ │ +│ │ (MyBatis Plus ORM) │ │ (本地/OSS/MinIO) │ │ +│ └─────────────────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 核心流程图 + +#### 用户登录流程(企业微信OAuth) + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端页面 + participant BE as 后端服务 + participant WX as 企业微信API + participant DB as 数据库 + + U->>FE: 点击"企业微信登录" + FE->>WX: 跳转企微授权页面 + WX->>U: 显示授权确认 + U->>WX: 确认授权 + WX->>FE: 回调返回code + FE->>BE: 携带code请求登录 + BE->>WX: 用code换取access_token + WX-->>BE: 返回access_token + BE->>WX: 获取用户信息(userid) + WX-->>BE: 返回用户信息 + BE->>DB: 查询用户是否存在 + alt 用户存在 + DB-->>BE: 返回用户信息 + BE->>BE: 生成JWT Token + BE-->>FE: 返回Token+用户信息 + FE->>FE: 存储Token,跳转工作台 + else 用户不存在 + BE-->>FE: 返回错误:用户未录入 + FE->>U: 提示联系管理员 + end +``` + +#### 在线考试流程 + +```mermaid +sequenceDiagram + participant S as 学员 + participant FE as 前端页面 + participant BE as 后端服务 + participant DB as 数据库 + + S->>FE: 进入考试列表 + FE->>BE: 获取我的考试列表 + BE->>DB: 查询分配给该学员的考试 + DB-->>BE: 返回考试列表 + BE-->>FE: 返回考试列表(含剩余次数) + + S->>FE: 点击"开始考试" + FE->>BE: 请求开始考试 + BE->>DB: 校验考试状态/次数/时间窗口 + alt 校验通过 + BE->>DB: 创建考试记录(开始时间) + BE->>DB: 获取试卷题目 + DB-->>BE: 返回题目列表 + BE-->>FE: 返回试卷内容+考试时长 + FE->>FE: 启动倒计时 + + loop 答题过程 + S->>FE: 选择/修改答案 + FE->>FE: 本地暂存答案 + FE->>BE: 定时自动保存(每30秒) + BE->>DB: 更新答题记录 + end + + alt 主动交卷 + S->>FE: 点击"交卷" + else 时间到 + FE->>FE: 倒计时结束 + end + + FE->>BE: 提交试卷 + BE->>BE: 自动判分(客观题) + BE->>DB: 保存成绩+答案详情 + BE-->>FE: 返回考试成绩 + FE->>S: 显示成绩+答案解析 + else 校验失败 + BE-->>FE: 返回错误(次数用尽/不在时间窗口) + FE->>S: 提示无法参加考试 + end +``` + +#### 培训计划执行流程 + +```mermaid +flowchart TB + subgraph 讲师操作 + A[创建培训计划] --> B[设置基本信息] + B --> C[关联知识内容] + C --> D[关联考试-可选] + D --> E[指定培训对象] + E --> F[发布培训计划] + end + + subgraph 学员学习 + F --> G[学员收到培训通知] + G --> H[进入培训计划] + H --> I[学习知识内容] + I --> J{知识学完?} + J -->|否| I + J -->|是| K{有关联考试?} + K -->|否| L[培训完成] + K -->|是| M[参加考试] + M --> N{考试通过?} + N -->|是| L + N -->|否| O{还有考试次数?} + O -->|是| M + O -->|否| P[培训未通过] + end + + subgraph 进度追踪 + I --> Q[更新学习进度] + M --> R[记录考试成绩] + L --> S[标记培训完成] + end +``` + +### 7.3 技术选型 + +| 层次 | 技术 | 版本 | 说明 | +|------|------|------|------| +| **构建** | Maven | 3.8+ | 依赖管理 | +| **框架** | Spring Boot | 3.1.2 | 主框架 | +| **ORM** | MyBatis Plus | 3.5.3+ | 简化CRUD | +| **数据库** | MySQL | 8.0 | 主数据库 | +| **认证** | java-jwt | 4.4+ | JWT Token生成验证 | +| **加密** | Spring Security | 6.x | BCrypt密码加密 | +| **文档** | Springdoc OpenAPI | 2.2+ | API文档 | +| **工具** | Lombok | 1.18+ | 简化代码 | +| **JSON** | Jackson | 内置 | JSON序列化 | +| **文件** | 本地存储 / MinIO | - | V1先用本地,V2可扩展 | +| **前端** | HTML + CSS + JS | - | 原生技术栈 | + +#### 关键依赖库 + +| 用途 | 库 | 说明 | +|------|-----|------| +| 企业微信SDK | weixin-java-cp | 企业微信Java SDK | +| 文件预览 | kkFileView / OnlyOffice | 文档在线预览(可选外部服务) | +| 视频播放 | video.js | 前端视频播放器 | +| Excel导入导出 | EasyExcel | 阿里开源,性能好 | +| 工具库 | Hutool | 常用工具集合 | + +### 7.4 风险评估 + +| 风险点 | 等级 | 描述 | 应对策略 | +|--------|------|------|----------| +| **企业微信对接** | 🟡 中 | 需要企业微信管理员配置应用 | 提前准备配置文档,预留账号密码登录作为备选 | +| **文件预览** | 🟡 中 | 不同格式文档预览兼容性 | V1优先支持PDF+图片+视频,复杂格式提示下载 | +| **大文件上传** | 🟡 中 | 视频文件可能较大 | 限制单文件大小,支持分片上传 | +| **考试并发** | 🟢 低 | 500人规模并发压力不大 | 合理设计索引,必要时加缓存 | +| **数据隔离** | 🟢 低 | 部门数据隔离逻辑 | 统一在Service层实现隔离过滤 | + +### 7.5 项目结构 + +``` +training-system/ +├── pom.xml +├── src/ +│ └── main/ +│ ├── java/com/sino/training/ +│ │ ├── TrainingApplication.java # 启动类 +│ │ │ +│ │ ├── common/ # 通用模块 +│ │ │ ├── config/ # 配置类 +│ │ │ ├── exception/ # 异常处理 +│ │ │ ├── result/ # 统一响应 +│ │ │ ├── interceptor/ # 拦截器 +│ │ │ └── utils/ # 工具类 +│ │ │ +│ │ ├── module/ # 业务模块 +│ │ │ ├── auth/ # 认证模块 +│ │ │ ├── system/ # 系统管理 +│ │ │ ├── knowledge/ # 知识库模块 +│ │ │ ├── exam/ # 考试模块 +│ │ │ └── training/ # 培训模块 +│ │ │ +│ │ └── integration/ # 外部集成 +│ │ └── wechat/ # 企业微信 +│ │ +│ └── resources/ +│ ├── application.yml # 主配置 +│ ├── application-dev.yml # 开发环境 +│ ├── application-prod.yml # 生产环境 +│ ├── mapper/ # MyBatis XML +│ ├── static/ # 静态资源 +│ └── templates/ # 页面模板 +│ +├── docs/ +│ └── PRD.md # 产品需求文档 +│ +└── sql/ + └── init.sql # 数据库初始化脚本 +``` + +--- + +## 八、附录 + +### 8.1 角色菜单权限对照表 + +| 菜单 | 管理员 | 讲师 | 学员 | +|------|--------|------|------| +| 工作台 | ✅ | ✅ | ✅ | +| 组织架构管理 | ✅ | ❌ | ❌ | +| 员工管理 | ✅ | 👁️ 本部门只读 | ❌ | +| 讲师管理 | ✅ | ❌ | ❌ | +| 知识库-分类管理 | ✅ | ✅ 本部门 | ❌ | +| 知识库-知识列表 | ✅ | ✅ 本部门 | 👁️ 本部门已发布 | +| 考题管理-题库分类 | ✅ | ✅ 本部门 | ❌ | +| 考题管理-题目列表 | ✅ | ✅ 本部门 | ❌ | +| 试卷管理 | ✅ | ✅ 本部门 | ❌ | +| 考试管理 | ✅ | ✅ 本部门 | 👁️ 我的考试 | +| 培训计划 | ✅ | ✅ 本部门 | 👁️ 我的培训 | +| 系统设置 | ✅ | ❌ | ❌ | + +### 8.2 状态枚举定义 + +```java +// 内容状态 +public enum ContentStatus { + DRAFT, // 草稿 + PUBLISHED, // 已发布 + OFFLINE // 已下架 +} + +// 用户角色 +public enum UserRole { + ADMIN, // 管理员 + LECTURER, // 讲师 + STUDENT // 学员 +} + +// 用户状态 +public enum UserStatus { + ENABLED, // 启用 + DISABLED // 禁用 +} + +// 题目类型 +public enum QuestionType { + SINGLE, // 单选题 + MULTIPLE, // 多选题 + JUDGE // 判断题 +} + +// 知识类型 +public enum KnowledgeType { + DOCUMENT, // 文档 + VIDEO // 视频 +} + +// 考试/培训目标类型 +public enum TargetType { + DEPARTMENT, // 部门 + GROUP, // 小组 + USER // 个人 +} + +// 考试状态 +public enum ExamStatus { + NOT_STARTED, // 未开始 + IN_PROGRESS, // 进行中 + ENDED // 已结束 +} + +// 培训计划状态 +public enum PlanStatus { + NOT_STARTED, // 未开始 + IN_PROGRESS, // 进行中 + ENDED // 已结束 +} +``` + +--- + +**文档状态:已确认,等待开发启动** diff --git a/training/workspace/specs/V2-StatisticsCenter.md b/training/workspace/specs/V2-StatisticsCenter.md new file mode 100644 index 0000000..9944e94 --- /dev/null +++ b/training/workspace/specs/V2-StatisticsCenter.md @@ -0,0 +1,554 @@ +# V2 统计报表中心 - 产品需求文档 + +> 版本:V2.0.0-draft +> 作者:Product Agent +> 日期:2026-02-04 +> 状态:待评审 + +--- + +## 一、功能概述 + +### 1.1 背景与目标 + +**背景**:V1 版本完成了培训系统的基础功能,但管理层无法量化培训效果,讲师无法追踪学员学习情况,缺乏数据支撑决策。 + +**目标**:构建统计报表中心,让培训效果可量化、可追踪、可对比。 + +### 1.2 目标用户 + +| 角色 | 核心诉求 | +|------|----------| +| **管理员** | 全局数据概览,跨部门对比,发现问题部门 | +| **讲师** | 本部门数据,学员学习进度,考试成绩分布 | +| **学员** | 个人学习记录,成绩趋势,与平均水平对比 | + +### 1.3 功能边界 + +| 范围 | 说明 | +|------|------| +| **包含** | 数据看板、培训统计、考试统计、学员统计、数据导出 | +| **不包含** | 实时监控、预测分析、自定义报表设计器 | + +--- + +## 二、功能清单 + +### 2.1 模块总览 + +``` +统计报表中心 +├── 2.1 数据概览看板 +│ ├── 关键指标卡片 +│ ├── 趋势图表 +│ └── 快捷入口 +│ +├── 2.2 培训统计 +│ ├── 培训计划完成率 +│ ├── 知识学习统计 +│ └── 部门培训排名 +│ +├── 2.3 考试统计 +│ ├── 考试通过率 +│ ├── 成绩分布 +│ └── 题目正确率分析 +│ +├── 2.4 学员统计 +│ ├── 学员学习排行 +│ ├── 个人学习报告 +│ └── 学习时长统计 +│ +└── 2.5 数据导出 + ├── Excel 导出 + └── 定期报表 +``` + +--- + +## 三、详细需求 + +### 3.1 数据概览看板 + +#### 3.1.1 功能描述 + +为不同角色提供个性化的数据概览首页,一目了然掌握关键指标。 + +#### 3.1.2 管理员看板 + +**关键指标卡片(4个)**: + +| 指标 | 计算方式 | 对比 | +|------|----------|------| +| 总学员数 | 状态=启用的学员数量 | 较上月增减 | +| 本月培训完成率 | 本月已完成培训人次 / 本月应完成培训人次 | 较上月变化 | +| 本月考试通过率 | 本月通过考试人次 / 本月参考人次 | 较上月变化 | +| 活跃学员数 | 本月有学习行为的学员数 | 较上月增减 | + +**趋势图表**: +- 近6个月培训完成率趋势(折线图) +- 近6个月考试通过率趋势(折线图) +- 部门培训完成率排名(横向柱状图,TOP 8) + +**快捷入口**: +- 查看详细培训报表 +- 查看详细考试报表 +- 导出月度汇总 + +#### 3.1.3 讲师看板 + +**关键指标卡片(4个)**: + +| 指标 | 范围 | +|------|------| +| 本部门学员数 | 本部门 | +| 本部门培训完成率 | 本部门本月 | +| 本部门考试通过率 | 本部门本月 | +| 待批阅数 | 如有主观题(V2暂无) | + +**趋势图表**: +- 本部门近6个月培训完成率趋势 +- 本部门学员学习进度分布(饼图:已完成/进行中/未开始) + +#### 3.1.4 学员看板 + +**关键指标卡片(4个)**: + +| 指标 | 说明 | +|------|------| +| 我的培训进度 | 已完成/总计划数 | +| 我的考试成绩 | 最近一次考试分数 | +| 学习排名 | 在本部门的排名 | +| 累计学习时长 | 总学习时长 | + +**图表**: +- 我的成绩趋势(近5次考试) +- 与部门平均分对比 + +#### 3.1.5 验收标准 + +```gherkin +Feature: 数据概览看板 + +Scenario: 管理员查看全局看板 + Given 我是管理员角色 + When 我进入统计报表中心 + Then 我应该看到4个关键指标卡片 + And 我应该看到培训完成率趋势图 + And 我应该看到部门排名图表 + And 所有数据应反映全平台数据 + +Scenario: 讲师查看部门看板 + Given 我是讲师角色 + When 我进入统计报表中心 + Then 所有数据应仅包含本部门数据 + And 我不应该看到其他部门的数据 + +Scenario: 指标卡片显示环比变化 + Given 当前月份培训完成率为 80% + And 上月培训完成率为 75% + When 我查看培训完成率卡片 + Then 应显示 "+5%" 的环比增长标记 + And 增长应显示为绿色 +``` + +--- + +### 3.2 培训统计 + +#### 3.2.1 培训计划完成率 + +**筛选条件**: +- 时间范围:本月/本季度/本年度/自定义 +- 部门:全部/指定部门(管理员可选) +- 培训计划:全部/指定计划 + +**统计维度**: + +| 维度 | 指标 | +|------|------| +| 按计划 | 计划名称、应参加人数、实际完成人数、完成率 | +| 按部门 | 部门名称、计划数、完成率、排名 | +| 按时间 | 月度完成率趋势 | + +**列表展示**: + +| 培训计划 | 部门 | 应参加 | 已完成 | 完成率 | 状态 | +|----------|------|--------|--------|--------|------| +| 2026Q1安全培训 | 救援一部 | 50 | 45 | 90% | 进行中 | +| 新员工入职培训 | 救援二部 | 20 | 20 | 100% | 已结束 | + +#### 3.2.2 知识学习统计 + +**统计内容**: +- 知识总数、已发布数 +- 学习总人次 +- 热门知识 TOP 10(按学习人次) +- 最少学习知识(提醒优化或下架) + +#### 3.2.3 验收标准 + +```gherkin +Feature: 培训统计 + +Scenario: 按部门查看培训完成率 + Given 我是管理员 + When 我选择按部门维度查看 + Then 应显示所有部门的培训完成率列表 + And 列表应按完成率降序排列 + And 应显示部门排名 + +Scenario: 筛选指定时间范围 + Given 我在培训统计页面 + When 我选择时间范围为 "2026年1月" + Then 所有数据应仅包含该月份的培训数据 + +Scenario: 导出培训报表 + Given 我在培训统计页面 + When 我点击导出按钮 + Then 应下载 Excel 文件 + And 文件应包含当前筛选条件下的所有数据 +``` + +--- + +### 3.3 考试统计 + +#### 3.3.1 考试通过率 + +**筛选条件**: +- 时间范围:本月/本季度/本年度/自定义 +- 部门:全部/指定部门 +- 考试:全部/指定考试 + +**统计指标**: + +| 指标 | 说明 | +|------|------| +| 参考人数 | 实际参加考试的人数 | +| 通过人数 | 分数 >= 及格线的人数 | +| 通过率 | 通过人数 / 参考人数 | +| 平均分 | 所有考生的平均分数 | +| 最高分 / 最低分 | 分数极值 | + +#### 3.3.2 成绩分布 + +**图表展示**: +- 分数段分布(柱状图):0-59 / 60-69 / 70-79 / 80-89 / 90-100 +- 各部门平均分对比(横向柱状图) + +**列表展示**: + +| 考试名称 | 参考人数 | 通过率 | 平均分 | 最高分 | 最低分 | +|----------|----------|--------|--------|--------|--------| +| 安全规范考核 | 120 | 85% | 78.5 | 98 | 42 | +| 月度技能测试 | 95 | 72% | 71.2 | 100 | 35 | + +#### 3.3.3 题目正确率分析 + +**功能描述**:分析每道题的正确率,发现难题和易错题。 + +**统计内容**: + +| 字段 | 说明 | +|------|------| +| 题目内容 | 题干摘要(前50字) | +| 题型 | 单选/多选/判断 | +| 作答人次 | 该题被作答的总次数 | +| 正确率 | 正确人次 / 作答人次 | +| 错误选项分布 | 各错误选项的选择占比 | + +**排序**: +- 默认按正确率升序(易错题优先) +- 可切换按作答人次排序 + +**用途**: +- 正确率 < 30% 的题目:可能题目有问题或知识点难度大 +- 正确率 > 95% 的题目:可能过于简单 + +#### 3.3.4 验收标准 + +```gherkin +Feature: 考试统计 + +Scenario: 查看考试成绩分布 + Given 我在考试统计页面 + When 我选择某场考试 + Then 应显示分数段分布柱状图 + And 应显示通过率、平均分等指标 + +Scenario: 查看题目正确率 + Given 我在考试统计页面 + When 我点击 "题目分析" + And 选择某场考试 + Then 应显示该考试所有题目的正确率 + And 默认按正确率升序排列 + And 可以查看每道题的错误选项分布 + +Scenario: 识别易错题 + Given 某道题的正确率为 25% + When 我查看题目分析列表 + Then 该题应标记为 "易错题" + And 应显示主要错误选项 +``` + +--- + +### 3.4 学员统计 + +#### 3.4.1 学员学习排行 + +**排行维度**: +- 学习时长排行(本月/本季度) +- 考试成绩排行(平均分/最高分) +- 培训完成数排行 + +**展示内容**: + +| 排名 | 学员 | 部门 | 学习时长 | 完成培训数 | 平均分 | +|------|------|------|----------|------------|--------| +| 1 | 张三 | 救援一部 | 45h | 8 | 92 | +| 2 | 李四 | 救援二部 | 42h | 7 | 88 | + +**范围控制**: +- 管理员:可查看全平台排行 +- 讲师:仅可查看本部门排行 +- 学员:可查看本部门排行,自己会高亮显示 + +#### 3.4.2 个人学习报告 + +**入口**: +- 讲师点击学员姓名进入 +- 学员点击"我的学习报告"进入 + +**报告内容**: + +| 模块 | 内容 | +|------|------| +| 基本信息 | 姓名、部门、入职时间、角色 | +| 学习概览 | 累计学习时长、完成培训数、参加考试数 | +| 培训记录 | 参加的培训计划列表,包含进度和状态 | +| 考试记录 | 参加的考试列表,包含成绩和是否通过 | +| 成绩趋势 | 近10次考试成绩折线图 | +| 能力雷达图 | 按知识分类的掌握程度(基于考试正确率) | + +#### 3.4.3 学习时长统计 + +**统计规则**: +- 知识学习:从打开到关闭/切换的时长(上限30分钟/次) +- 视频学习:实际播放时长 +- 考试时长:从开始到交卷的时长 + +**展示**: +- 个人:日/周/月学习时长统计 +- 部门:部门平均学习时长、学习时长分布 + +#### 3.4.4 验收标准 + +```gherkin +Feature: 学员统计 + +Scenario: 查看学习排行榜 + Given 我是讲师 + When 我进入学员排行榜页面 + Then 应显示本部门学员排行 + And 不应显示其他部门学员 + +Scenario: 查看个人学习报告 + Given 我是讲师 + When 我点击某学员的姓名 + Then 应进入该学员的学习报告页面 + And 应显示该学员的培训记录 + And 应显示该学员的考试记录 + And 应显示成绩趋势图 + +Scenario: 学员查看自己的报告 + Given 我是学员 + When 我点击 "我的学习报告" + Then 应显示我的学习报告 + And 应显示我在部门中的排名 +``` + +--- + +### 3.5 数据导出 + +#### 3.5.1 Excel 导出 + +**支持导出的报表**: + +| 报表 | 内容 | 权限 | +|------|------|------| +| 培训完成率报表 | 按计划/部门的完成率明细 | 管理员、讲师 | +| 考试成绩报表 | 学员成绩明细 | 管理员、讲师 | +| 学员学习报表 | 学员学习时长和完成情况 | 管理员、讲师 | +| 题目分析报表 | 题目正确率明细 | 管理员、讲师 | + +**导出规则**: +- 导出当前筛选条件下的数据 +- 文件名格式:`报表类型_日期_导出人.xlsx` +- 单次导出上限:10000 条记录 + +#### 3.5.2 验收标准 + +```gherkin +Feature: 数据导出 + +Scenario: 导出考试成绩报表 + Given 我在考试统计页面 + And 当前筛选条件为 "2026年1月" + When 我点击导出按钮 + Then 应下载 Excel 文件 + And 文件名应为 "考试成绩报表_20260204_张三.xlsx" + And 数据应仅包含2026年1月的考试成绩 + +Scenario: 导出数据量限制 + Given 当前筛选条件下有 15000 条记录 + When 我点击导出按钮 + Then 应提示 "数据量超过限制,请缩小筛选范围" +``` + +--- + +## 四、数据模型(建议) + +### 4.1 新增统计表(可选,用于性能优化) + +#### 日统计汇总表 (stat_daily_summary) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| stat_date | Date | 统计日期 | +| department_id | Long | 部门ID(0=全平台) | +| total_users | Integer | 学员总数 | +| active_users | Integer | 活跃学员数 | +| training_complete_count | Integer | 培训完成人次 | +| exam_pass_count | Integer | 考试通过人次 | +| exam_total_count | Integer | 考试参与人次 | +| total_learning_minutes | Long | 总学习时长(分钟) | + +#### 学习时长记录表 (stat_learning_log) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| user_id | Long | 用户ID | +| knowledge_id | Long | 知识ID | +| start_time | DateTime | 开始时间 | +| end_time | DateTime | 结束时间 | +| duration_seconds | Integer | 学习时长(秒) | +| create_time | DateTime | 创建时间 | + +--- + +## 五、页面原型 + +### 5.1 管理员数据看板 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 统计报表中心 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ 总学员数 │ │ 培训完成率 │ │ 考试通过率 │ │ 活跃学员数 │ │ +│ │ 486 │ │ 82% │ │ 78% │ │ 312 │ │ +│ │ +12 ↑ │ │ +5% ↑ │ │ -2% ↓ │ │ +28 ↑ │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ 培训完成率趋势 │ │ 部门培训完成率排名 │ │ +│ │ │ │ │ │ +│ │ 100%│ │ │ 救援一部 ████████████ 92% │ │ +│ │ 80%│ ╭──╮ ╭──╮ │ │ 救援三部 ██████████ 85% │ │ +│ │ 60%│╭──╯ ╰──╮╭╯ ╰── │ │ 救援二部 █████████ 80% │ │ +│ │ 40%│ ╰ │ │ 救援四部 ███████ 72% │ │ +│ │ └───────────────── │ │ 综合部 ██████ 65% │ │ +│ │ 9 10 11 12 1 2 │ │ │ │ +│ └─────────────────────────────────┘ └─────────────────────────────────┘ │ +│ │ +│ [查看培训详情] [查看考试详情] [导出月度报表] │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 考试统计页面 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 统计报表中心 > 考试统计 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 时间范围: [本月 ▼] 部门: [全部 ▼] 考试: [全部 ▼] [查询] [导出] │ +│ │ +│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ 成绩分布 │ │ 汇总指标 │ │ +│ │ │ │ │ │ +│ │ ┌───┐ │ │ 参考人数: 320 │ │ +│ │ │ │ ┌───┐ │ │ 通过人数: 256 │ │ +│ │ ┌───┐│ │ │ │ ┌───┐ │ │ 通过率: 80% │ │ +│ │ │ ││ │ │ │ │ │ ┌───┐ │ │ 平均分: 76.5 │ │ +│ │ │ ││ │ │ │ │ │ │ │ │ │ 最高分: 100 │ │ +│ │ └───┘└───┘ └───┘ └───┘ └───┘ │ │ 最低分: 32 │ │ +│ │ 0-59 60-69 70-79 80-89 90-100 │ │ │ │ +│ └─────────────────────────────────┘ └─────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ 考试名称 参考人数 通过率 平均分 最高分 操作 │ │ +│ ├──────────────────────────────────────────────────────────────────────┤ │ +│ │ 安全规范考核 120 85% 78.5 98 [详情][分析] │ │ +│ │ 月度技能测试 95 72% 71.2 100 [详情][分析] │ │ +│ │ 新员工入职考试 45 91% 82.3 96 [详情][分析] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ < 1 2 3 > │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、非功能需求 + +| 类型 | 要求 | +|------|------| +| **性能** | 看板页面加载时间 < 2秒 | +| **性能** | 报表查询响应时间 < 3秒(万级数据) | +| **兼容性** | 支持 Chrome、Edge、Firefox 最新版本 | +| **数据安全** | 严格按角色和部门隔离数据 | +| **导出** | Excel 导出支持 .xlsx 格式 | + +--- + +## 七、不做什么(Out of Scope) + +| 排除项 | 原因 | +|--------|------| +| 实时数据监控 | V2 不需要实时性,T+1 统计即可 | +| 自定义报表设计器 | 复杂度高,用户需求不明确 | +| 数据预测/AI分析 | 数据量不足,价值有限 | +| 打印报表 | 使用 Excel 导出替代 | + +--- + +## 八、里程碑建议 + +| 阶段 | 内容 | 建议周期 | +|------|------|----------| +| M1 | 数据看板(管理员+讲师+学员) | - | +| M2 | 培训统计 + 考试统计 | - | +| M3 | 学员统计 + 数据导出 | - | +| M4 | 测试 + 优化 | - | + +--- + +## 九、开放问题 + +| 问题 | 待确认 | +|------|--------| +| 学习时长统计是否需要精确到秒级? | 建议分钟级即可 | +| 是否需要支持定时自动发送报表邮件? | 建议 V3 考虑 | +| 能力雷达图的维度如何定义? | 建议按知识分类一级目录 | + +--- + +**文档状态:待评审** diff --git a/training/workspace/specs/course-km-arch-20260430-1344/01_input/prototype.html b/training/workspace/specs/course-km-arch-20260430-1344/01_input/prototype.html new file mode 100644 index 0000000..d6f2021 --- /dev/null +++ b/training/workspace/specs/course-km-arch-20260430-1344/01_input/prototype.html @@ -0,0 +1,1213 @@ + + + + + +课程管理系统 — 原型图 + + + + + +
+ + 首页 + 知识库 + 培训计划 + 题库管理 + 统计报表 + 系统设置 +
+ 👤 张讲师 + 讲师 +
+
+ + +
+ + + + +
+ + +
+
+
+ + +
+
课程分类
+ +
📁 全部
+
📂 中道内训师培训课程
+
└ 内训师基础技能
+
└ 课程开发方法
+
📂 中道员工在岗培训
+
└ 新员工入职培训
+
└ 岗位技能强化
+
📂 中道新人培训
+
📂 中道新人入组OJT培训
+
📂 专业技能
+
📂 中道救援车辆技术专业...
+
📂 中道员工通用胜任力培...
+
📂 通用课件库
+
+ + +
+ +
+
+
+ +
+
所属部门
+ +
状态
+ + + +
+
+
创建时间
+ + + +
+
+ + +
+
+ + + + 共 18 条 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称资源分类难度学习类型所属部门可见范围状态学习人数完成率操作
解锁企业数字化学习的N个可能性
🎬 视频  |  2026-03-15
行政管理初级必修课中道救援全公司已发布127 +
68%
+
+
+ + + +
中基层领导者十年领导力解码
🎬 视频  |  2026-03-10
人力资源规划中级不指定中道救援全公司已发布89 +
45%
+
+
+ + + +
如何制定24年企业年度培训计划
📄 文档  |  2026-02-28
人力资源规划初级必修课中道救援本部门已发布56 +
82%
+
+
+ + + +
救援车辆基础维修技能实训
🎵 音频  |  2026-02-20
专业技能高级选修课技术部指定草稿0 +
+
+ + + +
新员工入职流程图解
🖼️ 图片  |  2026-01-15
通用管理初级必修课人力资源部全公司已下架43 +
91%
+
+
+ + + +
+ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+

基础信息

+
+
+ + +
0 / 200
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ +
+ +
+ +
+
张三 ×
+
李四 ×
+
+
+
从系统讲师库中选择,可多选
+
+ +
+ +
+ + +
+
+
救援技能 ×
+
新员工 ×
+
必备培训 ×
+
+
+ +
+ + +
+
+
+ + +
+

课件列表

支持拖拽排序
+
+
+
+ + + ⓘ 支持 mp4/avi/mov/flv/mp3/wav/pdf/doc/docx/jpg/png 等格式,单文件最大 2GB +
+
+ +
🎬
+
+
解锁企业数字化学习的N个可能性(理论篇)
+
视频  |  00:34:57  |  已上传
+
+ + +
+
+ +
🎬
+
+
解锁企业数字化学习的N个可能性(实践篇)
+
视频  |  00:31:38  |  已上传
+
+ + +
+
+ +
📄
+
+
课程配套学习手册.pdf
+
文档  |  2.4MB  |  已上传
+
+ + +
+
+
+
+ + +
+

课程封面

+
+
+
+
尺寸建议 750×422,限制 2MB 以内,最多可上传 12 张封面
+
+
+上传封面
+
📋
+
💻
+
📊
+
🎯
+
🚗
+
🔧
+
+
+
+ + +
+
+
+
+ + +
+

学习规则

+
+
+
+ +
+ + +
+
ALL:所有课件均达到完成标准;ANY:任意 1 个课件达到完成标准
+
+
+ +
+ + +
+
+
+
+
+ + +
+
+ + +
仅对文档类课件生效,留空使用系统自动估算(≈每100KB 1分钟,最多50分钟)
+
+
+
+
+ + +
+

可见范围

+
+
+
+ + + +
+
+ +
本部门:仅所属部门员工可见;全公司:所有员工可见;指定范围:精确到部门/小组/学员
+
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ + + + + 课程封面 + +
+
+

解锁企业数字化学习的N个可能性

+
+
★★★★★ 4.8 分(共 42 条评价)
+ 必修课 + 初级 + 原创 +
+
+
+ 讲师 + 平台讲师 +
+
+ 课程类型 + 平台课 +
+
+ 有效期至 + 2026-12-03 +
+
+ 课件数 + 2 +
+
+ 学习人数 + 127 +
+
+ 奖励学分 + 🏆 50 分 +
+
+
+ + + +
+
+
+
+ +
+
+ +
+
+ + + + +
+
+ + +
+
完成要求:全部课件完成  |  当前进度:1/2 课件
+
+
1
+
🎬
+
+
解锁企业数字化学习的N个可能性(理论篇)
+
00:34:57  |  已完成 ✅
+
+
+ +
+
+
2
+
🎬
+
+
解锁企业数字化学习的N个可能性(实践篇)
+
00:31:38  |  学习中 ···
+
+
已学习 43%,断点续播
+
+ +
+
+ + +
+
+

本课程系统梳理了企业数字化转型背景下,学习与发展(L&D)从业者面临的核心挑战与机遇。

+

主讲人结合10余年企业培训实践经验,从以下维度展开讲授:

+
    +
  • 数字化学习生态的底层逻辑与驱动力
  • +
  • 混合式学习设计(HLD)的核心方法论
  • +
  • 学习数据分析与效果评估框架
  • +
  • AI 辅助内容生成与个性化学习路径
  • +
  • 企业内训师的角色转型路径
  • +
+

适合人群:企业培训管理者、内训师、人力资源从业者

+
+
+ + +
+
+
+
4.8
+
★★★★★
+
42 条评价
+
+
+
5★
32
+
4★
7
+
3★
2
+
2★
1
+
1★
0
+
+
+ +
+
+
+
+
+
张**(运营部)
+
2026-04-20
+
+
+
★★★★★
+
课程内容非常实用,理论和实践结合得很好。讲师讲解清晰,案例丰富,对我们企业的培训数字化转型很有启发。强烈推荐!
+ +
+
+
+
+
+
李**(技术部)
+
2026-04-18
+
+
+
★★★★☆
+
内容质量不错,AI辅助那章讲得很深入。建议可以增加更多实操案例,理论部分可以稍作精简。整体值得推荐。
+ +
+
+ + +
+
📝 提交我的评价(需完成课程后方可评价)
+
我的评分:
+
+ +
+ +
+ +
+
+
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
学员部门进度完成状态学习时长最近学习
张三运营部
100%
已完成1h 12m2026-04-25
李四技术部
43%
学习中28m2026-04-28
王五行政部
0%
未开始
+
+ +
+
+
+ + +
+ + + + +
+
+
+
+ + +
+
+ +

解锁企业数字化学习的N个可能性 — 统计数据

+ + +
+
+
👥
+
127
+
学习人数
+
↑ 较上周 +15
+
+
+
+
68%
+
完成率
+
↑ 较上周 +3%
+
+
+
⏱️
+
47m
+
平均学习时长
+
课程总长 1h 06m
+
+
+
+
4.8
+
平均评分
+
共 42 条评价
+
+
+ + +
+ +
+

学习进度分布

+
+
+
86人
+
+
已完成
+
+
+
22人
+
+
学习中
+
+
+
19人
+
+
未开始
+
+
+
+ ■ 已完成 86人(68%) + ■ 学习中 22人(17%) + ■ 未开始 19人(15%) +
+
+ + +
+

各课件完成率

+
+
+
+ 🎬 理论篇(00:34:57) + 92% +
+
+
已完成 117 人 / 总计 127 人
+
+
+
+ 🎬 实践篇(00:31:38) + 68% +
+
+
已完成 86 人 / 总计 127 人
+
+
+
+
+ + +
+
+

学员学习明细

+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
学员姓名部门学习进度完成状态累计时长评分最近学习时间
张三运营部
100%
已完成1h 12m★★★★★2026-04-25 14:22
李四技术部
43%
学习中28m2026-04-28 09:15
王五行政部
100%
已完成58m★★★★☆2026-04-22 16:40
赵六人力资源部
0%
未开始
+ +
+
+ +
+
+ + + + diff --git a/training/workspace/specs/student.md b/training/workspace/specs/student.md new file mode 100644 index 0000000..4f3c9ed --- /dev/null +++ b/training/workspace/specs/student.md @@ -0,0 +1,664 @@ +# 学员端功能需求文档 + +> 版本:V1.0.1 +> 创建日期:2026-01-15 +> 更新日期:2026-01-15 +> 状态:✅ 已批准(Supervisor 复审通过) + +--- + +## 版本更新记录 + +### V1.0.1 (2026-01-15) + +**根据Supervisor审查意见修订:** + +| 修订项 | 修订内容 | +|--------|----------| +| 知识学习入口 | 补充培训任务学习场景、返回逻辑、来源区分 | +| 断点续考机制 | 补充定时任务策略、超时判定规则、并发处理 | +| 排名计算规则 | 明确计算时机、统计范围、口径定义 | +| 学习时长防刷 | 新增活跃检测机制 | +| 数据表设计 | km_knowledge_progress 新增 department_id 字段 | +| 页面路径 | 调整为复用现有路径,按角色控制 | +| 解析显示策略 | 明确交卷后立即显示解析 | +| 非功能需求 | 补充可测试的性能指标 | + +--- + +## 一、概述 + +### 1.1 目标用户 +- **角色**:学员(STUDENT) +- **人数**:约450人 +- **入口**:PC浏览器(与管理端共用系统,按角色显示不同菜单) + +### 1.2 核心目标 +为学员提供便捷的在线学习平台,支持知识学习、在线考试、培训计划跟踪,帮助学员提升专业技能。 + +--- + +## 二、功能模块总览 + +| 模块 | 功能点 | 优先级 | +|------|--------|--------| +| 工作台 | 学习概览、待办事项 | P0 | +| 知识学习 | 知识浏览、学习进度、时长统计 | P0 | +| 在线考试 | 考试列表、在线答题、断点续考、成绩排名 | P0 | +| 我的培训 | 培训计划、进度跟踪、完成判定 | P0 | +| 个人中心 | 学习记录、考试记录、证书(预留) | P1 | + +--- + +## 三、详细功能需求 + +### 3.1 工作台(首页) + +#### 用户故事 +> 作为学员,我希望登录后看到学习概览和待办事项,以便快速了解学习状态和接下来要做的事。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 学习统计卡片 | 显示:待学习课程数、待完成考试数、培训进度百分比 | +| 我的培训计划 | 列表展示分配给我的培训计划(最多显示5条),显示进度 | +| 待完成考试 | 列表展示待完成的考试(最多显示5条),显示截止时间 | +| 快捷入口 | 点击可直接进入对应详情页 | + +#### 验收标准 +- [ ] 登录后默认进入工作台页面 +- [ ] 统计数据实时准确(待学习=未完成的必修知识数,待考试=未通过的必考考试数) +- [ ] 培训进度百分比 = 已完成必修项 / 总必修项 × 100% +- [ ] 点击培训计划可跳转到计划详情 +- [ ] 点击考试可跳转到考试页面 +- [ ] 只显示"进行中"状态的培训计划和处于"时间窗口内"的考试 + +--- + +### 3.2 知识学习 + +#### 用户故事 +> 作为学员,我希望浏览本部门的知识库,学习文档和视频,并能看到自己的学习进度。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 知识分类导航 | 左侧树形结构展示知识分类 | +| 知识列表 | 按分类筛选,显示知识标题、类型、学习状态 | +| 知识详情/预览 | 文档在线预览、视频在线播放 | +| 学习进度记录 | 记录学习完成状态、学习时长 | +| 强制学习机制 | 视频必须播放完成、文档必须阅读完成才记录为"已完成" | + +#### 3.2.1 知识列表页 + +**页面元素**: +- 分类筛选(树形) +- 知识卡片列表:标题、类型图标(文档/视频)、学习状态标签、时长/大小 +- 搜索框(按标题搜索) + +**学习状态**: +| 状态 | 说明 | 显示样式 | +|------|------|----------| +| 未学习 | 从未打开过 | 灰色标签 | +| 学习中 | 已开始但未完成 | 蓝色标签 + 进度% | +| 已完成 | 满足完成条件 | 绿色标签 ✓ | + +#### 3.2.2 知识详情页 + +**文档类型**: +- 在线预览(PDF直接显示,Word/Excel/PPT转换预览) +- 学习计时:进入页面开始计时,离开页面停止 +- 完成条件:停留时间 ≥ 预估阅读时间(可配置,默认按页数计算) + +**视频类型**: +- 在线播放(支持进度条、全屏) +- 播放进度记录:记录当前播放位置,下次打开从断点继续 +- 完成条件:播放进度 ≥ 90%(可配置) +- 禁止拖动快进(强制学习模式下) + +#### 3.2.3 学习入口场景(🔴 修订项1) + +**场景一:自由学习(从知识库进入)** +- 入口:左侧菜单"知识库" → 知识列表 → 知识详情 +- 学习完成后:停留在知识详情页,可点击"返回列表" +- 进度记录:标记来源为 `FREE`(自由学习) + +**场景二:培训任务学习(从培训计划进入)** +- 入口:培训详情页 → 点击知识项 → 知识详情 +- 页面顶部显示:返回培训计划入口(如:"← 返回《2026年Q1安全规范培训》") +- 学习完成后: + - 自动弹出提示:"学习完成!是否返回培训计划?" + - 用户可选择【返回培训】或【继续浏览】 +- 进度记录:标记来源为 `TRAINING`(培训任务),关联 `plan_id` + +**进度记录来源区分**: +| 来源 | 场景 | 影响 | +|------|------|------| +| FREE | 自由学习 | 仅记录个人学习进度 | +| TRAINING | 培训任务学习 | 同时更新培训计划进度 | + +> **说明**:无论从哪个入口学习,学习时长和完成状态都会记录。区分来源是为了后续统计分析(如:培训驱动的学习 vs 自主学习)。 + +#### 3.2.4 学习时长防刷机制(🟡 优化项1) + +**文档类型防刷策略**: +- 每60秒检测一次用户活跃状态 +- 活跃判定:60秒内有鼠标移动、点击、滚动任一事件 +- 非活跃时:暂停计时,页面显示"检测到您暂时离开,学习计时已暂停" +- 恢复活跃后:继续计时 + +**视频类型防刷策略**: +- 依赖视频播放事件,暂停时不计时长 +- 无需额外活跃检测 + +**多标签页处理**: +- 同一知识同时只能在一个标签页学习 +- 检测到多标签页打开同一知识时,后打开的标签页提示"该知识已在其他窗口学习中" + +#### 验收标准 +- [ ] 只显示本部门、已发布状态的知识 +- [ ] 分类树正确展示,点击分类筛选对应知识 +- [ ] 文档能正常在线预览(至少支持PDF) +- [ ] 视频能正常播放,支持暂停、音量调节 +- [ ] 视频播放进度自动保存,刷新页面后从断点继续 +- [ ] 强制学习模式下,视频不能快进跳过 +- [ ] 学习时长准确记录(精确到秒) +- [ ] 满足完成条件后自动标记为"已完成" +- [ ] 学习进度与培训计划进度联动 +- [ ] 🔴 从培训计划进入的知识学习,顶部显示返回入口 +- [ ] 🔴 学习完成后弹出返回培训计划提示(仅培训任务学习场景) +- [ ] 🟡 文档学习时,非活跃状态暂停计时 +- [ ] 🟡 同一知识不能在多个标签页同时学习 + +--- + +### 3.3 在线考试 + +#### 用户故事 +> 作为学员,我希望参加分配给我的考试,并能查看成绩和排名。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 考试列表 | 展示分配给我的所有考试 | +| 考试详情 | 显示考试信息、我的考试记录 | +| 在线答题 | 单选/多选/判断题作答 | +| 断点续考 | 中途退出后可继续答题 | +| 自动交卷 | 时间到自动提交 | +| 成绩查看 | 显示得分、答案解析 | +| 成绩排名 | 显示我在本次考试中的排名 | + +#### 3.3.1 考试列表页 + +**列表字段**: +| 字段 | 说明 | +|------|------| +| 考试名称 | 点击进入考试详情 | +| 考试时间 | 开始时间 ~ 结束时间 | +| 考试状态 | 未开始/进行中/已结束 | +| 我的状态 | 未参加/考试中/已完成 | +| 最高成绩 | 多次考试取最高分 | +| 剩余次数 | 最大次数 - 已考次数 | +| 操作 | 进入考试/查看成绩 | + +**筛选条件**: +- 考试状态:全部/进行中/已结束 +- 我的状态:全部/未完成/已完成 + +#### 3.3.2 考试详情页(进入考试前) + +**显示信息**: +- 考试名称、考试时间窗口 +- 考试规则:时长、总分、及格线、最大次数 +- 我的考试记录表格(第N次、得分、是否通过、时间) +- 【开始考试】/【继续考试】按钮 + +**按钮逻辑**: +| 场景 | 按钮状态 | +|------|----------| +| 不在时间窗口内 | 禁用,提示"考试未开始"或"考试已结束" | +| 次数已用完 | 禁用,提示"考试次数已用完" | +| 有进行中的考试记录 | 显示【继续考试】 | +| 可以开始新考试 | 显示【开始考试】 | + +#### 3.3.3 在线答题页 + +**页面布局**: +``` +┌─────────────────────────────────────────────────────────┐ +│ 考试名称 剩余时间: 45:23 │ +├─────────────────────────────────────────────────────────┤ +│ 第 3 题 / 共 20 题 [单选题] │ +│ ───────────────────────────────────────────────────── │ +│ 题干内容... │ +│ │ +│ ○ A. 选项A │ +│ ● B. 选项B(已选中) │ +│ ○ C. 选项C │ +│ ○ D. 选项D │ +├─────────────────────────────────────────────────────────┤ +│ 答题卡: [1✓] [2✓] [3●] [4○] [5○] ... │ +├─────────────────────────────────────────────────────────┤ +│ [上一题] [下一题] [交卷] │ +└─────────────────────────────────────────────────────────┘ +``` + +**功能说明**: +| 功能 | 说明 | +|------|------| +| 倒计时 | 显示剩余时间,最后5分钟变红色提醒 | +| 题目导航 | 点击答题卡数字可跳转到对应题目 | +| 答案自动保存 | 选择答案后自动保存到服务器(每30秒或切题时) | +| 上一题/下一题 | 切换题目 | +| 交卷 | 弹出确认框,确认后提交 | +| 自动交卷 | 时间结束自动提交当前答案 | + +**断点续考机制**: +- 考试开始后创建考试记录,状态为"进行中" +- 答案实时保存到服务器 +- 中途关闭浏览器/断网,考试记录保持"进行中" +- 重新打开考试,检测到进行中记录,恢复到上次状态 +- 倒计时从剩余时间继续(服务端计算:考试时长 - 已用时间) +- 超时未提交的考试,由定时任务自动交卷 + +#### 3.3.5 超时自动交卷机制(🔴 修订项2) + +**超时判定规则**: +``` +超时时间点 = 考试记录开始时间 + 考试时长(分钟) +``` + +**示例**: +- 学员A在 10:00 开始考试,考试时长60分钟 +- 超时时间点 = 10:00 + 60分钟 = 11:00 +- 无论考试时间窗口是否结束,11:00后该考试记录即视为超时 + +**定时任务策略**: +| 配置项 | 值 | 说明 | +|--------|-----|------| +| 执行频率 | 每1分钟 | Cron: `0 */1 * * * ?` | +| 扫描范围 | 状态为IN_PROGRESS的考试记录 | 只处理进行中的记录 | +| 超时判定 | 当前时间 > 开始时间 + 考试时长 | 精确到秒 | +| 处理动作 | 自动提交当前已保存的答案,计算成绩 | 标记为系统自动交卷 | + +**并发提交处理(乐观锁)**: +``` +场景:学员在超时前1秒点击交卷,同时定时任务也在处理该记录 +``` + +**处理策略**: +1. 考试记录表增加 `version` 字段(乐观锁) +2. 提交时检查 `status = IN_PROGRESS AND version = 当前版本` +3. 更新时 `version = version + 1` +4. 若更新失败(version已变),说明已被其他请求处理,直接返回已提交的结果 + +**状态流转**: +``` +IN_PROGRESS ──用户主动交卷──▶ SUBMITTED(来源:USER) + │ + └──────定时任务超时交卷──▶ SUBMITTED(来源:SYSTEM_TIMEOUT) +``` + +**交卷来源标记**: +| 来源 | 说明 | +|------|------| +| USER | 用户主动交卷 | +| SYSTEM_TIMEOUT | 系统超时自动交卷 | + +> **注意**:即使考试时间窗口已结束,只要考试记录未超时,学员仍可继续答题(但无法开始新的考试) + +#### 3.3.6 考试结果页 + +**显示内容**: +| 项目 | 说明 | +|------|------| +| 得分 | 大字显示,及格绿色/不及格红色 | +| 是否通过 | 通过✓ / 未通过✗ | +| 排名 | "您的成绩排名第 X 名(共 Y 人参加)" | +| 答题详情 | 每道题的正确答案、我的答案、解析 | +| 操作按钮 | 【再考一次】(有次数)/ 【返回列表】 | + +**答案解析显示策略(🟡 优化项4)**: +- 交卷后立即显示所有题目的答案解析 +- 无论考试是否通过,都显示解析 +- 目的:培训学习为主,帮助学员理解错误原因 + +#### 3.3.7 排名计算规则(🔴 修订项3) + +**计算时机**: +- 实时计算:每次交卷时计算排名 +- 非定时计算:不采用批量定时计算方式 + +**统计范围**: +- 范围:当前考试的所有参与者(被分配且已交卷的学员) +- 跨部门:若考试分配给多个部门,排名为所有参与者的总排名 + +**排名规则**: +| 优先级 | 规则 | 说明 | +|--------|------|------| +| 1 | 最高成绩降序 | 成绩高的排名靠前 | +| 2 | 达到最高成绩的时间升序 | 成绩相同时,先达到该成绩的排名靠前 | + +**"共Y人参加"统计口径**: +| 统计项 | 是否计入 | +|--------|----------| +| 已交卷学员 | ✅ 计入 | +| 正在考试中(未交卷) | ❌ 不计入 | +| 被分配但未参加 | ❌ 不计入 | + +**排名示例**: +``` +考试:安全规范考核 +分配对象:部门A(30人)、部门B(20人) + +参与情况: +- 部门A:25人已交卷,5人未参加 +- 部门B:18人已交卷,2人正在考试中 + +排名统计:共 43 人参加(25 + 18 = 43) + +排名结果: +第1名:张三(95分,首次达到时间 10:30) +第2名:李四(95分,首次达到时间 10:45) +第3名:王五(90分) +... +``` + +**实现建议**: +- 交卷时实时计算排名并返回 +- 排名计算SQL示例思路: + ```sql + SELECT user_id, MAX(score) as best_score, MIN(submit_time) as first_best_time + FROM ex_exam_record + WHERE exam_id = ? AND status = 'SUBMITTED' + GROUP BY user_id + ORDER BY best_score DESC, first_best_time ASC + ``` + +#### 验收标准 +- [ ] 只显示分配给当前学员的考试 +- [ ] 考试状态、我的状态准确显示 +- [ ] 不在时间窗口内无法进入考试 +- [ ] 次数用完无法再次考试 +- [ ] 答题过程中答案实时保存 +- [ ] 刷新页面/重新进入可继续答题(断点续考) +- [ ] 倒计时准确,超时自动交卷 +- [ ] 交卷后立即显示成绩和排名 +- [ ] 答案解析正确显示 +- [ ] 多次考试取最高分显示 +- [ ] 🔴 定时任务每分钟执行,自动处理超时考试记录 +- [ ] 🔴 并发交卷场景下数据一致(乐观锁机制) +- [ ] 🔴 排名实时计算,仅统计已交卷学员 +- [ ] 🔴 排名规则:最高分优先,同分按首次达到时间排序 + +--- + +### 3.4 我的培训 + +#### 用户故事 +> 作为学员,我希望查看分配给我的培训计划,跟踪学习进度,完成培训任务。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 培训列表 | 展示分配给我的培训计划 | +| 培训详情 | 显示计划内容、学习进度 | +| 进度跟踪 | 实时显示知识学习、考试完成情况 | +| 完成判定 | 根据必修项判定培训是否完成 | +| 证书预留 | 培训完成后预留证书/徽章展示位置 | + +#### 3.4.1 培训列表页 + +**列表字段**: +| 字段 | 说明 | +|------|------| +| 培训名称 | 点击进入详情 | +| 培训周期 | 开始日期 ~ 结束日期 | +| 状态 | 未开始/进行中/已结束 | +| 我的进度 | 进度条 + 百分比 | +| 完成状态 | 未完成/已完成 | + +#### 3.4.2 培训详情页 + +**页面结构**: +``` +┌─────────────────────────────────────────────────────────┐ +│ 2026年Q1安全规范培训 │ +│ ───────────────────────────────────────────────────── │ +│ 培训周期:2026-01-01 ~ 2026-03-31 │ +│ 培训目标:掌握高速公路救援安全规范... │ +│ 我的进度:████████░░░░ 75% │ +├─────────────────────────────────────────────────────────┤ +│ 📚 学习内容 (3/5) │ +│ ├─ ✅ 高速救援SOP手册 [必修] [已完成] │ +│ ├─ ✅ 安全操作视频 [必修] [已完成] │ +│ ├─ 🔵 应急处理流程 [必修] [学习中 60%] │ +│ ├─ ⚪ 设备使用指南 [选修] [未学习] │ +│ └─ ✅ 案例分析 [选修] [已完成] │ +├─────────────────────────────────────────────────────────┤ +│ 📝 考试任务 (1/2) │ +│ ├─ ✅ 安全规范考核 [必考] [已通过 85分] │ +│ └─ ⚪ 操作技能测试 [必考] [未参加] │ +├─────────────────────────────────────────────────────────┤ +│ 🏆 完成奖励 │ +│ └─ 完成培训后可获得【安全规范认证】徽章(敬请期待) │ +└─────────────────────────────────────────────────────────┘ +``` + +**进度计算规则**: + +``` +培训进度 = (已完成必修知识数 + 已通过必考考试数) / (必修知识总数 + 必考考试总数) × 100% +``` + +**完成判定规则**: +| 条件 | 说明 | +|------|------| +| 所有必修知识已完成 | ✓ | +| 所有必考考试已通过 | ✓ | +| 选修知识 | 不影响完成判定 | +| 选考考试 | 不影响完成判定 | + +#### 验收标准 +- [ ] 只显示分配给当前学员的培训计划 +- [ ] 进度百分比计算准确(只计算必修项) +- [ ] 学习内容、考试任务分开展示 +- [ ] 显示每项的必修/选修、必考/选考标签 +- [ ] 点击知识内容可跳转到知识详情页 +- [ ] 点击考试任务可跳转到考试页面 +- [ ] 所有必修项完成后,培训状态变为"已完成" +- [ ] 证书/徽章区域预留(V1显示"敬请期待") + +--- + +### 3.5 个人中心 + +#### 用户故事 +> 作为学员,我希望查看自己的学习记录、考试历史和获得的证书。 + +#### 功能清单 + +| 功能项 | 说明 | +|--------|------| +| 个人信息 | 显示姓名、部门、角色 | +| 学习统计 | 总学习时长、完成课程数 | +| 学习记录 | 知识学习历史列表 | +| 考试记录 | 考试历史列表 | +| 我的证书 | 证书/徽章展示(预留) | + +#### 验收标准 +- [ ] 正确显示当前用户信息 +- [ ] 学习时长统计准确(累计所有知识学习时长) +- [ ] 学习记录按时间倒序展示 +- [ ] 考试记录显示每次考试的详情 +- [ ] 证书模块预留(V1显示"暂无证书") + +--- + +## 四、数据契约补充 + +### 4.1 新增/修改实体 + +#### 知识学习进度表 (km_knowledge_progress) - 新增 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Long | 主键 | +| user_id | Long | 学员ID | +| knowledge_id | Long | 知识ID | +| department_id | Long | **🟡 新增** 所属部门(冗余字段,便于按部门统计) | +| status | Enum | 状态:NOT_STARTED/IN_PROGRESS/COMPLETED | +| progress | Integer | 进度百分比(0-100) | +| duration | Long | 学习时长(秒) | +| video_position | Long | 视频播放位置(秒),仅视频类型 | +| source | Enum | **🔴 新增** 学习来源:FREE/TRAINING | +| plan_id | Long | **🔴 新增** 关联培训计划ID(来源为TRAINING时有值) | +| start_time | DateTime | 首次学习时间 | +| complete_time | DateTime | 完成时间 | +| update_time | DateTime | 最后更新时间 | + +#### 考试记录表 (ex_exam_record) - 补充字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| status | Enum | **新增** 状态:IN_PROGRESS/SUBMITTED | +| last_save_time | DateTime | **新增** 最后保存时间(断点续考用) | +| version | Integer | **🔴 新增** 乐观锁版本号 | +| submit_source | Enum | **🔴 新增** 交卷来源:USER/SYSTEM_TIMEOUT | + +### 4.2 新增枚举 + +```java +// 学习状态 +public enum LearningStatus { + NOT_STARTED, // 未学习 + IN_PROGRESS, // 学习中 + COMPLETED // 已完成 +} + +// 🔴 学习来源 +public enum LearningSource { + FREE, // 自由学习(从知识库直接进入) + TRAINING // 培训任务学习(从培训计划进入) +} + +// 考试记录状态 +public enum ExamRecordStatus { + IN_PROGRESS, // 考试中(用于断点续考) + SUBMITTED // 已提交 +} + +// 🔴 交卷来源 +public enum SubmitSource { + USER, // 用户主动交卷 + SYSTEM_TIMEOUT // 系统超时自动交卷 +} +``` + +--- + +## 五、接口预留(V2/V3功能) + +### 5.1 证书/徽章接口(预留) + +``` +POST /api/certificate/generate # 生成证书 +GET /api/certificate/my # 我的证书列表 +GET /api/certificate/{id} # 证书详情 +GET /api/badge/my # 我的徽章列表 +``` + +### 5.2 错题本接口(V2预留) + +``` +GET /api/wrong-questions # 错题列表 +POST /api/wrong-questions/review # 标记已复习 +``` + +--- + +## 六、非功能需求 + +| 项目 | 要求 | 测试指标(🟡 优化项5) | +|------|------|------------------------| +| 页面加载 | 页面加载 < 2秒 | 95%请求 < 2秒 | +| 考试提交 | 考试提交 < 1秒 | 99%请求 < 1秒 | +| 并发支持 | 支持100人同时在线考试 | 100并发答题,响应时间 < 500ms,成功率 > 99.9% | +| 答案保存 | 答案自动保存 | 保存成功率 > 99.99%,保存延迟 < 300ms | +| 数据安全 | 考试答案定时自动保存,防止数据丢失 | 断网重连后数据无丢失 | +| 兼容性 | 支持主流浏览器 | Chrome 90+、Edge 90+、Firefox 90+ | + +--- + +## 七、不做什么(Out of Scope) + +| 功能 | 原因 | +|------|------| +| 移动端适配 | V1仅支持PC浏览器 | +| 防作弊功能 | 产品确认不需要 | +| 学习日历 | 产品确认不需要 | +| 错题本 | V2规划 | +| 社交功能(评论、点赞) | V3规划 | + +--- + +## 八、页面清单(🟡 优化项3) + +> **说明**:采用与管理端复用同一套系统的方案,按角色控制菜单和功能显示,不使用独立的 `/student/` 路径前缀。 + +| 页面 | 路径 | 角色可见 | 说明 | +|------|------|----------|------| +| 工作台 | /index.html | 全角色 | 根据角色显示不同内容 | +| 知识列表 | /knowledge/list.html | 全角色 | 学员只读,讲师可管理 | +| 知识详情 | /knowledge/view.html?id={id} | 全角色 | 学员增加学习进度记录 | +| 考试列表 | /exam/my-exams.html | 学员 | 我的考试列表 | +| 考试详情 | /exam/detail.html?id={id} | 学员 | 考试信息页 | +| 在线答题 | /exam/answer.html?id={id} | 学员 | 答题页面 | +| 考试结果 | /exam/result.html?recordId={id} | 学员 | 成绩结果页 | +| 培训列表 | /training/my-training.html | 学员 | 我的培训列表 | +| 培训详情 | /training/detail.html?id={id} | 学员 | 培训计划详情 | +| 个人中心 | /profile/index.html | 全角色 | 个人信息页 | +| 学习记录 | /profile/learning.html | 学员 | 学习记录列表 | +| 考试记录 | /profile/exam-history.html | 学员 | 考试记录列表 | +| 我的证书 | /profile/certificate.html | 学员 | 证书展示(预留) | + +**菜单权限控制**: +| 菜单项 | 管理员 | 讲师 | 学员 | +|--------|--------|------|------| +| 工作台 | ✅ | ✅ | ✅ | +| 知识库 | ✅ 管理 | ✅ 管理 | ✅ 只读学习 | +| 考题管理 | ✅ | ✅ | ❌ | +| 试卷管理 | ✅ | ✅ | ❌ | +| 考试管理 | ✅ | ✅ | ❌ | +| 我的考试 | ❌ | ❌ | ✅ | +| 培训计划 | ✅ | ✅ | ❌ | +| 我的培训 | ❌ | ❌ | ✅ | +| 个人中心 | ✅ | ✅ | ✅ | +| 系统设置 | ✅ | ❌ | ❌ | + +--- + +## 九、修订项对照表 + +| 修订项 | 类型 | 章节位置 | 状态 | +|--------|------|----------|------| +| 知识学习入口场景 | 🔴 必须修正 | 3.2.3 | ✅ 已补充 | +| 断点续考超时机制 | 🔴 必须修正 | 3.3.5 | ✅ 已补充 | +| 排名计算规则 | 🔴 必须修正 | 3.3.7 | ✅ 已补充 | +| 学习时长防刷机制 | 🟡 建议优化 | 3.2.4 | ✅ 已采纳 | +| 数据表department_id | 🟡 建议优化 | 4.1 | ✅ 已采纳 | +| 页面路径设计 | 🟡 建议优化 | 8 | ✅ 已调整 | +| 答案解析显示策略 | 🟡 建议优化 | 3.3.6 | ✅ 已明确 | +| 非功能需求测试指标 | 🟡 建议优化 | 6 | ✅ 已补充 | + +--- + +**文档状态:✅ 已批准** + +> **Supervisor 复审结论**:通过 +> **复审日期**:2026-01-15 +> **下一阶段**:技术评审 → 开发