21 KiB
21 KiB
UNIS CRM 系统全量审计分析报告
- 审计日期:2026-09-01
- 审计范围:后端(Spring Boot + MyBatis)、前端(frontend / frontend1 两个 React 工程)、SQL 脚本、Docker/Nginx 部署配置
- 审计方式:静态代码审查(未改动任何代码)
- 结论概览:商机/渠道/CRM 拓展互移、商机 tab 阶段筛选导出等核心业务逻辑正确;但存在 2 个越权类安全漏洞、1 个前端业务阻断缺陷、1 个全新部署致命缺口 及若干配置/一致性问题,建议按优先级修复。
一、严重问题(建议优先修复)
1. 打卡照片 / 日报附件下载接口存在越权访问(IDOR)
- 位置:
- WorkController.java#L152-L159(
GET /api/work/checkin-photos/{fileName}) - WorkController.java#L169-L176(
GET /api/work/report-attachments/{fileName}) - WorkServiceImpl.java#L468-L476、WorkServiceImpl.java#L506-L514
- WorkController.java#L152-L159(
- 问题:两个下载接口不接收
X-User-Id,服务层仅做了「目录穿越」校验(拒绝..、/、\),未校验文件属主/数据权限。文件名格式为{userId}-{uuid}.{ext}(见 WorkServiceImpl.java#L462、WorkServiceImpl.java#L495)。任何已登录用户只要拿到/猜到他人文件名(文件名含 userId 前缀,可枚举)即可下载其打卡照片和日报附件。 - 建议:下载时校验文件前缀 userId 与当前登录用户一致,或通过数据权限服务校验归属。
2. 生产配置硬编码大量敏感凭据,且 jwt-secret 使用默认值
- 位置:application.yml#L4-L10、L20-L29、L53、L78、L88、application-prod.yml#L51
- 问题:
- MinIO(
admin/Admin@123456)、PostgreSQL(unis@123)、Redis(zghz@123)、企微 secret、OMS api-key 全部明文写入仓库。 - 两个 profile 的
jwt-secret均为默认值change-me-please-change-me-32bytes,且 docker-compose 未通过环境变量覆盖(docker-compose.yml#L14-L16 仅传TZ与SPRING_PROFILES_ACTIVE)。一旦使用默认值,攻击者可伪造任意用户/租户的 access token。
- MinIO(
- 建议:改为注入环境变量/密钥管理,强制覆盖
jwt-secret。
3. 日报提交失败后前端永久锁死,需刷新页面才能恢复
- 位置:Work.tsx#L2175-L2226
- 问题:
reportSubmitInFlightRef.current = true与setReportSubmitLocked(true)在 L2205-L2206 设置,但只在成功路径(L2214-L2215)复位。一旦saveWorkDailyReport抛错(网络异常、后端校验失败、401 等),两个标记永久保持true,提交按钮被守卫 L2176 永久禁用,提示「提交未确认,请刷新后重试」。当日已填写的整份日报(行项/附件/明日计划)必须刷新页面才能再次提交,丢失风险高。 - 建议:将两个标记的复位移入
catch或finally(与setSubmittingReport(false)一起)。
4. 全新安装脚本 init_full_pg17.sql 缺少 4 张运行时依赖表
- 位置:init_full_pg17.sql(自声明为全新环境权威初始化入口)
- 问题:该脚本没有创建以下后端实际依赖的表:
crm_crm_expansion/crm_crm_expansion_contact(CRM 拓展主/从表,见 CrmExpansionMapper.xml)crm_oms_dict_mapping(OMS 字典映射,见 CrmOmsDictMappingMapper.xml)crm_channel_expansion_coverage(覆盖地市从表,见 ExpansionMapper.xml#L106-L119)
- 这 4 张表只存在于增量脚本 20260827.sql#L45、L77、L92、L142;已核实 8 个启动期 SchemaInitializer 均未补建(common/ 下只有 dashboard/日历/日报提醒/语音/数据权限等表)。
- 影响:全新库只跑
init_full_pg17.sql时,CRM 拓展模块、商机 OMS 推送/回传、渠道覆盖地市会在运行时抛relation does not exist。生产库(已执行 20260827.sql)不受影响,但属部署链路硬缺口。 - 建议:在
init_full_pg17.sql中补齐这 4 张表(可直接并入 20260827.sql 的定义)。
5. OMS 集成回写的阶段/状态校验与新阶段码不一致(残留死代码,不阻断实际同步)
- 位置:OpportunityServiceImpl.java#L920-L925、OpportunityServiceImpl.java#L944-L974
- 已确认(用户核实):OMS 反写只根据
archived字段触发「已签约」,不下发status=won/lost。因此archived=true → 强制 stage=S5路径(L922-L925)实际可正常同步「已签单」,不构成阻断。 - 残留问题(低危):
resolveIntegrationStatus仅识别字面量"won"/"lost"阶段,但阶段经mapStageToCrm映射后为S5/L,这两个分支是死代码;WON_STAGE_CODES = Set.of("won","S6")(L65)定义后从未使用,且S6实为 OMS 码,佐证旧逻辑未随阶段码迁移同步更新。- 若 OMS 未来开始下发
status=won/lost,仍会走到switch的case "won","lost"抛错;若 OMS 下发stage(如L)但不带 status,商机status会落为active(语义不符,但crm_opportunity.status允许active,不报错)。
- 建议:后续可在
resolveIntegrationStatus中按映射后新码(S5→won、L→lost)推导 status,并清理死代码WON_STAGE_CODES;非阻塞,可留作低优先维护项。
二、业务逻辑问题
6. 打卡可重复提交(当日无去重)
- 位置:WorkServiceImpl.java#L324-L355
- 问题:
saveCheckIn每次直接insertCheckIn,未先查当日是否已有记录;updateCheckIn(WorkMapper.java#L94)为从未调用的死代码;打卡表当日索引非唯一(WorkCheckInSchemaInitializer.java#L37)。双击/重试会产生多条当日打卡,统计重复。
7. 日报导出按「业务类型」筛选在 5000 条截断之后执行
- 位置:WorkServiceImpl.java#L316-L321
- 问题:
exportDailyReports先合并去重并limit(EXPORT_LIMIT=5000),再用内存hasReportLineOfType过滤;而打卡导出的bizType是在 SQL 内过滤(WorkMapper.xml#L35-L37)。日期区间大、其它类型行数多时,目标类型日报会被 5000 上限截断,导出结果偏少/为空。
8. 打卡日期边界使用 DB current_date,与日报 Asia/Shanghai 口径不一致
- 位置:WorkMapper.xml#L96、L720、L744
- 问题:打卡按
checkin_date = current_date(依赖 DB 会话时区),日报统一用 Asia/Shanghai(WorkServiceImpl.java#L372-L373)。若 DB 服务器时区非上海,北京时间凌晨 0-8 点打卡的归属日期会错位。
9. Dashboard「当前阶段」硬编码旧枚举,与商机/拓展新阶段码不一致
- 位置:DashboardMapper.xml#L186-L194(商机)、L247-L254(销售)、L307-L314(渠道)
- 问题:
case o.stage when 'initial_contact'...'won'/'lost'已与新版字典码值(S0/S1/S2/S3/S4/S5/L,见 20260828update.sql)脱节,首页动态会直接显示原始码(如S5、L)。建议改为动态 joinsys_dict_item/sj_xmjd。
10. 跟进记录新增接口返回的 ID 是「影响行数」而非真实主键
- 位置:OpportunityServiceImpl.java#L315-L327、ExpansionServiceImpl.java#L497-L506
- 问题:
insertOpportunityFollowUp/insertExpansionFollowUp未配置useGeneratedKeys,inserted恒为 1,接口返回的Long实际是行数而非跟进记录 ID。前端当前未依赖该 ID,影响较低,但属错误返回值。
11. 商机导出采用「全量拉取 + 前端过滤」
- 位置:Opportunities.tsx#L2255
- 问题:导出调用
getOpportunityOverview("", undefined, false, null, ...),limit=null一次性拉取租户内全部商机再在客户端过滤。商机规模大时单次请求体量大、耗时长,存在超时/内存风险。建议改为服务端筛选或分批拉取。
12. 语音识别配置页缺少「未选租户」防护
- 位置:speech-recognition-settings/index.tsx#L156、L201-L217
- 问题:未选租户时保存会落到全局租户(0),静默改写全局配置;而日报提醒页有
isTenantUnselected拦截(report-reminder-settings/index.tsx)。行为不一致。
13. 前端 request() 与 fetchWithAuth() 对 401 处理不一致
- 位置:auth.ts#L1259-L1271 vs auth.ts#L1298-L1311
- 问题:
request()遇 401 直接handleUnauthorizedResponse()(清登录态跳登录页),不做「刷新+重试」;fetchWithAuth()会先刷新再重试。token 被提前吊销或服务端判定失效但本地未过期时,用户会在操作中被直接登出并丢失已输入内容。
14. 附件上传走 XHR 但未做 token 主动刷新
- 位置:auth.ts#L1668-L1723
- 问题:
uploadWorkReportAttachment直接读localStorage.accessToken发 XHR,不经过ensureFreshAccessToken。长时间编辑后 token 到期,上传 401 → 直接登出,正在填写的日报/打卡表单数据丢失。建议上传前先刷新 token。
三、潜在风险 / 代码规范
| # | 问题 | 位置 |
|---|---|---|
| 15 | 生产环境 MyBatis log-impl: StdOutImpl 打印全部 SQL 与绑定参数(含敏感数据) |
application.yml#L36 |
| 16 | 语音转写整文件读入内存、无独立大小限制;打卡照片无大小限制(依赖全局 500MB),可致 OOM/存储膨胀 | WorkServiceImpl.java#L516-L573、WorkServiceImpl.java#L448-L465 |
| 17 | 定时任务共享默认单线程调度器,ReportReminderScheduler(每分钟)与 BusinessCalendarAutoSync(每日 3:15)可能相互阻塞,且无分布式锁 |
ReportReminderScheduler.java、BusinessCalendarAutoSync.java |
| 18 | 数据权限完全依赖 unisbase 插件在 SQL 上动态注入,SQL 本身无兜底条件;插件缺失/非 Web 上下文可能返回全库数据 | WorkMapper.java#L32-L72 |
| 19 | 前端权限判断 fail-open:权限码列表为空时全部放行(if (!codes.length) return true),权限拉取失败时 UI 虚假放行 |
auth.ts#L1469-L1483 |
| 20 | 20260828update.sql 的 UPDATE ... FROM crm_oms_dict_mapping 依赖 oms_value 唯一,但表唯一约束为 (dict_type, crm_value, oms_value),未约束 (dict_type, oms_value) 唯一,后续新增映射可能产生非确定性更新 |
20260828update.sql#L106-L112、20260827.sql#L104 |
| 21 | 生产 profile 企微默认 enabled:false,与 SSO 期望(redirect-uri 指向 crm.unissense.top)不一致;/api/wecom/sso/**、/api/opportunities/integration/** 为 permit-all |
application-prod.yml#L72-L82 |
| 22 | frontend1 http.ts 鉴权白名单用 url.includes(path) 子串匹配,路径匹配过度放宽 |
http.ts#L14、L68-L70 |
| 23 | 生产 MinIO endpoint 为 https://miniodown.nex.unisspace.com 但 use_ssl: false,可能连接异常 |
application-prod.yml#L5-L10 |
| 24 | 后端 Dockerfile 复制 target/unis-crm-backend-1.0.0-SNAPSHOT.jar,但仓库内 jar 位于 backend/ 根目录(非 target/),依赖 CI 先执行 mvn package,否则 docker compose build 会失败 |
backend/Dockerfile#L7 |
| 25 | 打卡/日报元数据使用字符串内嵌标记([[CHECKIN_PHOTOS]]、[[WORK_REPORT_LINES]] 等)拼入 remark 字段,解析依赖精确格式,健壮性差 |
WorkServiceImpl.java#L91-L102 |
| 26 | ActionDialog 确认回调 void onConfirm() 未捕获 rejection |
ActionDialog.tsx#L150 |
| 27 | 腾讯地图生产 Key 硬编码进前端源码作兜底 | tencentMap.ts#L1 |
| 28 | 移动端多选下拉点击遮罩关闭不「回滚」已勾选项,与常规确定/取消交互不一致,易误改覆盖省市 | AdaptiveSelect.tsx#L204-L216 |
| 29 | 浏览器定位最坏路径约 33s,弱信号下用户易误以为卡死 | tencentMap.ts#L145-L176 |
| 30 | Dashboard 权限/消息加载静默吞异常,问题被掩盖 | DashboardServiceImpl.java#L171-L207 |
四、已核对且判定为正常的重点项
- 商机 tab 阶段筛选/导出约束(与项目硬约束一致)
- 未签单 tab:
excludeStageCodes=lostStageCodes(排除 L-已丢单)✓(Opportunities.tsx#L2254、后端 OpportunityMapper.xml#L288-L294) - 已丢单 tab:仅
stageCodes=lostStageCodes✓ - 切换 tab 重置阶段筛选 ✓(Opportunities.tsx#L1718-L1725)
- 阶段选项动态取自
sys_dict_item/sj_xmjd,未硬编码 ✓
- 未签单 tab:
- 渠道拓展/CRM 拓展互移流程:属主校验、字段映射(覆盖省市/办公地址/联系人)、跟进/签到/日报消息的 biz_type 迁移、旧记录清理均正确(ExpansionServiceImpl.java#L398-L495),与前端弹窗字段、校验一致。
- 覆盖地市存储模型:后端读写
crm_channel_expansion_coverage从表(ExpansionMapper.xml#L106-L119),insertChannelExpansion不引用废弃的 coverage 列,与 20260827.sql 的设计一致 ✓ - 商机集成 archived→S5 强制:已实现并通过单测(OpportunityServiceImplTest.java#L211-L228);且经确认 OMS 反写仅用
archived字段触发「已签约」(不下发status=won/lost),实际同步正常 ✓ - SQL 增量脚本幂等性:
create table if not exists、drop constraint if exists、on conflict do nothing/update均正确;20260828update.sql 的换算用单条 UPDATE 避免连环转换 ✓ - 前后端字段一致性:
MoveCrmToChannelRequest(coverageProvince/coverageCity/coverageItems)、CreateOpportunityRequest与前端序列化一致;confidencePct 正则与前端兜底一致 ✓ - Nginx 延迟解析:两个前端 nginx 均使用
resolver + set $backend_host延迟解析域名,符合约束 ✓(default.conf.template#L8、L16-L19) - 前端 Dockerfile/.dockerignore:
.dockerignore已排除 node_modules、.git、系统目录,多阶段构建合理 ✓ - 日报保存逻辑:「当日重复提交→更新」「北京时间 10 点前归属前一天」「提醒在事务提交后触发」均正确(WorkServiceImpl.java#L357-L415)
五、修复优先级建议
| 优先级 | 问题编号 | 理由 |
|---|---|---|
| P0 | #1、#2 | 越权与凭据泄露,安全风险最高 |
| P0 | #4 | 全新部署直接功能瘫痪 |
| P1 | #3 | 前端业务阻断,日报当天无法提交 |
| P2 | #6、#7、#8、#9、#11、#13、#14 | 数据正确性与一致性 |
| P3 | #5 及其余 | #5 已确认不阻断实际同步(OMS 仅用 archived 反写),降为低危维护项 |
说明:本报告仅做静态审计,未运行代码;部分结论(如 #5 是否实际触发、#11 的量级、#8 的时区)建议结合运行时数据/日志复核后再修复。