External Integrations Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
外部集成领域负责平台与所有第三方系统之间的数据交换和功能协作。该领域覆盖了CRM数据同步(Salesforce/Veeva)、虚拟会议管理(Zoom)、邮件投递与追踪(SendGrid + iCal4j)、文件传输(SFTP)、文档转换(CloudConvert)、地理编码(Google Maps API)、身份认证(SSO/SAML)、数据导出(Mosaic)、外部自动化(Podio webhook)以及外部程序创建API等功能。
1.2 集成清单总览
| 外部系统 | 集成方向 | 协议/方式 | 核心用途 |
|---|---|---|---|
| Salesforce (Force API) | 双向 | SOAP Partner API | CRM联系人/活动同步 |
| Veeva CRM | 双向 | SOAP Partner API (Salesforce) | Veeva CRM多渠道活动同步 |
| Zoom | 双向 | REST API + Webhook | 虚拟会议/网络研讨会管理 |
| SendGrid | 入向 | REST API | 邮件活动追踪数据同步 |
| iCal4j | 出向 | iCalendar标准 | 日历邀请生成(随邮件发送) |
| Google Maps | 出向 | REST API | 地理编码(地址转GPS坐标) |
| SFTP | 出向 | SSH/SFTP | 文件上传/下载/删除 |
| CloudConvert | 出向 | REST API | DOCX到PDF文档转换 |
| SSO/SAML | 入向 | SAML 2.0 | Azure AD/Okta企业SSO |
| Mosaic | 出向 | 数据库查询 + Excel导出 | 合规数据导出 |
| Podio | 出向 | REST Webhook | 程序创建通知 |
| External API | 入向 | REST API | 外部系统创建程序 |
2. Integration Inventory
2.1 Salesforce (Force API)
集成模式: 通过Salesforce SOAP Partner API进行双向数据同步。使用策略模式支持标准Salesforce和Veeva两种CRM变体。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/SfdcFacade.java- 门面类,统一入口pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/ForceService.java- SOAP操作基类pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/IntegrationServiceConfiguration.java- 策略工厂配置pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/DefaultContactService.java- 标准SF联系人pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/DefaultAccountService.java- 标准SF账户pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/VeevaAccountService.java- Veeva账户pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/plus/integration/sf/service/VeevaContactService.java- Veeva联系人(空实现)pharmagin-api/pharmagin-web/src/main/resources/yaml/field-mapping*.yaml- 10个字段映射配置文件
同步实体:
- Contact / Person Account -> NpiRecord(查找Speaker候选人)
- Contact / Account -> ContactResponse(获取联系人详情)
- Attendee -> Contact / Person Account(反向创建)
- EventRequest -> Event / Multichannel_Activity_vod__c(活动同步)
架构模式:
策略模式选择CRM类型:
IntegrationServiceConfiguration(line 17-58) 根据agency.sf.type配置值决定使用DefaultContactService/DefaultAccountService(标准Salesforce)还是VeevaContactService/VeevaAccountService(Veeva CRM)。YAML驱动的字段映射:
FieldMappingFactory从 YAML 文件加载字段映射配置,支持按客户定制。当前有10个映射文件覆盖不同客户(dendreon, lantheus, pharmagin, cutanea, ipsen, kala, noven, stemline, tesaro + 默认)。单例连接管理:
SfdcFacade(line 36-37) 声明为@Scope("singleton"),PartnerConnection在首次认证时创建并缓存,仅在用户名变化时重建 (line 82-89)。
关键问题:
- SQL注入风险:
SfdcFacade.getSFDCUserId()(line 203) 直接拼接用户输入到SOQL查询中:"SELECT Id FROM User where Username = '" + email + "'"— 虽然SOQL注入的攻击面比SQL注入小,但仍存在风险。DefaultAccountService.searchPersonAccounts()(line 169-173) 同样直接拼接查询。 - 连接共享问题: 单例中的
PartnerConnection被所有请求共享 (line 52),在多线程环境下可能存在线程安全问题,且一个产品的Salesforce凭据可能与另一个产品的混淆。 - 错误处理不一致:
DefaultAccountService多处使用ce.printStackTrace()(line 79, 83, 98, 152, 202, 259) 而非结构化日志。VeevaAccountService也有相同问题 (line 79, 151, 258)。 FieldMappingFactory单例问题: 静态instance变量 (line 40-41) 意味着mappings列表在整个JVM中共享,updateFieldMappingConfiguration()调用时会clear()并重新加载 (line 67),可能导致并发问题。- 硬编码的Veeva对象名:
VeevaAccountService硬编码了"Multichannel_Activity_vod__c"、"Pharmagin_vod"、"Professional_vod"等Veeva自定义对象名 (line 40, 133-134, 270)。
2.2 Zoom API
集成模式: 通过Zoom REST API管理虚拟会议和网络研讨会,通过Webhook接收实时事件。使用Server-to-Server OAuth (S2S) 认证。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/service/ZoomAbstractService.java- 抽象基类,含认证和REST调用逻辑pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/service/ZoomVirtualMeetingService.java- 会议CRUDpharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/service/ZoomWebinarService.java- 网络研讨会CRUDpharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/service/StubVirtualMeetingService.java- 第三方虚拟会议的空实现pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/service/VirtualProgramServiceFactory.java- 工厂类pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/controller/ZoomMeetingWebhookController.java- Webhook接收pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/virtualProgram/model/ZoomAccessTokenManager.java- Token缓存pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/config/VirtualProgramConfig.java- 配置模型
虚拟服务类型(VirtualServiceType 枚举):
1- Zoom Virtual Meeting2- Zoom Webinar3- Third Party Virtual Meeting(空实现,用于外部平台如Teams)
API操作覆盖:
| 操作 | Meeting API | Webinar API |
|---|---|---|
| 创建 | POST /users/{userId}/meetings | POST /users/{userId}/webinars |
| 查询 | GET /meetings/ | GET /webinars/ |
| 更新 | PATCH /meetings/ | PATCH /webinars/ |
| 删除 | DELETE /meetings/ | DELETE /webinars/ |
| 添加参会者 | POST /meetings/{id}/registrants | POST /webinars/{id}/registrants 或 panelists |
| 更新参会者 | PUT /meetings/{id}/registrants/status | PUT /webinars/{id}/registrants/status |
| 投票 | POST /meetings/{id}/polls | POST /webinars/{id}/polls |
| 报告 | GET /report/meetings/{id}/participants | GET /report/webinars/{id}/participants |
Webhook处理:
ZoomMeetingWebhookController (line 37-198) 处理6种Webhook事件:
meeting/participant/join- 参会者加入会议meeting/participant/leave- 参会者离开会议meeting/ended- 会议结束webinar/participant/join- 参会者加入研讨会webinar/participant/leave- 参会者离开研讨会webinar/ended- 研讨会结束
Webhook安全验证实现了完整的HMAC-SHA256签名验证 (line 124-151),包括时间戳防重放攻击(5分钟窗口)和Zoom Endpoint Validation协议 (line 104-109, 153-162)。
认证机制:
使用Server-to-Server OAuth2:getAccessToken() (ZoomAbstractService line 267-298) 向 s2sOAuthTokenUrl 发送 account_credentials grant请求。Token通过 ZoomAccessTokenManager(Guava Cache,55分钟过期)缓存 (line 12-13)。
重试机制:
restCall() (line 150-188) 有一层简单重试:当收到错误码 124(Token过期)时,清除缓存Token并重试一次 (line 176-179)。
关键问题:
- 单次重试过于简单:
restCall()只在Token过期时重试一次 (line 178-179),对于网络瞬断、速率限制(429)等场景没有处理。 - Token缓存全局单例:
ZoomAccessTokenManager使用静态Cache(line 12),在多产品部署场景下所有产品共享同一个Token,可能导致Token对应错误的Zoom账户。 - 遗留代码:
generateZoomSignature()(line 221-243) 使用旧的API Key签名方式,已被generateSignature()(line 245-265) 取代,但未删除。 - 大量代码重复:
ZoomVirtualMeetingService和ZoomWebinarService之间存在大量逻辑重复(createProgram、updateProgram、deleteProgram、handleParticipantJoin/Leave、handleMeetingEnd 等),仅URL路径不同(/meetings/vs/webinars/)。 - 异常处理不精确:
restCall()捕获HttpClientErrorException后对所有非124错误统一抛出ServiceException(line 183),丢失了原始HTTP状态码信息。 - Webhook Controller缺乏权限限制: Webhook端点
v1/webhooks/zoom/虽然有签名验证,但位于一般的Controller路径下,应通过安全配置确保公开访问。
2.3 SendGrid
集成模式: 通过SendGrid REST API(v3 Messages端点)拉取邮件活动追踪数据,使用增量同步模式持久化到本地数据库。注意:邮件发送不通过SendGrid API——平台使用JavaMail + SMTP直接发送(见mail模块),SendGrid集成仅用于活动追踪。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/sendgrid/service/SendGridService.java- 核心同步服务pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/sendgrid/controller/SendGridController.java- 空Controllerpharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/sendgrid/DownloadSendGridEmailActivity.java- Excel导出模型pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/SendGridEmailActivity.java- 数据实体
同步机制:
performIncrementalSync() (line 59-77) 实现增量同步:
- 从数据库获取最近一次事件时间
getNextStartTime()(line 79-106) - 若无历史数据则从30天前开始
- 调用SendGrid API获取
last_event_time > {timestamp}的消息 - 若结果达到1000条(API限制),启用时间分片算法
- 时间分片递归二分(先按天,然后按小时,最小5分钟分片)
- 结果通过
batchUpsertEmailActivities()批量写入数据库
时间分片策略(处理API限制):
SendGrid API单次最多返回1000条记录。当结果满时:
fetchWithTimeSlicingSplit()(line 195-217):先按日期二分- 若单日超限,切换到
fetchWithTimeSlicingDateTime()(line 228-255):按小时二分 fetchWithDateTimeSplit()(line 257-295):最终降到5分钟粒度- 5分钟仍超限则截断并记录警告 (line 262-264)
关键问题:
- Controller完全为空:
SendGridController(line 11-13) 没有任何端点实现,同步似乎仅通过cron job触发,缺少手动触发和状态查询API。 - SimpleDateFormat线程安全问题:
dateFormat(line 41) 作为实例变量被多线程共享,SimpleDateFormat非线程安全。 - 递归深度未限制: 时间分片的递归二分没有最大深度限制,极端情况下可能栈溢出。
- 凭据直接在Header中: API Key通过
"Bearer " + agencyConfiguration.getSendGrid().getApiKey()(line 165) 直接硬编码到HTTP Header,无密钥轮换机制。 - URL未编码:
buildUrl()(line 155-161) 将SOQL查询字符串直接拼接到URL中,未进行URL编码,包含空格和引号的查询可能导致请求失败。 - 无速率限制感知: 未处理SendGrid API的速率限制响应(429 Too Many Requests)。
2.4 iCalendar (日历同步)
集成模式: 使用 iCal4j 库生成符合 iCalendar 标准的 .ics 文件,作为邮件附件发送给参会者,实现日历事件同步。不涉及Google Calendar API调用。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/mail/service/MailService.java- 包含createICalendar()方法 (line 488)
实现细节:
- 使用
net.fortuna.ical4j库创建VEvent日历事件 (line 546) - 生成的
.ics数据作为text/calendar;METHOD=PUBLISHMIME附件 (line 279-280) - 文件名固定为
invite.ics(line 281) - 支持参会者信息(Cn参数)、组织者信息
关键说明: 这不是Google Calendar API集成。Google API仅用于地理编码(见2.6节)。日历功能完全通过iCal标准实现,不依赖任何外部日历服务API。
2.5 Google Maps Geocoding API
集成模式: 通过Google Maps Geocoding API将地址字符串转换为GPS坐标(经纬度),用于地图展示。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/service/GoogleAPIService.java- 地理编码服务
实现细节:
- API URL:
https://maps.googleapis.com/maps/api/geocode/json(line 18) - 参数:地址通过URLEncoder编码,固定
region=us(line 18) - 返回
geometry.location中的lat/lng坐标 (line 54) - 通过
agency.google.api.key配置API密钥 (line 27-28) - 通过
agency.enableGoogleGeocodeAPI功能开关控制 (line 22)
关键问题:
- 自建RestTemplate:
GoogleAPIService在类级别直接new RestTemplate()(line 19),不使用Spring管理的Bean,无连接池和超时配置。 - 返回类型不安全:
getGPS()返回Map<String, Object>(line 31),调用方需要自行转型,且无Null安全保证。 - 无缓存: 每次调用都向Google API发送请求,相同地址不缓存,可能导致不必要的API费用。
- API密钥在URL中: 密钥直接放在URL参数中 (line 39),可能被日志记录或浏览器历史泄露。
2.6 SFTP
集成模式: 通过 SSHJ 库进行 SSH/SFTP 文件传输操作,支持列出远程文件、上传、下载和删除。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/sftp/service/SFTPService.java- 核心SFTP服务pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/sftp/controller/SFTPController.java- REST端点
操作:
| 方法 | 功能 | 端点 |
|---|---|---|
listFiles() | 列出本地和远程文件 | GET /v1/sftp/files |
uploadRemoteFiles() | 批量上传到远程 | POST /v1/sftp/remote-files/upload |
upload() | 单文件上传 | 内部调用 |
download() | 从远程下载 | 内部调用 |
remove() | 删除远程文件 | 内部调用 |
凭据管理: SFTP连接信息通过 AgencyConfiguration.SFTP 配置:
agency.sftp.host
agency.sftp.username
agency.sftp.password
agency.sftp.remoteDirectory关键问题:
- 禁用主机密钥验证:
setupSshj()(line 140) 使用PromiscuousVerifier,完全跳过SSH主机密钥验证,容易受到中间人攻击。这是严重安全隐患。 - 每次操作新建连接: 每个SFTP操作(list/upload/download/remove)都创建新的SSH连接 (line 138-158),没有连接池,连接建立开销大。
- 密码明文存储: SSH密码直接存储在Spring Cloud Config中 (AgencyConfiguration.SFTP line 77),不支持密钥认证。
- 异常处理混乱:
upload()(line 116-136) 和uploadRemoteFiles()(line 86-114) 在finally中抛出RuntimeException,可能掩盖原始异常。 listFiles()中的NPE风险: (line 40)localDirectory.listFiles()可能返回null(目录不可读时),导致NullPointerException。
2.7 CloudConvert
集成模式: 使用CloudConvert Java SDK将DOCX文档转换为PDF。仅在文档模板服务中使用。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/xdoc/service/DocumentTemplateService.java(line 889-930)
转换流程:
- Upload Import - 上传本地DOCX文件
- Convert Task - 请求DOCX到PDF转换
- Wait - 等待转换完成
- URL Export - 获取转换后文件的下载URL
- Wait - 等待导出完成
- Download - 下载PDF文件流
关键问题:
- 每次转换创建新客户端:
new CloudConvertClient()(line 891) 每次都创建新实例,无连接复用。 - 凭据配置不明: CloudConvert客户端未显式传入API密钥,依赖SDK的默认配置机制(环境变量或属性文件),不可控。
- 同步阻塞调用:
cloudConvertClient.tasks().wait()(line 908, 917) 是阻塞调用,长文档转换可能阻塞线程较长时间。 - 异常被吞没:
catch (Exception e)(line 927) 仅记录日志,不抛出异常,调用方无法知道转换失败。 - 无超时控制: 没有对转换等待设置超时,CloudConvert服务不可用时线程会无限期阻塞。
2.8 SSO/SAML (Azure AD, Okta)
集成模式: 独立部署的SAML 2.0 Service Provider,接收来自Azure AD和Okta的SAML断言,生成应用级认证Cookie。
核心文件:
pharmagin-api/pharmagin-sso/src/main/java/com/pharmagin/sso/config/SecurityConfig.java- Spring Security SAML配置pharmagin-api/pharmagin-sso/src/main/java/com/pharmagin/sso/config/CustomAuthenticationSuccessHandler.java- 认证成功处理器pharmagin-api/pharmagin-sso/src/main/java/com/pharmagin/sso/config/PharmaginSsoProperties.java- SSO配置属性pharmagin-api/pharmagin-sso/src/main/java/com/pharmagin/sso/service/UserAuthService.java- Token生成存储pharmagin-api/pharmagin-sso/src/main/java/com/pharmagin/sso/controller/IdpSelectionController.java- IDP选择页
认证流程:
- 用户访问
/页面,展示IDP选择页面 (Thymeleaf模板idp-selection.html) - 用户选择IDP后发起SAML认证
- Spring Security SAML2 处理SAML Response
CustomAuthenticationSuccessHandler(line 51-71): a. 从SAML断言中提取用户邮箱 (line 73-143) b. 通过UserAuthService.generateAndStoreToken()生成UUID Token (line 38) c. Token存储到user_auth_token数据库表 d. 设置pharmaginAuthCookie,值为email|token格式 (line 146-149) e. 重定向到defaultRedirectUrl
Cookie配置:
- Cookie名称:
pharmaginAuth(默认)(PharmaginSsoProperties line 43) - 最大有效期:900秒 (15分钟) (line 44)
- HttpOnly和Secure被注释掉 (CustomAuthenticationSuccessHandler line 161-162)
IDP邮箱属性配置: pharmagin.sso.idp-email-attributes Map支持为不同IDP配置不同的SAML属性名用于提取邮箱 (PharmaginSsoProperties line 15)。如果属性不存在则回退到SAML NameID。
关键问题:
- Cookie安全标志被禁用:
HttpOnly和Secure标志被注释掉 (line 161-162),Cookie可被JavaScript读取且可在HTTP上传输,存在XSS和中间人攻击风险。 - Token无过期清理:
user_auth_token表中的Token只有生成逻辑,没有过期清理机制 (UserAuthService),随时间推移表会无限增长。 - Cookie值未加密: Cookie中直接包含明文邮箱 (line 147),虽然URL编码但未加密。
- 邮箱提取逻辑过于复杂:
getEmailFromAuthentication()(line 73-143) 有多层回退逻辑(SAML属性 -> NameID -> authentication.getName()),难以调试和维护。 - pharmagin-login未投产: 新版SSO服务
pharmagin-login(端口9113)已开发但因需要IDP配置更新未部署,两套SSO代码同时存在。
2.9 Mosaic
集成模式: 不是外部API调用,而是将内部数据库数据按Mosaic格式导出。通过自定义SQL查询从本地数据库读取数据,格式化后供Mosaic合规系统消费。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/mosaic/service/MosaicService.java- 数据导出服务pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/mosaic/response/MosaicEvent.java- 事件数据模型pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/mosaic/response/MosaicAttendee.java- 参会者数据模型pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/mapper/MosaicMapper.java- MyBatis Mapperpharmagin-api/pharmagin-web/src/main/resources/mybatis/MosaicMapper.xml- SQL映射
数据导出:
getEvents()- 导出所有会议事件(含开始时间、主办方、讲者、场地等)getAttendees()- 导出所有参会者(含注册状态、签到状态、食物消费、邮件退订状态等)
参会者状态映射: getAttendeeStatus() (line 58-89) 将内部多种状态映射为Mosaic标准状态:Attended, No show, Cancelled, Confirmed, Invited, Targeted, Waitlist。
配置: 通过 agency.enableMosaicIntegration 控制 (AgencyConfiguration line 61)。
2.10 Podio Webhook
集成模式: 当程序创建时,异步向Podio webhook URL发送POST请求,传递程序信息。包含Spring Retry重试机制。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/program/service/PodioService.java- 异步包装pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/program/service/PodioRetryService.java- 带重试的发送
重试策略:
- 最大尝试次数:3次 (PodioRetryService line 23)
- 退避策略:固定3秒延迟 (line 24)
- 恢复方法:仅记录日志 (line 40-42)
关键问题:
- URL为空时静默返回 (line 29-31),无法区分"未配置"和"配置为空"。
@Recover方法仅处理RestClientException,对其他异常类型不生效。
2.11 External API
集成模式: 提供给外部系统调用的REST API,支持认证和程序创建。这是平台向外暴露的API,而非调用外部系统。
核心文件:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/external/controller/ExternalAPIController.javapharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/external/service/ExternalAPIService.java
端点:
| 方法 | 端点 | 功能 |
|---|---|---|
| POST | /v1/external/authentication | 获取访问令牌 |
| POST | /v1/external/programs | 创建程序 |
3. Data Model Analysis
3.1 Entity Overview Table
| 实体/表 | 模块 | 数据库表 | 用途 |
|---|---|---|---|
| SendGridEmailActivity | sendgrid | sendgrid_email_activity | SendGrid邮件活动追踪 |
| UserAuthToken | sso | user_auth_token | SSO认证Token存储 |
| MosaicEvent | mosaic | (MyBatis查询) | Mosaic事件导出视图 |
| MosaicAttendee | mosaic | (MyBatis查询) | Mosaic参会者导出视图 |
| ContactResponse | sf | (内存对象) | Salesforce联系人响应 |
| EventRequest | sf | (内存对象) | Salesforce活动请求 |
| NpiRecord | sf | (内存对象) | NPI/联系人搜索结果 |
| VirtualProgramResponse | virtualProgram | (JSON字段) | Zoom会议信息(存于meeting_request.virtual_program_info) |
| VirtualProgramAttendeeResponse | virtualProgram | (JSON字段) | Zoom参会者信息(存于attendee.virtual_program_attendee_info) |
3.2 Data Model Issues
Zoom数据以JSON存储在关系字段中:
VirtualProgramResponse和VirtualProgramAttendeeResponse序列化为JSON存储在meeting_request和attendee表的 JSON 字段中,通过BeanUtil.convertMapToEntity()反序列化,无法进行SQL查询。Salesforce映射使用YAML而非数据库: 字段映射硬编码在10个YAML文件中,添加新客户需要新增YAML文件并重新部署。
SendGrid数据缺少关联:
sendgrid_email_activity表中的邮件记录与平台内部的邮件发送记录(communication模块)没有显式关联。UserAuthToken表缺少过期时间列: 仅有
email、token、created_at三列,没有expires_at列,无法通过SQL清理过期Token。
4. API Inventory
4.1 REST Endpoints Table
| 模块 | 方法 | 端点 | 描述 |
|---|---|---|---|
| virtualProgram | GET | /v1/virtualPrograms/attendees/{id}/resend | 重发Zoom邀请 |
| virtualProgram | PUT | /v1/virtualPrograms/attendees/ | 添加Zoom参会者 |
| virtualProgram | POST | /v1/virtualPrograms/meetings/ | 创建Zoom会议 |
| virtualProgram | GET | /v1/virtualPrograms/meetings/{id}/url | 获取Zoom加入链接 |
| virtualProgram | GET | /v1/virtualPrograms/meetings/{id}/zoom | 获取Zoom Web SDK信息 |
| zoom webhook | POST | /v1/webhooks/zoom/meeting/participant/join | Zoom参会者加入 |
| zoom webhook | POST | /v1/webhooks/zoom/meeting/participant/leave | Zoom参会者离开 |
| zoom webhook | POST | /v1/webhooks/zoom/meeting/ended | Zoom会议结束 |
| zoom webhook | POST | /v1/webhooks/zoom/webinar/participant/join | Zoom研讨会参会者加入 |
| zoom webhook | POST | /v1/webhooks/zoom/webinar/participant/leave | Zoom研讨会参会者离开 |
| zoom webhook | POST | /v1/webhooks/zoom/webinar/ended | Zoom研讨会结束 |
| sftp | GET | /v1/sftp/files | 列出SFTP文件 |
| sftp | POST | /v1/sftp/local-files/upload | 上传到本地 |
| sftp | POST | /v1/sftp/remote-files/upload | 上传到远程 |
| external | POST | /v1/external/authentication | 外部认证 |
| external | POST | /v1/external/programs | 外部创建程序 |
| sendgrid | (空) | /api/v1/sendgrid | 无端点 |
4.2 API Design Issues
- 路径前缀不一致: SendGrid Controller使用
/api/v1/sendgrid前缀,其他模块使用/v1/前缀。 - VirtualProgram Controller的Swagger标签错误:
@Api(tags = "User Management")(VirtualProgramController line 41),应该是 "Virtual Program Management"。 - Webhook端点URL设计: Zoom webhook使用
/v1/webhooks/zoom/meeting/participant/join这种深层路径结构,可能导致Zoom webhook配置复杂。通常一个端点处理所有事件类型更简洁。 - 缺少SFTP下载和删除端点:
SFTPService有download()和remove()方法,但Controller未暴露对应端点。
5. Integration Architecture Issues
5.1 Error Handling
当前状况:混乱且不一致
各集成的错误处理策略差异巨大:
| 集成 | 错误处理方式 | 问题 |
|---|---|---|
| Salesforce | IntegrationException (RuntimeException) + ce.printStackTrace() | 大量 printStackTrace() 代替结构化日志,部分异常被吞没 |
| Zoom | ServiceException 包装 | 所有HTTP错误统一处理,丢失原始状态码 |
| SendGrid | RuntimeException 包装 | 嵌套异常链冗长 |
| SFTP | RuntimeException 直接抛出 | finally块中也抛RuntimeException |
| CloudConvert | 异常完全吞没 | catch(Exception e) 仅日志不抛出 |
| Google API | 返回null | 调用方无法区分"未配置"和"调用失败" |
| Podio | Spring Retry @Recover | 仅日志,静默失败 |
具体问题:
ForceService.createEvent()(line 64) 使用log.error()记录正常请求数据,滥用ERROR级别。VeevaAccountService多个方法中catch (ConnectionException ce) { ce.printStackTrace(); }(line 79, 151, 199, 258) 使用printStackTrace()而非 SLF4J。SFTPService的uploadRemoteFiles()(line 107-109) 在finally块中抛出RuntimeException,可能掩盖在try块中抛出的原始异常。
5.2 Retry/Resilience Patterns
当前状况:大部分集成没有重试
| 集成 | 重试机制 | 断路器 | 超时控制 |
|---|---|---|---|
| Salesforce | SessionRenewer (自动续Session) | 无 | 无显式超时 |
| Zoom | 单次Token过期重试 | 无 | 无显式超时 |
| SendGrid | 无 | 无 | 无显式超时 |
| SFTP | 无 | 无 | 无连接超时 |
| CloudConvert | 无 | 无 | 无转换超时 |
| Google API | 无 | 无 | RestTemplate默认超时 |
| Podio | Spring Retry (3次, 3秒延迟) | 无 | 无 |
| SSO/SAML | 无 | 无 | N/A (被动接收) |
关键缺失:
- 没有任何集成使用断路器模式(Circuit Breaker),外部服务不可用时会持续尝试并阻塞线程。
- 除Podio外,没有系统化的重试策略(指数退避、抖动等)。
- Salesforce的
ProductionSessionRenewer(SfdcFacade line 301-312) 提供了Session自动续期,但不处理其他类型的连接错误。 - 没有任何集成有健康检查或连通性测试能力。
5.3 Security (Credential Management)
当前状况:凭据以明文存储在配置文件中
所有集成的凭据都通过Spring Cloud Config(pharmagin-config-repo)以YAML明文存储:
| 集成 | 凭据类型 | 存储位置 | 风险 |
|---|---|---|---|
| Salesforce | username + securityToken | agency.sf.* | 明文在配置文件 |
| Zoom S2S | s2sClientId + s2sClientSecret + s2sAccountId | product.virtualPrograms.zoomVirtualMeeting.* | 明文在配置文件 |
| Zoom SDK | meetingSDKClientId + meetingSDKClientSecret | 同上 | 明文在配置文件 |
| Zoom Webhook | eventSecretToken | 同上 | 明文在配置文件 |
| SendGrid | apiKey | agency.sendGrid.apiKey | 明文在配置文件 |
| SFTP | password | agency.sftp.password | 明文在配置文件 |
| api.key | agency.google.api.key | 明文在配置文件 | |
| CloudConvert | (未知) | SDK默认 | 不可控 |
| SSO SAML | 签名证书 | src/main/resources/credentials/ | 文件系统 |
关键问题:
- 没有使用任何密钥管理服务(如AWS Secrets Manager、HashiCorp Vault)。
- 配置文件中的密码没有加密(Spring Cloud Config支持
{cipher}前缀加密但未使用)。 - Salesforce的
securityToken字段实际传递的是password+token拼接值,字段命名误导 (SfdcFacade line 49, 288)。 - SFTP使用密码认证而非SSH密钥对 (SFTPService line 149)。
- SSO Cookie没有启用HttpOnly和Secure标志 (CustomAuthenticationSuccessHandler line 161-162)。
5.4 Monitoring/Logging
当前状况:日志记录不规范,无监控
日志问题:
- Salesforce模块使用
log.error()记录正常操作(如createEvent的请求内容,ForceService line 64),ERROR级别滥用。 - 多处使用
e.printStackTrace()代替SLF4J (VeevaAccountService line 79, 151, 258; DefaultAccountService line 83, 98, 202; SfdcFacade line 248)。 - 日志中可能泄露敏感信息:
SfdcFacade.getSFDCUserId()(line 209-210) 记录SFDC User ID,Zoom请求body可能包含API凭据。 - 缺少结构化日志(如MDC上下文、correlation ID)。
监控缺失:
- 无API调用指标(调用次数、延迟、错误率)。
- 无外部服务健康检查。
- 无告警机制(如Salesforce连接失败、Zoom Token获取失败等)。
- SendGrid增量同步无状态暴露(无法通过API查看最后同步时间、同步记录数等)。
6. Problem Summary
6.1 Critical Issues (重写必须修复)
| # | 问题 | 影响范围 | 严重程度 |
|---|---|---|---|
| C1 | SFTP禁用主机密钥验证 (PromiscuousVerifier) | SFTPService line 140 | 安全:中间人攻击风险 |
| C2 | SSO Cookie未设置HttpOnly/Secure标志 | CustomAuthenticationSuccessHandler line 161-162 | 安全:Cookie可被窃取 |
| C3 | 所有凭据明文存储在配置文件中 | 全部集成 | 安全:凭据泄露风险 |
| C4 | Salesforce SOQL查询拼接无参数化 | SfdcFacade line 203; DefaultAccountService line 169-185 | 安全:SOQL注入 |
| C5 | FieldMappingFactory静态单例与共享状态 | FieldMappingFactory line 40-41, 56-63 | 并发:多产品环境数据错乱 |
| C6 | SfdcFacade单例中共享PartnerConnection | SfdcFacade line 36-37, 52 | 并发:多线程连接冲突 |
| C7 | ZoomAccessTokenManager全局静态缓存 | ZoomAccessTokenManager line 12 | 并发:多产品Token混淆 |
6.2 Design Defects (应该改进)
| # | 问题 | 影响范围 |
|---|---|---|
| D1 | 无断路器/超时/限流机制 | 全部外部调用 |
| D2 | 错误处理不一致:printStackTrace vs log vs 吞没 | 全部集成 |
| D3 | 无统一的重试策略(仅Podio有) | Salesforce, Zoom, SendGrid, SFTP |
| D4 | ZoomVirtualMeetingService与ZoomWebinarService大量代码重复 | virtualProgram模块 |
| D5 | SimpleDateFormat非线程安全 | SendGridService line 41 |
| D6 | UserAuthToken表无过期清理机制 | SSO模块 |
| D7 | CloudConvert异常被完全吞没 | DocumentTemplateService line 927-929 |
| D8 | SendGrid URL未编码 | SendGridService line 155-161 |
| D9 | Google API使用自建RestTemplate无连接池 | GoogleAPIService line 19 |
| D10 | SFTP finally块中抛RuntimeException可能掩盖原始异常 | SFTPService line 107-109 |
| D11 | 外部API /v1/external/authentication 返回Token但无速率限制 | ExternalAPIService |
6.3 Technical Debt (锦上添花)
| # | 问题 | 影响范围 |
|---|---|---|
| T1 | Salesforce使用SOAP Partner API,应迁移到REST API | sf模块全部 |
| T2 | YAML字段映射应迁移到数据库可配置 | 10个YAML文件 |
| T3 | SendGridController完全为空 | SendGrid模块 |
| T4 | VirtualProgramController Swagger标签错误 | VirtualProgramController line 41 |
| T5 | 遗留的generateZoomSignature()方法未删除 | ZoomAbstractService line 221-243 |
| T6 | Veeva对象名硬编码(如Multichannel_Activity_vod__c) | VeevaAccountService line 40 |
| T7 | ForceService中log.error()用于记录正常请求数据 | ForceService line 64, 87 |
| T8 | 双SSO服务共存(pharmagin-sso + pharmagin-login) | SSO架构 |
| T9 | Google API返回无类型Map | GoogleAPIService line 31 |
| T10 | Mosaic导出使用SimpleDateFormat | MosaicService line 29 |
7. Rewrite Recommendations
7.1 Integration Architecture (统一集成架构)
建议引入统一的集成基础设施层:
IntegrationClient (统一抽象)
├── RetryPolicy (指数退避 + 抖动)
├── CircuitBreaker (断路器)
├── TimeoutPolicy (超时控制)
├── RateLimiter (限流)
├── Metrics (Micrometer指标)
└── CredentialProvider (密钥管理)技术选型建议:
- 使用 Resilience4j 替代手工重试,提供CircuitBreaker + Retry + TimeLimiter + RateLimiter
- 使用 AWS Secrets Manager 或 HashiCorp Vault 管理凭据
- 使用 Micrometer 采集集成指标并对接Prometheus/Grafana
- 使用 Spring WebClient (响应式) 替代
RestTemplate,提供更好的超时和错误处理
7.2 Salesforce Integration
- 从SOAP迁移到REST API: Salesforce SOAP Partner API是遗留接口,REST API更现代、性能更好
- 消除SOQL拼接: 使用参数化查询或至少严格的输入验证
- 按产品隔离连接: 每个产品/客户拥有独立的Salesforce连接,不共享单例
- 字段映射数据库化: 从YAML迁移到数据库表,支持运行时配置变更
- 异步化大批量同步: 使用消息队列(如SQS/RabbitMQ)处理大批量的Activity同步
7.3 Zoom Integration
- 合并Meeting和Webinar服务: 提取公共逻辑到基类,通过策略模式差异化URL路径
- 按产品隔离Token缓存: 使用
Map<productId, CachedToken>替代全局静态缓存 - 增强重试逻辑: 处理429(Rate Limit)响应,实现指数退避
- 合并Webhook端点: 使用单一端点按event_type路由,减少配置复杂性
- 删除遗留签名方法: 移除
generateZoomSignature()旧实现
7.4 SFTP
- 强制启用主机密钥验证: 配置已知主机密钥或使用证书认证
- SSH密钥认证替代密码: 使用私钥文件而非明文密码
- 引入连接池: 复用SSH连接避免频繁建连
- 增加超时和重试: 配置连接超时、读写超时
7.5 SSO
- 启用Cookie安全标志: 必须设置
HttpOnly=true和Secure=true - Token过期清理: 添加
expires_at列和定期清理Job - 统一到pharmagin-login: 完成IDP配置迁移,废弃pharmagin-sso
- 考虑使用标准Token: 用JWT替代自定义UUID Token,减少数据库依赖
7.6 SendGrid
- 修复URL编码: 使用
UriComponentsBuilder正确编码查询参数 - 修复SimpleDateFormat线程安全: 使用
DateTimeFormatter(线程安全) - 添加速率限制处理: 检测429响应并退避
- 限制递归深度: 添加最大递归深度参数防止栈溢出
- 实现Controller端点: 添加手动同步触发和状态查询API
7.7 凭据管理
所有集成的凭据应统一迁移到密钥管理服务:
- 开发环境可使用Spring Cloud Config的
{cipher}加密 - 生产环境应使用AWS Secrets Manager或Vault
- 支持密钥自动轮换
- 审计密钥访问日志
7.8 统一可观测性
- 结构化日志: 所有集成操作使用MDC记录correlation ID、集成名称、操作类型
- 指标采集: 每次外部调用记录延迟、成功/失败计数
- 健康检查端点: 暴露
/actuator/health中各集成的连通性检查 - 告警: 配置错误率/延迟阈值告警