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;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
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.vo.LeaiTokenVO;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 乐读派认证入口控制器
* 前端 iframe 模式的主入口
*/
@Slf4j
@Tag(name = "乐读派认证")
@RestController
@RequestMapping("/leai-auth")
@RequiredArgsConstructor
public class LeaiAuthController {
private final LeaiApiClient leaiApiClient;
private final LeaiConfig leaiConfig;
private final SysUserMapper sysUserMapper;
private final ISysUserService sysUserService;
/**
* 前端 iframe 主入口返回 token 信息 JSON
* GET /leai-auth/token
* 需要登录认证
*/
@GetMapping("/leai-auth/token")
public Result<Map<String, String>> getToken() {
@GetMapping("/token")
@Operation(summary = "获取乐读派创作 Token")
public Result<LeaiTokenVO> getToken() {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId);
SysUser user = sysUserService.getById(userId);
if (user == null) {
return Result.error(404, "用户不存在");
throw new BusinessException(404, "用户不存在");
}
String phone = user.getPhone();
if (phone == null || phone.isEmpty()) {
return Result.error(400, "用户未绑定手机号,无法使用创作功能");
throw new BusinessException(400, "用户未绑定手机号,无法使用创作功能");
}
try {
String token = leaiApiClient.exchangeToken(phone);
Map<String, String> data = new LinkedHashMap<>();
data.put("token", token);
data.put("orgId", leaiConfig.getOrgId());
data.put("h5Url", leaiConfig.getH5Url());
data.put("phone", phone);
// Entity VO 转换Controller 层负责
// 注意: orgId 对应本项目的租户 codetenant_code
LeaiTokenVO vo = new LeaiTokenVO();
vo.setToken(token);
vo.setOrgId(leaiConfig.getOrgId()); // 即租户 tenant_code
vo.setH5Url(leaiConfig.getH5Url());
vo.setPhone(phone);
log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone);
return Result.success(data);
return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[乐读派] 获取创作Token失败, userId={}", userId, e);
return Result.error(500, "获取创作Token失败: " + e.getMessage());
throw new BusinessException(500, "获取创作Token失败: " + e.getMessage());
}
}
/**
* 跳转模式备选 token + 302 重定向到 H5
* GET /leai-auth
* 需要登录认证
*/
@GetMapping("/leai-auth")
public void authRedirect(
@RequestParam(required = false) String returnPath,
HttpServletResponse response) throws IOException {
@GetMapping
@Operation(summary = "重定向到乐读派 H5 创作页")
public void authRedirect(LeaiAuthRedirectDTO dto, HttpServletResponse response) throws IOException {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId);
SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
response.sendError(401, "请先登录并绑定手机号");
return;
@ -94,8 +98,8 @@ public class LeaiAuthController {
.append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8))
.append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8));
if (returnPath != null && !returnPath.isEmpty()) {
url.append("&returnPath=").append(URLEncoder.encode(returnPath, StandardCharsets.UTF_8));
if (dto.getReturnPath() != null && !dto.getReturnPath().isEmpty()) {
url.append("&returnPath=").append(URLEncoder.encode(dto.getReturnPath(), StandardCharsets.UTF_8));
}
log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone);
@ -109,33 +113,35 @@ public class LeaiAuthController {
/**
* iframe Token 刷新接口
* GET /leai-auth/refresh-token
* 需要登录认证
* 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口
*/
@GetMapping("/leai-auth/refresh-token")
public Result<Map<String, String>> refreshToken() {
@GetMapping("/refresh-token")
@Operation(summary = "刷新乐读派 Token")
public Result<LeaiTokenVO> refreshToken() {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserMapper.selectById(userId);
SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
return Result.error(401, "请先登录并绑定手机号");
throw new BusinessException(401, "请先登录并绑定手机号");
}
String phone = user.getPhone();
try {
String token = leaiApiClient.exchangeToken(phone);
Map<String, String> data = new LinkedHashMap<>();
data.put("token", token);
data.put("orgId", leaiConfig.getOrgId());
data.put("phone", phone);
// Entity VO 转换Controller 层负责
LeaiTokenVO vo = new LeaiTokenVO();
vo.setToken(token);
vo.setOrgId(leaiConfig.getOrgId());
vo.setPhone(phone);
log.info("[乐读派] Token刷新成功, userId={}", userId);
return Result.success(data);
return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception 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;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
import com.competition.modules.leai.mapper.LeaiWebhookEventMapper;
import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.service.ILeaiWebhookEventService;
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.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.*;
/**
@ -19,19 +20,20 @@ import java.util.*;
* 无需认证由乐读派服务端调用通过 HMAC 签名验证
*/
@Slf4j
@Tag(name = "乐读派 Webhook")
@RestController
@RequiredArgsConstructor
public class LeaiWebhookController {
private final LeaiApiClient leaiApiClient;
private final LeaiSyncService leaiSyncService;
private final LeaiWebhookEventMapper webhookEventMapper;
private final ILeaiSyncService leaiSyncService;
private final ILeaiWebhookEventService webhookEventService;
private final ObjectMapper objectMapper;
/**
* 接收乐读派 Webhook 回调
* POST /webhook/leai
*
* <p>
* Header:
* X-Webhook-Id: 事件唯一 ID
* X-Webhook-Event: 事件类型 (work.status_changed / work.progress)
@ -39,6 +41,7 @@ public class LeaiWebhookController {
* X-Webhook-Signature: HMAC-SHA256={hex}
*/
@PostMapping("/webhook/leai")
@Operation(summary = "接收乐读派 Webhook 回调")
public Map<String, String> webhook(
@RequestBody String rawBody,
@RequestHeader("X-Webhook-Id") String webhookId,
@ -54,17 +57,15 @@ public class LeaiWebhookController {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
log.warn("[Webhook] 时间戳格式错误: {}", timestamp);
throw new RuntimeException("时间戳格式错误");
throw new BusinessException(400, "时间戳格式错误");
}
if (Math.abs(System.currentTimeMillis() - ts) > 300_000) {
log.warn("[Webhook] 时间戳已过期: {}", timestamp);
throw new RuntimeException("时间戳已过期");
throw new BusinessException(400, "时间戳已过期");
}
// 2. 幂等去重
LambdaQueryWrapper<LeaiWebhookEvent> dupCheck = new LambdaQueryWrapper<>();
dupCheck.eq(LeaiWebhookEvent::getEventId, webhookId);
if (webhookEventMapper.selectCount(dupCheck) > 0) {
if (webhookEventService.existsByEventId(webhookId)) {
log.info("[Webhook] 重复事件,跳过: {}", webhookId);
return Collections.singletonMap("status", "duplicate");
}
@ -72,7 +73,7 @@ public class LeaiWebhookController {
// 3. 验证 HMAC-SHA256 签名
if (!leaiApiClient.verifyWebhookSignature(webhookId, timestamp, rawBody, signature)) {
log.warn("[Webhook] 签名验证失败! webhookId={}", webhookId);
throw new RuntimeException("签名验证失败");
throw new BusinessException(401, "签名验证失败");
}
// 4. 解析事件 payload
@ -81,17 +82,17 @@ public class LeaiWebhookController {
payload = objectMapper.readValue(rawBody, new TypeReference<Map<String, Object>>() {});
} catch (Exception 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")
Map<String, Object> data = (Map<String, Object>) payload.get("data");
if (data == null) {
data = new HashMap<>();
}
String remoteWorkId = toString(data.get("work_id"), null);
String remoteWorkId = LeaiUtil.toString(data.get("work_id"), null);
// 5. V4.0 同步规则处理
if (remoteWorkId != null && !remoteWorkId.isEmpty()) {
@ -103,19 +104,8 @@ public class LeaiWebhookController {
}
// 6. 记录事件幂等去重
LeaiWebhookEvent webhookEventEntity = new LeaiWebhookEvent();
webhookEventEntity.setEventId(webhookId);
webhookEventEntity.setEventType(webhookEvent);
webhookEventEntity.setRemoteWorkId(remoteWorkId);
webhookEventEntity.setPayload(payload);
webhookEventEntity.setProcessed(1);
webhookEventEntity.setCreateTime(LocalDateTime.now());
webhookEventMapper.insert(webhookEventEntity);
webhookEventService.saveEvent(webhookId, webhookEvent, remoteWorkId, payload);
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;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 乐读派 Webhook 事件去重表
* <p>
* 注意此表为追加-only日志表不需要 BaseEntity 的审计字段updateBymodifyTimedeleted
* 因此独立定义 id createTime不继承 BaseEntity
*/
@Data
@EqualsAndHashCode(callSuper = false)
@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;
/** 事件唯一ID (X-Webhook-Id) */
@Schema(description = "事件唯一IDX-Webhook-Id")
@TableField("event_id")
private String eventId;
/** 事件类型 */
@Schema(description = "事件类型")
@TableField("event_type")
private String eventType;
/** 乐读派作品ID */
@Schema(description = "乐读派作品ID")
@TableField("remote_work_id")
private String remoteWorkId;
/** 事件原始载荷 */
@Schema(description = "事件原始载荷")
@TableField(value = "payload", typeHandler = JacksonTypeHandler.class)
private Object payload;
/** 是否已处理 */
@Schema(description = "是否已处理0-未处理1-已处理")
@TableField("processed")
private Integer processed;
/** 创建时间 */
@Schema(description = "创建时间")
@TableField("create_time")
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;
import com.competition.common.exception.BusinessException;
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.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@ -64,30 +66,30 @@ public class LeaiApiClient {
Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0);
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
throw new RuntimeException("令牌交换失败: code=" + code
+ ", msg=" + toString(result.get("msg"), "unknown"));
throw new BusinessException(502, "令牌交换失败: code=" + code
+ ", msg=" + LeaiUtil.toString(result.get("msg"), "unknown"));
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get("data");
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()) {
throw new RuntimeException("令牌交换失败: sessionToken 为空");
throw new BusinessException(502, "令牌交换失败: sessionToken 为空");
}
log.info("[乐读派] 令牌交换成功, phone={}, expiresIn={}s", phone, data.get("expiresIn"));
return sessionToken;
} catch (RuntimeException e) {
} catch (BusinessException e) {
throw 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(),
new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0);
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
log.warn("[乐读派] B2查询失败: workId={}, code={}", workId, code);
return null;
@ -166,7 +168,7 @@ public class LeaiApiClient {
Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {});
int code = toInt(result.get("code"), 0);
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
log.warn("[乐读派] B3查询失败: code={}, msg={}", code, result.get("msg"));
return Collections.emptyList();
@ -245,7 +247,7 @@ public class LeaiApiClient {
}
return sb.toString();
} 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)
.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.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.UgcWorkPage;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
@ -22,12 +22,11 @@ import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class LeaiSyncService {
public class LeaiSyncService implements ILeaiSyncService {
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final LeaiApiClient leaiApiClient;
private final LeaiConfig leaiConfig;
/**
* V4.0 核心同步逻辑
@ -42,10 +41,11 @@ public class LeaiSyncService {
* @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果
* @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source) {
int remoteStatus = toInt(remoteData.get("status"), 0);
String phone = toString(remoteData.get("phone"), null);
int remoteStatus = LeaiUtil.toInt(remoteData.get("status"), 0);
String phone = LeaiUtil.toString(remoteData.get("phone"), null);
// 查找本地作品通过 remoteWorkId
UgcWork localWork = findByRemoteWorkId(remoteWorkId);
@ -94,8 +94,8 @@ public class LeaiSyncService {
private void insertNewWork(String remoteWorkId, Map<String, Object> remoteData, String phone) {
UgcWork work = new UgcWork();
work.setRemoteWorkId(remoteWorkId);
work.setTitle(toString(remoteData.get("title"), "未命名作品"));
work.setStatus(toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING));
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
work.setStatus(LeaiUtil.toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING));
work.setVisibility("private");
work.setIsDeleted(0);
work.setIsRecommended(false);
@ -104,11 +104,11 @@ public class LeaiSyncService {
work.setFavoriteCount(0);
work.setCommentCount(0);
work.setShareCount(0);
work.setProgress(toInt(remoteData.get("progress"), 0));
work.setProgressMessage(toString(remoteData.get("progressMessage"), null));
work.setStyle(toString(remoteData.get("style"), null));
work.setAuthorName(toString(remoteData.get("author"), null));
work.setFailReason(toString(remoteData.get("failReason"), null));
work.setProgress(LeaiUtil.toInt(remoteData.get("progress"), 0));
work.setProgressMessage(LeaiUtil.toString(remoteData.get("progressMessage"), null));
work.setStyle(LeaiUtil.toString(remoteData.get("style"), null));
work.setAuthorName(LeaiUtil.toString(remoteData.get("author"), null));
work.setFailReason(LeaiUtil.toString(remoteData.get("failReason"), null));
work.setCreateTime(LocalDateTime.now());
work.setModifyTime(LocalDateTime.now());
@ -145,7 +145,7 @@ public class LeaiSyncService {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.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());
ugcWorkMapper.update(null, wrapper);
}
@ -159,10 +159,10 @@ public class LeaiSyncService {
.set(UgcWork::getStatus, LeaiApiClient.STATUS_PROCESSING);
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")) {
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());
@ -181,22 +181,22 @@ public class LeaiSyncService {
// 更新可变字段
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")) {
wrapper.set(UgcWork::getProgress, toInt(remoteData.get("progress"), 0));
wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
}
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")) {
wrapper.set(UgcWork::getStyle, toString(remoteData.get("style"), null));
wrapper.set(UgcWork::getStyle, LeaiUtil.toString(remoteData.get("style"), null));
}
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")) {
wrapper.set(UgcWork::getFailReason, toString(remoteData.get("failReason"), null));
wrapper.set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), null));
}
Object coverUrl = remoteData.get("coverUrl");
if (coverUrl == null) coverUrl = remoteData.get("cover_url");
@ -247,9 +247,9 @@ public class LeaiSyncService {
UgcWorkPage page = new UgcWorkPage();
page.setWorkId(workId);
page.setPageNo(i + 1);
page.setImageUrl(toString(pageData.get("imageUrl"), null));
page.setText(toString(pageData.get("text"), null));
page.setAudioUrl(toString(pageData.get("audioUrl"), null));
page.setImageUrl(LeaiUtil.toString(pageData.get("imageUrl"), null));
page.setText(LeaiUtil.toString(pageData.get("text"), null));
page.setAudioUrl(LeaiUtil.toString(pageData.get("audioUrl"), null));
ugcWorkPageMapper.insert(page);
}
@ -275,14 +275,4 @@ public class LeaiSyncService {
// 当前简化处理直接通过手机号查用户
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;
import com.competition.modules.leai.service.ILeaiSyncService;
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.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
@ -13,7 +14,7 @@ import java.util.Map;
/**
* B3 定时对账任务
* 30 分钟调用 B3 接口对账补偿 Webhook 遗漏
*
* <p>
* 查询范围最近 2 小时内更新的作品覆盖 2 个对账周期确保不遗漏边界数据
*/
@Slf4j
@ -22,7 +23,7 @@ import java.util.Map;
public class LeaiReconcileTask {
private final LeaiApiClient leaiApiClient;
private final LeaiSyncService leaiSyncService;
private final ILeaiSyncService leaiSyncService;
/**
* 每30分钟执行一次初始延迟60秒
@ -42,10 +43,10 @@ public class LeaiReconcileTask {
int synced = 0;
for (Map<String, Object> work : works) {
String workId = toString(work.get("workId"), null);
if (workId == null) continue;
int remoteStatus = toInt(work.get("status"), 0);
String workId = LeaiUtil.toString(work.get("workId"), null);
if (workId == null) {
continue;
}
// 尝试调 B2 获取完整数据
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId);
@ -73,14 +74,4 @@ public class LeaiReconcileTask {
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;
}