refactor: 乐读派(leai)模块规范化改造

按照项目 Java 后端规范对 leai 模块进行全面重构:

- 新增 ILeaiWebhookEventService/ILeaiSyncService 接口,遵循 IService 模式
- Controller 层通过 Service 接口调用,不再直接注入 Mapper
- 新增 LeaiTokenVO/LeaiAuthRedirectDTO,替代 Map<String,String> 入参出参
- RuntimeException 替换为 BusinessException
- 添加 @Tag/@Operation Swagger 注解
- 提取共享工具类 LeaiUtil,消除 4 处重复的 toInt/toString 方法
- LeaiWebhookEvent 实体添加 @Schema 注解

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-04-08 10:23:11 +08:00
parent 9b5c24c49c
commit bc7c17b281
12 changed files with 306 additions and 153 deletions

View File

@ -1,85 +1,89 @@
package com.competition.modules.leai.controller; package com.competition.modules.leai.controller;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.Result; import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil; import com.competition.common.util.SecurityUtil;
import com.competition.modules.leai.config.LeaiConfig; import com.competition.modules.leai.config.LeaiConfig;
import com.competition.modules.leai.dto.LeaiAuthRedirectDTO;
import com.competition.modules.leai.service.LeaiApiClient; import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.vo.LeaiTokenVO;
import com.competition.modules.sys.entity.SysUser; import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper; import com.competition.modules.sys.service.ISysUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/** /**
* 乐读派认证入口控制器 * 乐读派认证入口控制器
* 前端 iframe 模式的主入口 * 前端 iframe 模式的主入口
*/ */
@Slf4j @Slf4j
@Tag(name = "乐读派认证")
@RestController @RestController
@RequestMapping("/leai-auth")
@RequiredArgsConstructor @RequiredArgsConstructor
public class LeaiAuthController { public class LeaiAuthController {
private final LeaiApiClient leaiApiClient; private final LeaiApiClient leaiApiClient;
private final LeaiConfig leaiConfig; private final LeaiConfig leaiConfig;
private final SysUserMapper sysUserMapper; private final ISysUserService sysUserService;
/** /**
* 前端 iframe 主入口返回 token 信息 JSON * 前端 iframe 主入口返回 token 信息 JSON
* GET /leai-auth/token
* 需要登录认证 * 需要登录认证
*/ */
@GetMapping("/leai-auth/token") @GetMapping("/token")
public Result<Map<String, String>> getToken() { @Operation(summary = "获取乐读派创作 Token")
public Result<LeaiTokenVO> getToken() {
Long userId = SecurityUtil.getCurrentUserId(); Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId); SysUser user = sysUserService.getById(userId);
if (user == null) { if (user == null) {
return Result.error(404, "用户不存在"); throw new BusinessException(404, "用户不存在");
} }
String phone = user.getPhone(); String phone = user.getPhone();
if (phone == null || phone.isEmpty()) { if (phone == null || phone.isEmpty()) {
return Result.error(400, "用户未绑定手机号,无法使用创作功能"); throw new BusinessException(400, "用户未绑定手机号,无法使用创作功能");
} }
try { try {
String token = leaiApiClient.exchangeToken(phone); String token = leaiApiClient.exchangeToken(phone);
Map<String, String> data = new LinkedHashMap<>(); // Entity VO 转换Controller 层负责
data.put("token", token); // 注意: orgId 对应本项目的租户 codetenant_code
data.put("orgId", leaiConfig.getOrgId()); LeaiTokenVO vo = new LeaiTokenVO();
data.put("h5Url", leaiConfig.getH5Url()); vo.setToken(token);
data.put("phone", phone); vo.setOrgId(leaiConfig.getOrgId()); // 即租户 tenant_code
vo.setH5Url(leaiConfig.getH5Url());
vo.setPhone(phone);
log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone); log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone);
return Result.success(data); return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("[乐读派] 获取创作Token失败, userId={}", userId, e); log.error("[乐读派] 获取创作Token失败, userId={}", userId, e);
return Result.error(500, "获取创作Token失败: " + e.getMessage()); throw new BusinessException(500, "获取创作Token失败: " + e.getMessage());
} }
} }
/** /**
* 跳转模式备选 token + 302 重定向到 H5 * 跳转模式备选 token + 302 重定向到 H5
* GET /leai-auth
* 需要登录认证 * 需要登录认证
*/ */
@GetMapping("/leai-auth") @GetMapping
public void authRedirect( @Operation(summary = "重定向到乐读派 H5 创作页")
@RequestParam(required = false) String returnPath, public void authRedirect(LeaiAuthRedirectDTO dto, HttpServletResponse response) throws IOException {
HttpServletResponse response) throws IOException {
Long userId = SecurityUtil.getCurrentUserId(); Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId); SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) { if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
response.sendError(401, "请先登录并绑定手机号"); response.sendError(401, "请先登录并绑定手机号");
return; return;
@ -94,8 +98,8 @@ public class LeaiAuthController {
.append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8)) .append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8))
.append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8)); .append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8));
if (returnPath != null && !returnPath.isEmpty()) { if (dto.getReturnPath() != null && !dto.getReturnPath().isEmpty()) {
url.append("&returnPath=").append(URLEncoder.encode(returnPath, StandardCharsets.UTF_8)); url.append("&returnPath=").append(URLEncoder.encode(dto.getReturnPath(), StandardCharsets.UTF_8));
} }
log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone); log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone);
@ -109,33 +113,35 @@ public class LeaiAuthController {
/** /**
* iframe Token 刷新接口 * iframe Token 刷新接口
* GET /leai-auth/refresh-token
* 需要登录认证
* 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口 * 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口
*/ */
@GetMapping("/leai-auth/refresh-token") @GetMapping("/refresh-token")
public Result<Map<String, String>> refreshToken() { @Operation(summary = "刷新乐读派 Token")
public Result<LeaiTokenVO> refreshToken() {
Long userId = SecurityUtil.getCurrentUserId(); Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId); SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) { if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
return Result.error(401, "请先登录并绑定手机号"); throw new BusinessException(401, "请先登录并绑定手机号");
} }
String phone = user.getPhone(); String phone = user.getPhone();
try { try {
String token = leaiApiClient.exchangeToken(phone); String token = leaiApiClient.exchangeToken(phone);
Map<String, String> data = new LinkedHashMap<>(); // Entity VO 转换Controller 层负责
data.put("token", token); LeaiTokenVO vo = new LeaiTokenVO();
data.put("orgId", leaiConfig.getOrgId()); vo.setToken(token);
data.put("phone", phone); vo.setOrgId(leaiConfig.getOrgId());
vo.setPhone(phone);
log.info("[乐读派] Token刷新成功, userId={}", userId); log.info("[乐读派] Token刷新成功, userId={}", userId);
return Result.success(data); return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("[乐读派] Token刷新失败, userId={}", userId, e); log.error("[乐读派] Token刷新失败, userId={}", userId, e);
return Result.error(500, "Token刷新失败: " + e.getMessage()); throw new BusinessException(500, "Token刷新失败: " + e.getMessage());
} }
} }
} }

View File

@ -1,17 +1,18 @@
package com.competition.modules.leai.controller; package com.competition.modules.leai.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.entity.LeaiWebhookEvent; import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.mapper.LeaiWebhookEventMapper; import com.competition.modules.leai.service.ILeaiWebhookEventService;
import com.competition.modules.leai.service.LeaiApiClient; import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.service.LeaiSyncService; import com.competition.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** /**
@ -19,19 +20,20 @@ import java.util.*;
* 无需认证由乐读派服务端调用通过 HMAC 签名验证 * 无需认证由乐读派服务端调用通过 HMAC 签名验证
*/ */
@Slf4j @Slf4j
@Tag(name = "乐读派 Webhook")
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
public class LeaiWebhookController { public class LeaiWebhookController {
private final LeaiApiClient leaiApiClient; private final LeaiApiClient leaiApiClient;
private final LeaiSyncService leaiSyncService; private final ILeaiSyncService leaiSyncService;
private final LeaiWebhookEventMapper webhookEventMapper; private final ILeaiWebhookEventService webhookEventService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** /**
* 接收乐读派 Webhook 回调 * 接收乐读派 Webhook 回调
* POST /webhook/leai * POST /webhook/leai
* * <p>
* Header: * Header:
* X-Webhook-Id: 事件唯一 ID * X-Webhook-Id: 事件唯一 ID
* X-Webhook-Event: 事件类型 (work.status_changed / work.progress) * X-Webhook-Event: 事件类型 (work.status_changed / work.progress)
@ -39,6 +41,7 @@ public class LeaiWebhookController {
* X-Webhook-Signature: HMAC-SHA256={hex} * X-Webhook-Signature: HMAC-SHA256={hex}
*/ */
@PostMapping("/webhook/leai") @PostMapping("/webhook/leai")
@Operation(summary = "接收乐读派 Webhook 回调")
public Map<String, String> webhook( public Map<String, String> webhook(
@RequestBody String rawBody, @RequestBody String rawBody,
@RequestHeader("X-Webhook-Id") String webhookId, @RequestHeader("X-Webhook-Id") String webhookId,
@ -54,17 +57,15 @@ public class LeaiWebhookController {
ts = Long.parseLong(timestamp); ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
log.warn("[Webhook] 时间戳格式错误: {}", timestamp); log.warn("[Webhook] 时间戳格式错误: {}", timestamp);
throw new RuntimeException("时间戳格式错误"); throw new BusinessException(400, "时间戳格式错误");
} }
if (Math.abs(System.currentTimeMillis() - ts) > 300_000) { if (Math.abs(System.currentTimeMillis() - ts) > 300_000) {
log.warn("[Webhook] 时间戳已过期: {}", timestamp); log.warn("[Webhook] 时间戳已过期: {}", timestamp);
throw new RuntimeException("时间戳已过期"); throw new BusinessException(400, "时间戳已过期");
} }
// 2. 幂等去重 // 2. 幂等去重
LambdaQueryWrapper<LeaiWebhookEvent> dupCheck = new LambdaQueryWrapper<>(); if (webhookEventService.existsByEventId(webhookId)) {
dupCheck.eq(LeaiWebhookEvent::getEventId, webhookId);
if (webhookEventMapper.selectCount(dupCheck) > 0) {
log.info("[Webhook] 重复事件,跳过: {}", webhookId); log.info("[Webhook] 重复事件,跳过: {}", webhookId);
return Collections.singletonMap("status", "duplicate"); return Collections.singletonMap("status", "duplicate");
} }
@ -72,7 +73,7 @@ public class LeaiWebhookController {
// 3. 验证 HMAC-SHA256 签名 // 3. 验证 HMAC-SHA256 签名
if (!leaiApiClient.verifyWebhookSignature(webhookId, timestamp, rawBody, signature)) { if (!leaiApiClient.verifyWebhookSignature(webhookId, timestamp, rawBody, signature)) {
log.warn("[Webhook] 签名验证失败! webhookId={}", webhookId); log.warn("[Webhook] 签名验证失败! webhookId={}", webhookId);
throw new RuntimeException("签名验证失败"); throw new BusinessException(401, "签名验证失败");
} }
// 4. 解析事件 payload // 4. 解析事件 payload
@ -81,17 +82,17 @@ public class LeaiWebhookController {
payload = objectMapper.readValue(rawBody, new TypeReference<Map<String, Object>>() {}); payload = objectMapper.readValue(rawBody, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) { } catch (Exception e) {
log.error("[Webhook] payload 解析失败", e); log.error("[Webhook] payload 解析失败", e);
throw new RuntimeException("payload 解析失败"); throw new BusinessException(400, "payload 解析失败");
} }
String event = toString(payload.get("event"), ""); String event = LeaiUtil.toString(payload.get("event"), "");
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) payload.get("data"); Map<String, Object> data = (Map<String, Object>) payload.get("data");
if (data == null) { if (data == null) {
data = new HashMap<>(); data = new HashMap<>();
} }
String remoteWorkId = toString(data.get("work_id"), null); String remoteWorkId = LeaiUtil.toString(data.get("work_id"), null);
// 5. V4.0 同步规则处理 // 5. V4.0 同步规则处理
if (remoteWorkId != null && !remoteWorkId.isEmpty()) { if (remoteWorkId != null && !remoteWorkId.isEmpty()) {
@ -103,19 +104,8 @@ public class LeaiWebhookController {
} }
// 6. 记录事件幂等去重 // 6. 记录事件幂等去重
LeaiWebhookEvent webhookEventEntity = new LeaiWebhookEvent(); webhookEventService.saveEvent(webhookId, webhookEvent, remoteWorkId, payload);
webhookEventEntity.setEventId(webhookId);
webhookEventEntity.setEventType(webhookEvent);
webhookEventEntity.setRemoteWorkId(remoteWorkId);
webhookEventEntity.setPayload(payload);
webhookEventEntity.setProcessed(1);
webhookEventEntity.setCreateTime(LocalDateTime.now());
webhookEventMapper.insert(webhookEventEntity);
return Collections.singletonMap("status", "ok"); return Collections.singletonMap("status", "ok");
} }
private static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
} }

View File

@ -0,0 +1,15 @@
package com.competition.modules.leai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 乐读派重定向请求 DTO
*/
@Data
@Schema(description = "乐读派重定向请求")
public class LeaiAuthRedirectDTO {
@Schema(description = "重定向后的路径")
private String returnPath;
}

View File

@ -1,45 +1,51 @@
package com.competition.modules.leai.entity; package com.competition.modules.leai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 乐读派 Webhook 事件去重表 * 乐读派 Webhook 事件去重表
* <p>
* 注意此表为追加-only日志表不需要 BaseEntity 的审计字段updateBymodifyTimedeleted
* 因此独立定义 id createTime不继承 BaseEntity
*/ */
@Data @Data
@EqualsAndHashCode(callSuper = false)
@TableName(value = "t_leai_webhook_event", autoResultMap = true) @TableName(value = "t_leai_webhook_event", autoResultMap = true)
public class LeaiWebhookEvent implements Serializable { @Schema(description = "乐读派 Webhook 事件")
public class LeaiWebhookEvent {
@TableId(type = IdType.AUTO) @Schema(description = "主键ID")
@TableField("id")
private Long id; private Long id;
/** 事件唯一ID (X-Webhook-Id) */ @Schema(description = "事件唯一IDX-Webhook-Id")
@TableField("event_id") @TableField("event_id")
private String eventId; private String eventId;
/** 事件类型 */ @Schema(description = "事件类型")
@TableField("event_type") @TableField("event_type")
private String eventType; private String eventType;
/** 乐读派作品ID */ @Schema(description = "乐读派作品ID")
@TableField("remote_work_id") @TableField("remote_work_id")
private String remoteWorkId; private String remoteWorkId;
/** 事件原始载荷 */ @Schema(description = "事件原始载荷")
@TableField(value = "payload", typeHandler = JacksonTypeHandler.class) @TableField(value = "payload", typeHandler = JacksonTypeHandler.class)
private Object payload; private Object payload;
/** 是否已处理 */ @Schema(description = "是否已处理0-未处理1-已处理")
@TableField("processed")
private Integer processed; private Integer processed;
/** 创建时间 */ @Schema(description = "创建时间")
@TableField("create_time") @TableField("create_time")
private LocalDateTime createTime; private LocalDateTime createTime;
} }

View File

@ -0,0 +1,26 @@
package com.competition.modules.leai.service;
import java.util.Map;
/**
* 乐读派作品同步 Service 接口
* <p>
* Webhook 回调和 B3 对账共用此服务
*/
public interface ILeaiSyncService {
/**
* 同步乐读派作品到本地
* <p>
* 同步规则
* - remoteStatus == -1 强制更新FAILED
* - remoteStatus == 2 强制更新PROCESSING 进度变化
* - remoteStatus > localStatus 全量更新状态前进
* - else 忽略
*
* @param remoteWorkId 乐读派作品ID
* @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果
* @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/
void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source);
}

View File

@ -0,0 +1,28 @@
package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
/**
* 乐读派 Webhook 事件 Service 接口
*/
public interface ILeaiWebhookEventService extends IService<LeaiWebhookEvent> {
/**
* 检查事件是否已存在幂等去重
*
* @param eventId 事件唯一ID
* @return true-已存在重复事件
*/
boolean existsByEventId(String eventId);
/**
* 保存 Webhook 事件记录
*
* @param eventId 事件唯一ID
* @param eventType 事件类型
* @param remoteWorkId 乐读派作品ID
* @param payload 事件载荷
*/
void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload);
}

View File

@ -1,6 +1,8 @@
package com.competition.modules.leai.service; package com.competition.modules.leai.service;
import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.config.LeaiConfig; import com.competition.modules.leai.config.LeaiConfig;
import com.competition.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -64,30 +66,30 @@ public class LeaiApiClient {
Map<String, Object> result = objectMapper.readValue(response.getBody(), Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {}); new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0); int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) { if (code != 200) {
throw new RuntimeException("令牌交换失败: code=" + code throw new BusinessException(502, "令牌交换失败: code=" + code
+ ", msg=" + toString(result.get("msg"), "unknown")); + ", msg=" + LeaiUtil.toString(result.get("msg"), "unknown"));
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get("data"); Map<String, Object> data = (Map<String, Object>) result.get("data");
if (data == null) { if (data == null) {
throw new RuntimeException("令牌交换失败: data 为 null"); throw new BusinessException(502, "令牌交换失败: data 为 null");
} }
String sessionToken = toString(data.get("sessionToken"), null); String sessionToken = LeaiUtil.toString(data.get("sessionToken"), null);
if (sessionToken == null || sessionToken.isEmpty()) { if (sessionToken == null || sessionToken.isEmpty()) {
throw new RuntimeException("令牌交换失败: sessionToken 为空"); throw new BusinessException(502, "令牌交换失败: sessionToken 为空");
} }
log.info("[乐读派] 令牌交换成功, phone={}, expiresIn={}s", phone, data.get("expiresIn")); log.info("[乐读派] 令牌交换成功, phone={}, expiresIn={}s", phone, data.get("expiresIn"));
return sessionToken; return sessionToken;
} catch (RuntimeException e) { } catch (BusinessException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("令牌交换请求失败: " + e.getMessage(), e); throw new BusinessException(502, "令牌交换请求失败: " + e.getMessage());
} }
} }
@ -116,7 +118,7 @@ public class LeaiApiClient {
Map<String, Object> result = objectMapper.readValue(response.getBody(), Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {}); new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0); int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) { if (code != 200) {
log.warn("[乐读派] B2查询失败: workId={}, code={}", workId, code); log.warn("[乐读派] B2查询失败: workId={}, code={}", workId, code);
return null; return null;
@ -166,7 +168,7 @@ public class LeaiApiClient {
Map<String, Object> result = objectMapper.readValue(response.getBody(), Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {}); new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0); int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) { if (code != 200) {
log.warn("[乐读派] B3查询失败: code={}, msg={}", code, result.get("msg")); log.warn("[乐读派] B3查询失败: code={}, msg={}", code, result.get("msg"));
return Collections.emptyList(); return Collections.emptyList();
@ -245,7 +247,7 @@ public class LeaiApiClient {
} }
return sb.toString(); return sb.toString();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("HMAC-SHA256 签名失败", e); throw new BusinessException(500, "HMAC-SHA256 签名失败");
} }
} }
@ -256,14 +258,4 @@ public class LeaiApiClient {
return ZonedDateTime.ofInstant(Instant.now().minusSeconds(7200), ZoneOffset.UTC) return ZonedDateTime.ofInstant(Instant.now().minusSeconds(7200), ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT); .format(DateTimeFormatter.ISO_INSTANT);
} }
private static int toInt(Object obj, int defaultVal) {
if (obj == null) return defaultVal;
if (obj instanceof Number) return ((Number) obj).intValue();
try { return Integer.parseInt(obj.toString()); } catch (Exception e) { return defaultVal; }
}
private static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
} }

View File

@ -2,7 +2,7 @@ package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.competition.modules.leai.config.LeaiConfig; import com.competition.modules.leai.util.LeaiUtil;
import com.competition.modules.ugc.entity.UgcWork; import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage; import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.mapper.UgcWorkMapper; import com.competition.modules.ugc.mapper.UgcWorkMapper;
@ -22,12 +22,11 @@ import java.util.*;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class LeaiSyncService { public class LeaiSyncService implements ILeaiSyncService {
private final UgcWorkMapper ugcWorkMapper; private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper; private final UgcWorkPageMapper ugcWorkPageMapper;
private final LeaiApiClient leaiApiClient; private final LeaiApiClient leaiApiClient;
private final LeaiConfig leaiConfig;
/** /**
* V4.0 核心同步逻辑 * V4.0 核心同步逻辑
@ -42,10 +41,11 @@ public class LeaiSyncService {
* @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果 * @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果
* @param source 来源标识用于日志 "Webhook[work.status_changed]" * @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/ */
@Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source) { public void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source) {
int remoteStatus = toInt(remoteData.get("status"), 0); int remoteStatus = LeaiUtil.toInt(remoteData.get("status"), 0);
String phone = toString(remoteData.get("phone"), null); String phone = LeaiUtil.toString(remoteData.get("phone"), null);
// 查找本地作品通过 remoteWorkId // 查找本地作品通过 remoteWorkId
UgcWork localWork = findByRemoteWorkId(remoteWorkId); UgcWork localWork = findByRemoteWorkId(remoteWorkId);
@ -94,8 +94,8 @@ public class LeaiSyncService {
private void insertNewWork(String remoteWorkId, Map<String, Object> remoteData, String phone) { private void insertNewWork(String remoteWorkId, Map<String, Object> remoteData, String phone) {
UgcWork work = new UgcWork(); UgcWork work = new UgcWork();
work.setRemoteWorkId(remoteWorkId); work.setRemoteWorkId(remoteWorkId);
work.setTitle(toString(remoteData.get("title"), "未命名作品")); work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
work.setStatus(toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING)); work.setStatus(LeaiUtil.toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING));
work.setVisibility("private"); work.setVisibility("private");
work.setIsDeleted(0); work.setIsDeleted(0);
work.setIsRecommended(false); work.setIsRecommended(false);
@ -104,11 +104,11 @@ public class LeaiSyncService {
work.setFavoriteCount(0); work.setFavoriteCount(0);
work.setCommentCount(0); work.setCommentCount(0);
work.setShareCount(0); work.setShareCount(0);
work.setProgress(toInt(remoteData.get("progress"), 0)); work.setProgress(LeaiUtil.toInt(remoteData.get("progress"), 0));
work.setProgressMessage(toString(remoteData.get("progressMessage"), null)); work.setProgressMessage(LeaiUtil.toString(remoteData.get("progressMessage"), null));
work.setStyle(toString(remoteData.get("style"), null)); work.setStyle(LeaiUtil.toString(remoteData.get("style"), null));
work.setAuthorName(toString(remoteData.get("author"), null)); work.setAuthorName(LeaiUtil.toString(remoteData.get("author"), null));
work.setFailReason(toString(remoteData.get("failReason"), null)); work.setFailReason(LeaiUtil.toString(remoteData.get("failReason"), null));
work.setCreateTime(LocalDateTime.now()); work.setCreateTime(LocalDateTime.now());
work.setModifyTime(LocalDateTime.now()); work.setModifyTime(LocalDateTime.now());
@ -145,7 +145,7 @@ public class LeaiSyncService {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId()) wrapper.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, LeaiApiClient.STATUS_FAILED) .set(UgcWork::getStatus, LeaiApiClient.STATUS_FAILED)
.set(UgcWork::getFailReason, toString(remoteData.get("failReason"), "未知错误")) .set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), "未知错误"))
.set(UgcWork::getModifyTime, LocalDateTime.now()); .set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper); ugcWorkMapper.update(null, wrapper);
} }
@ -159,10 +159,10 @@ public class LeaiSyncService {
.set(UgcWork::getStatus, LeaiApiClient.STATUS_PROCESSING); .set(UgcWork::getStatus, LeaiApiClient.STATUS_PROCESSING);
if (remoteData.containsKey("progress")) { if (remoteData.containsKey("progress")) {
wrapper.set(UgcWork::getProgress, toInt(remoteData.get("progress"), 0)); wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
} }
if (remoteData.containsKey("progressMessage")) { if (remoteData.containsKey("progressMessage")) {
wrapper.set(UgcWork::getProgressMessage, toString(remoteData.get("progressMessage"), null)); wrapper.set(UgcWork::getProgressMessage, LeaiUtil.toString(remoteData.get("progressMessage"), null));
} }
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now()); wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
@ -181,22 +181,22 @@ public class LeaiSyncService {
// 更新可变字段 // 更新可变字段
if (remoteData.containsKey("title")) { if (remoteData.containsKey("title")) {
wrapper.set(UgcWork::getTitle, toString(remoteData.get("title"), null)); wrapper.set(UgcWork::getTitle, LeaiUtil.toString(remoteData.get("title"), null));
} }
if (remoteData.containsKey("progress")) { if (remoteData.containsKey("progress")) {
wrapper.set(UgcWork::getProgress, toInt(remoteData.get("progress"), 0)); wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
} }
if (remoteData.containsKey("progressMessage")) { if (remoteData.containsKey("progressMessage")) {
wrapper.set(UgcWork::getProgressMessage, toString(remoteData.get("progressMessage"), null)); wrapper.set(UgcWork::getProgressMessage, LeaiUtil.toString(remoteData.get("progressMessage"), null));
} }
if (remoteData.containsKey("style")) { if (remoteData.containsKey("style")) {
wrapper.set(UgcWork::getStyle, toString(remoteData.get("style"), null)); wrapper.set(UgcWork::getStyle, LeaiUtil.toString(remoteData.get("style"), null));
} }
if (remoteData.containsKey("author")) { if (remoteData.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, toString(remoteData.get("author"), null)); wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(remoteData.get("author"), null));
} }
if (remoteData.containsKey("failReason")) { if (remoteData.containsKey("failReason")) {
wrapper.set(UgcWork::getFailReason, toString(remoteData.get("failReason"), null)); wrapper.set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), null));
} }
Object coverUrl = remoteData.get("coverUrl"); Object coverUrl = remoteData.get("coverUrl");
if (coverUrl == null) coverUrl = remoteData.get("cover_url"); if (coverUrl == null) coverUrl = remoteData.get("cover_url");
@ -247,9 +247,9 @@ public class LeaiSyncService {
UgcWorkPage page = new UgcWorkPage(); UgcWorkPage page = new UgcWorkPage();
page.setWorkId(workId); page.setWorkId(workId);
page.setPageNo(i + 1); page.setPageNo(i + 1);
page.setImageUrl(toString(pageData.get("imageUrl"), null)); page.setImageUrl(LeaiUtil.toString(pageData.get("imageUrl"), null));
page.setText(toString(pageData.get("text"), null)); page.setText(LeaiUtil.toString(pageData.get("text"), null));
page.setAudioUrl(toString(pageData.get("audioUrl"), null)); page.setAudioUrl(LeaiUtil.toString(pageData.get("audioUrl"), null));
ugcWorkPageMapper.insert(page); ugcWorkPageMapper.insert(page);
} }
@ -275,14 +275,4 @@ public class LeaiSyncService {
// 当前简化处理直接通过手机号查用户 // 当前简化处理直接通过手机号查用户
return null; // 暂时不自动关联用户后续通过 phone + orgId 查询 return null; // 暂时不自动关联用户后续通过 phone + orgId 查询
} }
private static int toInt(Object obj, int defaultVal) {
if (obj == null) return defaultVal;
if (obj instanceof Number) return ((Number) obj).intValue();
try { return Integer.parseInt(obj.toString()); } catch (Exception e) { return defaultVal; }
}
private static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
} }

View File

@ -0,0 +1,41 @@
package com.competition.modules.leai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
import com.competition.modules.leai.mapper.LeaiWebhookEventMapper;
import com.competition.modules.leai.service.ILeaiWebhookEventService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 乐读派 Webhook 事件 Service 实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LeaiWebhookEventServiceImpl extends ServiceImpl<LeaiWebhookEventMapper, LeaiWebhookEvent>
implements ILeaiWebhookEventService {
@Override
public boolean existsByEventId(String eventId) {
LambdaQueryWrapper<LeaiWebhookEvent> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LeaiWebhookEvent::getEventId, eventId);
return count(wrapper) > 0;
}
@Override
public void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload) {
LeaiWebhookEvent event = new LeaiWebhookEvent();
event.setEventId(eventId);
event.setEventType(eventType);
event.setRemoteWorkId(remoteWorkId);
event.setPayload(payload);
event.setProcessed(1);
event.setCreateTime(LocalDateTime.now());
save(event);
}
}

View File

@ -1,7 +1,8 @@
package com.competition.modules.leai.task; package com.competition.modules.leai.task;
import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.service.LeaiApiClient; import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.service.LeaiSyncService; import com.competition.modules.leai.util.LeaiUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -13,7 +14,7 @@ import java.util.Map;
/** /**
* B3 定时对账任务 * B3 定时对账任务
* 30 分钟调用 B3 接口对账补偿 Webhook 遗漏 * 30 分钟调用 B3 接口对账补偿 Webhook 遗漏
* * <p>
* 查询范围最近 2 小时内更新的作品覆盖 2 个对账周期确保不遗漏边界数据 * 查询范围最近 2 小时内更新的作品覆盖 2 个对账周期确保不遗漏边界数据
*/ */
@Slf4j @Slf4j
@ -22,7 +23,7 @@ import java.util.Map;
public class LeaiReconcileTask { public class LeaiReconcileTask {
private final LeaiApiClient leaiApiClient; private final LeaiApiClient leaiApiClient;
private final LeaiSyncService leaiSyncService; private final ILeaiSyncService leaiSyncService;
/** /**
* 每30分钟执行一次初始延迟60秒 * 每30分钟执行一次初始延迟60秒
@ -42,10 +43,10 @@ public class LeaiReconcileTask {
int synced = 0; int synced = 0;
for (Map<String, Object> work : works) { for (Map<String, Object> work : works) {
String workId = toString(work.get("workId"), null); String workId = LeaiUtil.toString(work.get("workId"), null);
if (workId == null) continue; if (workId == null) {
continue;
int remoteStatus = toInt(work.get("status"), 0); }
// 尝试调 B2 获取完整数据 // 尝试调 B2 获取完整数据
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId); Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId);
@ -73,14 +74,4 @@ public class LeaiReconcileTask {
log.error("[B3对账] 执行异常", e); log.error("[B3对账] 执行异常", e);
} }
} }
private static int toInt(Object obj, int defaultVal) {
if (obj == null) return defaultVal;
if (obj instanceof Number) return ((Number) obj).intValue();
try { return Integer.parseInt(obj.toString()); } catch (Exception e) { return defaultVal; }
}
private static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
} }

View File

@ -0,0 +1,44 @@
package com.competition.modules.leai.util;
/**
* 乐读派模块共享工具类
* 提取多处重复使用的类型转换方法
*/
public final class LeaiUtil {
private LeaiUtil() {
// 工具类禁止实例化
}
/**
* 安全转换为 int
*
* @param obj 待转换对象
* @param defaultVal 默认值
* @return 转换结果
*/
public static int toInt(Object obj, int defaultVal) {
if (obj == null) {
return defaultVal;
}
if (obj instanceof Number) {
return ((Number) obj).intValue();
}
try {
return Integer.parseInt(obj.toString());
} catch (Exception e) {
return defaultVal;
}
}
/**
* 安全转换为 String
*
* @param obj 待转换对象
* @param defaultVal 默认值
* @return 转换结果
*/
public static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
}

View File

@ -0,0 +1,24 @@
package com.competition.modules.leai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 乐读派 Token 响应 VO
*/
@Data
@Schema(description = "乐读派 Token 信息")
public class LeaiTokenVO {
@Schema(description = "Session Token")
private String token;
@Schema(description = "机构ID对应本项目的租户 code即 tenant_code")
private String orgId;
@Schema(description = "H5 前端地址")
private String h5Url;
@Schema(description = "用户手机号")
private String phone;
}