diff --git a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java index 12a7ae7..6e807e5 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java +++ b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiAuthController.java @@ -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> getToken() { + @GetMapping("/token") + @Operation(summary = "获取乐读派创作 Token") + public Result 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 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 对应本项目的租户 code(tenant_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> refreshToken() { + @GetMapping("/refresh-token") + @Operation(summary = "刷新乐读派 Token") + public Result 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 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()); } } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiWebhookController.java b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiWebhookController.java index 8cf2536..81fb7d1 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiWebhookController.java +++ b/backend-java/src/main/java/com/competition/modules/leai/controller/LeaiWebhookController.java @@ -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 - * + *

* 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 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 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>() {}); } 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 data = (Map) 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; - } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java b/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java new file mode 100644 index 0000000..ed0505b --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/dto/LeaiAuthRedirectDTO.java @@ -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; +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/entity/LeaiWebhookEvent.java b/backend-java/src/main/java/com/competition/modules/leai/entity/LeaiWebhookEvent.java index 54a3190..701d4ac 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/entity/LeaiWebhookEvent.java +++ b/backend-java/src/main/java/com/competition/modules/leai/entity/LeaiWebhookEvent.java @@ -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 事件去重表 + *

+ * 注意:此表为追加-only日志表,不需要 BaseEntity 的审计字段(updateBy、modifyTime、deleted 等), + * 因此独立定义 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 = "事件唯一ID(X-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; } diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiSyncService.java b/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiSyncService.java new file mode 100644 index 0000000..d704550 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiSyncService.java @@ -0,0 +1,26 @@ +package com.competition.modules.leai.service; + +import java.util.Map; + +/** + * 乐读派作品同步 Service 接口 + *

+ * Webhook 回调和 B3 对账共用此服务 + */ +public interface ILeaiSyncService { + + /** + * 同步乐读派作品到本地 + *

+ * 同步规则: + * - 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 remoteData, String source); +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiWebhookEventService.java b/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiWebhookEventService.java new file mode 100644 index 0000000..a2193c8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/service/ILeaiWebhookEventService.java @@ -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 { + + /** + * 检查事件是否已存在(幂等去重) + * + * @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); +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java index aafd5ad..ec9da62 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java @@ -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 result = objectMapper.readValue(response.getBody(), new TypeReference>() {}); - 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 data = (Map) 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 result = objectMapper.readValue(response.getBody(), new TypeReference>() {}); - 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 result = objectMapper.readValue(response.getBody(), new TypeReference>() {}); - 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; - } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java index 5378189..81353d7 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java @@ -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 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 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 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; - } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/impl/LeaiWebhookEventServiceImpl.java b/backend-java/src/main/java/com/competition/modules/leai/service/impl/LeaiWebhookEventServiceImpl.java new file mode 100644 index 0000000..5064a11 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/service/impl/LeaiWebhookEventServiceImpl.java @@ -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 + implements ILeaiWebhookEventService { + + @Override + public boolean existsByEventId(String eventId) { + LambdaQueryWrapper 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); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java index 0a9a4f2..fb4de58 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java +++ b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java @@ -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 遗漏 - * + *

* 查询范围:最近 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 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 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; - } } diff --git a/backend-java/src/main/java/com/competition/modules/leai/util/LeaiUtil.java b/backend-java/src/main/java/com/competition/modules/leai/util/LeaiUtil.java new file mode 100644 index 0000000..67e005e --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/util/LeaiUtil.java @@ -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; + } +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java b/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java new file mode 100644 index 0000000..7d766c4 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/vo/LeaiTokenVO.java @@ -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; +}