diff --git a/.gitignore b/.gitignore index 667aaef..92a574b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ build/ ### VS Code ### .vscode/ + +# 本地密钥(钉钉机器人等),勿提交 +src/main/resources/application-local-dingtalk.yml + +# hxrd 订单接口联调 Cookie,勿提交 +src/test/resources/hxr-order-integration.properties diff --git a/src/main/java/com/rj/AISmartCard20251230Application.java b/src/main/java/com/rj/AISmartCard20251230Application.java index 5f9c89c..1337f10 100644 --- a/src/main/java/com/rj/AISmartCard20251230Application.java +++ b/src/main/java/com/rj/AISmartCard20251230Application.java @@ -1,6 +1,7 @@ package com.rj; import com.rj.config.AmapProperties; +import com.rj.config.HxrAdminProperties; import com.rj.config.LbAssessmentDingTalkProperties; import com.rj.config.YihangyiVllmAsrProperties; import org.mybatis.spring.annotation.MapperScan; @@ -41,7 +42,12 @@ import org.springframework.scheduling.annotation.EnableScheduling; @MapperScan("com.rj.mapper") @SpringBootApplication @EnableScheduling -@EnableConfigurationProperties({YihangyiVllmAsrProperties.class, AmapProperties.class, LbAssessmentDingTalkProperties.class}) +@EnableConfigurationProperties({ + YihangyiVllmAsrProperties.class, + AmapProperties.class, + LbAssessmentDingTalkProperties.class, + HxrAdminProperties.class +}) public class AISmartCard20251230Application { public static void main(String[] args) { diff --git a/src/main/java/com/rj/config/AppSchedulingConfiguration.java b/src/main/java/com/rj/config/AppSchedulingConfiguration.java index fd3a044..b86b6f6 100644 --- a/src/main/java/com/rj/config/AppSchedulingConfiguration.java +++ b/src/main/java/com/rj/config/AppSchedulingConfiguration.java @@ -1,10 +1,13 @@ package com.rj.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import java.time.Clock; + /** * 显式扩大 {@code @Scheduled} 线程池,避免单个耗时任务(如长时间 HTTP)占满默认单线程后拖死全部定时任务。 *

与 {@code spring.task.scheduling.pool.size} 配置互补;此处代码保证至少 8 个调度线程。 @@ -14,6 +17,11 @@ public class AppSchedulingConfiguration implements SchedulingConfigurer { private static final int SCHEDULER_POOL_SIZE = 8; + @Bean + public Clock applicationClock() { + return Clock.systemDefaultZone(); + } + @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); diff --git a/src/main/java/com/rj/config/HxrAdminProperties.java b/src/main/java/com/rj/config/HxrAdminProperties.java new file mode 100644 index 0000000..6cb1e95 --- /dev/null +++ b/src/main/java/com/rj/config/HxrAdminProperties.java @@ -0,0 +1,34 @@ +package com.rj.config; + +import com.rj.scheduler.LBAdminPullScheduler; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * hxrd 后台订单列表拉取(供 {@link LBAdminPullScheduler} 使用)。 + */ +@Data +@ConfigurationProperties(prefix = "hxr.admin") +public class HxrAdminProperties { + + /** + * 是否启用拉取任务;未启用时不发 HTTP 请求。 + */ + private boolean enabled = true; + + /** + * 完整请求 URL(含查询串),与浏览器地址栏一致即可。 + */ + private String orderSelectUrl = + "https://hxrdhoutai.hxrdsm.cn/app/admin/order/select?page=1&limit=90"; + + /** + * 请求头 Cookie 完整取值,例如 {@code PHPSID=xxxx}。非空时优先于 {@link #phpsid}。 + */ + private String cookie = ""; + + /** + * 仅 PHPSID 会话值(不含 {@code PHPSID=} 前缀);在 {@link #cookie} 为空时使用。 + */ + private String phpsid = ""; +} diff --git a/src/main/java/com/rj/controller/LbOrderRowController.java b/src/main/java/com/rj/controller/LbOrderRowController.java new file mode 100644 index 0000000..88d6393 --- /dev/null +++ b/src/main/java/com/rj/controller/LbOrderRowController.java @@ -0,0 +1,98 @@ +package com.rj.controller; + +import com.rj.dto.LbOrderRowSyncFromHxrRequest; +import com.rj.entity.LbOrderRow; +import com.rj.service.ILbOrderRowService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/lbOrderRow") +@Tag(name = "LB 订单行", description = "lb_order_row 增删改查与分页") +public class LbOrderRowController { + + @Autowired + private ILbOrderRowService lbOrderRowService; + + @PostMapping("/add") + @Operation(summary = "新增") + public ResponseEntity> add( + @Parameter(description = "实体(id 为订单 id,需调用方指定)", required = true) @RequestBody LbOrderRow entity) { + Map result = lbOrderRowService.add(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @PutMapping("/update") + @Operation(summary = "编辑") + public ResponseEntity> update( + @Parameter(description = "实体", required = true) @RequestBody LbOrderRow entity) { + Map result = lbOrderRowService.update(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除") + public ResponseEntity> delete( + @Parameter(description = "主键(订单 id)", required = true) @PathVariable Long id) { + Map result = lbOrderRowService.deleteById(id); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @GetMapping("/list") + @Operation(summary = "分页查询") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String isResell, + @RequestParam(required = false) String orderSn, + @RequestParam(required = false) Long buyerId, + @RequestParam(required = false) Long sellerId, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) Long merchandiseId) { + Map result = + lbOrderRowService.pageQuery( + current, size, isResell, orderSn, buyerId, sellerId, status, merchandiseId); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.internalServerError().body(result); + } + + @PostMapping("/sync-order-from-third") + @Operation( + summary = "从 外部系统 同步订单", + description = "按购买时间区间、租户 id、转卖状态调用后台 order/select,将结果写入 lb_order_row(按订单 id upsert)") + public ResponseEntity> syncFromHxr( + @Parameter(description = "同步条件", required = true) @RequestBody LbOrderRowSyncFromHxrRequest request) { + Map result = + lbOrderRowService.syncFromHxrAdmin( + request.getBuyTimeStart(), + request.getBuyTimeEnd(), + request.getTenantId(), + request.getIsResell()); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } +} diff --git a/src/main/java/com/rj/dto/LbOrderRowSyncFromHxrRequest.java b/src/main/java/com/rj/dto/LbOrderRowSyncFromHxrRequest.java new file mode 100644 index 0000000..69a4847 --- /dev/null +++ b/src/main/java/com/rj/dto/LbOrderRowSyncFromHxrRequest.java @@ -0,0 +1,24 @@ +package com.rj.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 从 hxrd 后台同步订单到 {@code lb_order_row} 的请求体。 + */ +@Data +@Schema(description = "LB 订单从 hxrd 后台同步请求") +public class LbOrderRowSyncFromHxrRequest { + + @Schema(description = "购买时间起(对应后台 buy_time[0]),可空表示不限制") + private String buyTimeStart; + + @Schema(description = "购买时间止(对应后台 buy_time[1]),可空表示不限制") + private String buyTimeEnd; + + @Schema(description = "写入本系统的租户 id", requiredMode = Schema.RequiredMode.REQUIRED) + private String tenantId; + + @Schema(description = "转卖状态筛选(对应后台 is_resell)", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer isResell; +} diff --git a/src/main/java/com/rj/dto/hxr/HxrOrderRow.java b/src/main/java/com/rj/dto/hxr/HxrOrderRow.java new file mode 100644 index 0000000..23adac4 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrOrderRow.java @@ -0,0 +1,28 @@ +package com.rj.dto.hxr; + +/** + * hxrd 后台 {@code /app/admin/order/select} 返回的 {@code data} 中单条订单。 + */ +public record HxrOrderRow( + long id, + Long oldId, + long sellerId, + long buyerId, + String orderSn, + String totalMoney, + String payTime, + String payImg, + int status, + int isResell, + int isShow, + String consignee, + String phone, + String province, + String city, + String area, + String address, + long merchandiseId, + String confirmTime, + String buyTime, + String createdAt, + String updatedAt) {} diff --git a/src/main/java/com/rj/dto/hxr/HxrOrderSelectResponse.java b/src/main/java/com/rj/dto/hxr/HxrOrderSelectResponse.java new file mode 100644 index 0000000..9bdc320 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrOrderSelectResponse.java @@ -0,0 +1,9 @@ +package com.rj.dto.hxr; + +import java.util.List; + +/** + * hxrd 后台 {@code /app/admin/order/select} 顶层 JSON。 + */ +public record HxrOrderSelectResponse( + int code, String msg, int count, List data, String allMoney) {} diff --git a/src/main/java/com/rj/entity/FlexibleLocalDateTimeDeserializer.java b/src/main/java/com/rj/entity/FlexibleLocalDateTimeDeserializer.java new file mode 100644 index 0000000..733c1fb --- /dev/null +++ b/src/main/java/com/rj/entity/FlexibleLocalDateTimeDeserializer.java @@ -0,0 +1,41 @@ +package com.rj.entity; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Parses {@link LocalDateTime} from ISO-8601 or common SQL-style {@code yyyy-MM-dd HH:mm:ss}. + */ +public class FlexibleLocalDateTimeDeserializer extends JsonDeserializer { + + private static final DateTimeFormatter SQL_SPACE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String text = p.getValueAsString(); + if (text == null) { + return null; + } + text = text.trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text, SQL_SPACE); + } catch (DateTimeParseException ignored) { + // continue + } + try { + return LocalDateTime.parse(text, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } catch (DateTimeParseException e) { + throw ctxt.weirdStringException(text, LocalDateTime.class, + "Expected ISO-8601 local date-time or yyyy-MM-dd HH:mm:ss"); + } + } +} diff --git a/src/main/java/com/rj/entity/JsonFlexibleLocalDateTime.java b/src/main/java/com/rj/entity/JsonFlexibleLocalDateTime.java new file mode 100644 index 0000000..6be69f8 --- /dev/null +++ b/src/main/java/com/rj/entity/JsonFlexibleLocalDateTime.java @@ -0,0 +1,19 @@ +package com.rj.entity; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Accepts ISO-8601 local date-time and common SQL-style {@code yyyy-MM-dd HH:mm:ss} for JSON binding. + */ +@JacksonAnnotationsInside +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) +public @interface JsonFlexibleLocalDateTime { +} diff --git a/src/main/java/com/rj/entity/LbAssessmentApply.java b/src/main/java/com/rj/entity/LbAssessmentApply.java index 5fa6803..949c8d0 100644 --- a/src/main/java/com/rj/entity/LbAssessmentApply.java +++ b/src/main/java/com/rj/entity/LbAssessmentApply.java @@ -76,13 +76,16 @@ public class LbAssessmentApply implements Serializable { @TableField("application_datetime") @Schema(description = "申请日期时间") + @JsonFlexibleLocalDateTime private LocalDateTime applicationDatetime; @TableField("created_at") @Schema(description = "创建时间") + @JsonFlexibleLocalDateTime private LocalDateTime createdAt; @TableField("updated_at") @Schema(description = "修改时间") + @JsonFlexibleLocalDateTime private LocalDateTime updatedAt; } diff --git a/src/main/java/com/rj/entity/LbOrderRow.java b/src/main/java/com/rj/entity/LbOrderRow.java new file mode 100644 index 0000000..84f9b0f --- /dev/null +++ b/src/main/java/com/rj/entity/LbOrderRow.java @@ -0,0 +1,113 @@ +package com.rj.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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("lb_order_row") +@Schema(description = "LB 订单行表") +public class LbOrderRow implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 业务主键为订单 id,由调用方传入,非数据库自增 */ + @TableId(value = "id", type = IdType.INPUT) + @Schema(description = "订单 id") + private Long id; + + @TableField("old_id") + @Schema(description = "旧订单 id") + private Long oldId; + + @TableField("tenant_id") + @Schema(description = "租户ID") + private String tenantId; + + @TableField("seller_id") + @Schema(description = "卖家 id") + private Long sellerId; + + @TableField("buyer_id") + @Schema(description = "买家 id") + private Long buyerId; + + @TableField("order_sn") + @Schema(description = "订单号") + private String orderSn; + + @TableField("total_money") + @Schema(description = "总金额(接口为字符串)") + private String totalMoney; + + @TableField("pay_time") + @Schema(description = "支付时间(原样字符串)") + private String payTime; + + @TableField("pay_img") + @Schema(description = "支付凭证图") + private String payImg; + + @TableField("status") + @Schema(description = "状态") + private Integer status; + + @TableField("is_resell") + @Schema(description = "是否转卖") + private Integer isResell; + + @TableField("is_show") + @Schema(description = "是否展示") + private Integer isShow; + + @TableField("consignee") + @Schema(description = "收货人") + private String consignee; + + @TableField("phone") + @Schema(description = "电话") + private String phone; + + @TableField("province") + @Schema(description = "省") + private String province; + + @TableField("city") + @Schema(description = "市") + private String city; + + @TableField("area") + @Schema(description = "区") + private String area; + + @TableField("address") + @Schema(description = "详细地址") + private String address; + + @TableField("merchandise_id") + @Schema(description = "商品 id") + private Long merchandiseId; + + @TableField("confirm_time") + @Schema(description = "确认时间") + private String confirmTime; + + @TableField("buy_time") + @Schema(description = "下单时间") + private String buyTime; + + @TableField("created_at") + @Schema(description = "创建时间") + private String createdAt; + + @TableField("updated_at") + @Schema(description = "更新时间") + private String updatedAt; +} diff --git a/src/main/java/com/rj/mapper/LbOrderRowMapper.java b/src/main/java/com/rj/mapper/LbOrderRowMapper.java new file mode 100644 index 0000000..49cde86 --- /dev/null +++ b/src/main/java/com/rj/mapper/LbOrderRowMapper.java @@ -0,0 +1,9 @@ +package com.rj.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rj.entity.LbOrderRow; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LbOrderRowMapper extends BaseMapper { +} diff --git a/src/main/java/com/rj/scheduler/LBAdminPullScheduler.java b/src/main/java/com/rj/scheduler/LBAdminPullScheduler.java new file mode 100644 index 0000000..c12323b --- /dev/null +++ b/src/main/java/com/rj/scheduler/LBAdminPullScheduler.java @@ -0,0 +1,66 @@ +package com.rj.scheduler; + +import com.rj.config.AppConfig; +import com.rj.config.HxrAdminProperties; +import com.rj.service.HxrAdminOrderSelectService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + * hxrd 后台订单列表定时拉取:每 50 分钟触发一次,仅工作日(周一至周五)且在每天 18:10~23:59:59(应用时区)内真正执行。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LBAdminPullScheduler { + + private static final long FIFTY_MINUTES_MS = 50L * 60L * 1000L; + + private final AppConfig appConfig; + private final HxrAdminProperties hxrAdminProperties; + private final HxrAdminOrderSelectService hxrAdminOrderSelectService; + private final Clock clock; + + @Scheduled( + fixedRate = FIFTY_MINUTES_MS, + initialDelayString = "${hxr.admin.pull-initial-delay-ms:0}") + public void pullAdminOrders() { + try { + if (!appConfig.getScheduler().isStart()) { + log.debug("全局调度未启用,跳过 未寄卖 订单拉取"); + return; + } + if (!hxrAdminProperties.isEnabled()) { + log.debug("hxr.admin.enabled=false,跳过订单拉取"); + return; + } + ZoneId zone = ZoneId.of(appConfig.getTimezone()); + ZonedDateTime nowZdt = ZonedDateTime.now(clock.withZone(zone)); + DayOfWeek dow = nowZdt.getDayOfWeek(); + if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { + log.debug("当前为周末 {},跳过 未寄卖 订单拉取", dow); + return; + } + LocalTime now = nowZdt.toLocalTime(); + LocalTime windowStart = LocalTime.of(18, 10); + LocalTime windowEnd = LocalTime.of(23, 59, 59); + if (now.isBefore(windowStart) || now.isAfter(windowEnd)) { + log.debug("当前时刻 {} 不在窗口 {}~{},跳过 未寄卖 订单拉取", now, windowStart, windowEnd); + return; + } + + log.info("开始执行 未寄卖 订单拉取(工作日 18:10~23:59:59 窗口内)"); + hxrAdminOrderSelectService.fetchAndLogEachOrder(); + } catch (Exception e) { + log.error("未寄卖 admin 订单拉取失败", e); + } + } +} diff --git a/src/main/java/com/rj/service/HxrAdminOrderSelectService.java b/src/main/java/com/rj/service/HxrAdminOrderSelectService.java new file mode 100644 index 0000000..69ebcc6 --- /dev/null +++ b/src/main/java/com/rj/service/HxrAdminOrderSelectService.java @@ -0,0 +1,148 @@ +package com.rj.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.rj.config.HxrAdminProperties; +import com.rj.dto.hxr.HxrOrderRow; +import com.rj.dto.hxr.HxrOrderSelectResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; + +/** + * 调用 hxrd 后台订单列表接口并解析 JSON。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HxrAdminOrderSelectService { + + private static final ObjectMapper JSON = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private final HxrAdminProperties properties; + + /** + * 拉取订单列表;配置未就绪或 HTTP 非 2xx 时返回 empty。 + *

使用 {@link HxrAdminProperties#getOrderSelectUrl()} 原样请求(与定时任务一致)。 + */ + public Optional fetchOrderSelect() throws Exception { + return fetchOrderSelectInternal(properties.getOrderSelectUrl()); + } + + /** + * 按购买时间区间、转卖状态等条件拉取订单列表(在配置的 {@code orderSelectUrl} 上覆盖查询参数)。 + * + * @param buyTimeStart 购买时间起,对应 {@code buy_time[0]},可为 null 或空串表示不限制 + * @param buyTimeEnd 购买时间止,对应 {@code buy_time[1]} + * @param isResell 转卖筛选,对应 {@code is_resell};为 null 时不改写该参数 + */ + public Optional fetchOrderSelect( + String buyTimeStart, String buyTimeEnd, Integer isResell) throws Exception { + return fetchOrderSelectInternal(buildOrderSelectUrl(buyTimeStart, buyTimeEnd, isResell)); + } + + private Optional fetchOrderSelectInternal(String requestUri) throws Exception { + String cookieHeader = resolveCookieHeader(); + if (cookieHeader == null || cookieHeader.isBlank()) { + log.warn("hxr.admin 未配置 cookie 或 phpsid,跳过拉取"); + return Optional.empty(); + } + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(35)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(requestUri)) + .timeout(Duration.ofSeconds(160)) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") + .header("Cookie", cookieHeader) + .header("Priority", "u=1, i") + .header("Referer", "https://hxrdhoutai.hxrdsm.cn/app/admin/order/index") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if (status < 200 || status >= 300) { + log.warn("hxr admin order/select HTTP {} bodyPrefix={}", status, abbreviate(response.body(), 500)); + return Optional.empty(); + } + + HxrOrderSelectResponse body = JSON.readValue(response.body(), HxrOrderSelectResponse.class); + if (body.code() != 0) { + log.warn("hxr admin order/select api code={} msg={}", body.code(), body.msg()); + return Optional.empty(); + } + return Optional.of(body); + } + + /** + * 在 {@link HxrAdminProperties#getOrderSelectUrl()} 上覆盖 {@code buy_time[0]}、{@code buy_time[1]}、{@code is_resell}。 + */ + private String buildOrderSelectUrl(String buyTimeStart, String buyTimeEnd, Integer isResell) { + UriComponentsBuilder b = UriComponentsBuilder.fromUriString(properties.getOrderSelectUrl()); + b.replaceQueryParam("buy_time[0]", buyTimeStart == null ? "" : buyTimeStart); + b.replaceQueryParam("buy_time[1]", buyTimeEnd == null ? "" : buyTimeEnd); + if (isResell != null) { + b.replaceQueryParam("is_resell", isResell); + } + return b.build().encode().toUriString(); + } + + /** + * 拉取并逐条打日志(一行一条 JSON)。 + */ + public void fetchAndLogEachOrder() throws Exception { + Optional opt = fetchOrderSelect(); + if (opt.isEmpty()) { + return; + } + HxrOrderSelectResponse body = opt.get(); + log.info("未寄卖 order/select ok count={} allMoney={}", body.count(), body.allMoney()); + for (HxrOrderRow row : body.data()) { + log.info("hxr order row {}", JSON.writeValueAsString(row)); + } + } + + private String resolveCookieHeader() { + String c = firstNonBlank(properties.getCookie()); + if (c != null) { + return c; + } + String id = firstNonBlank(properties.getPhpsid()); + if (id != null) { + return "PHPSID=" + id; + } + return ""; + } + + private static String firstNonBlank(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static String abbreviate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } +} diff --git a/src/main/java/com/rj/service/ILbOrderRowService.java b/src/main/java/com/rj/service/ILbOrderRowService.java new file mode 100644 index 0000000..c1a23c0 --- /dev/null +++ b/src/main/java/com/rj/service/ILbOrderRowService.java @@ -0,0 +1,31 @@ +package com.rj.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rj.entity.LbOrderRow; + +import java.util.Map; + +public interface ILbOrderRowService extends IService { + + Map add(LbOrderRow entity); + + Map update(LbOrderRow entity); + + Map deleteById(Long id); + + Map pageQuery( + Integer current, + Integer size, + String tenantId, + String orderSn, + Long buyerId, + Long sellerId, + Integer status, + Long merchandiseId); + + /** + * 按条件调用 hxrd 后台订单列表接口拉取数据,并 upsert 到 {@code lb_order_row}。 + */ + Map syncFromHxrAdmin( + String buyTimeStart, String buyTimeEnd, String tenantId, Integer isResell); +} diff --git a/src/main/java/com/rj/service/impl/LbOrderRowServiceImpl.java b/src/main/java/com/rj/service/impl/LbOrderRowServiceImpl.java new file mode 100644 index 0000000..5d194a6 --- /dev/null +++ b/src/main/java/com/rj/service/impl/LbOrderRowServiceImpl.java @@ -0,0 +1,273 @@ +package com.rj.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rj.dto.hxr.HxrOrderRow; +import com.rj.dto.hxr.HxrOrderSelectResponse; +import com.rj.entity.LbOrderRow; +import com.rj.mapper.LbOrderRowMapper; +import com.rj.service.HxrAdminOrderSelectService; +import com.rj.service.ILbOrderRowService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +public class LbOrderRowServiceImpl extends ServiceImpl implements ILbOrderRowService { + + @Autowired + private HxrAdminOrderSelectService hxrAdminOrderSelectService; + + @Override + public Map add(LbOrderRow entity) { + Map result = new HashMap<>(); + try { + if (entity.getId() == null) { + result.put("success", false); + result.put("message", "id(订单 id)不能为空"); + return result; + } + if (entity.getTenantId() == null || entity.getTenantId().trim().isEmpty()) { + result.put("success", false); + result.put("message", "tenantId不能为空"); + return result; + } + if (entity.getSellerId() == null) { + result.put("success", false); + result.put("message", "sellerId不能为空"); + return result; + } + if (entity.getBuyerId() == null) { + result.put("success", false); + result.put("message", "buyerId不能为空"); + return result; + } + if (entity.getOrderSn() == null || entity.getOrderSn().trim().isEmpty()) { + result.put("success", false); + result.put("message", "orderSn不能为空"); + return result; + } + if (entity.getMerchandiseId() == null) { + result.put("success", false); + result.put("message", "merchandiseId不能为空"); + return result; + } + if (this.getById(entity.getId()) != null) { + result.put("success", false); + result.put("message", "该订单 id 已存在"); + return result; + } + + entity.setTenantId(entity.getTenantId().trim()); + entity.setOrderSn(entity.getOrderSn().trim()); + if (entity.getStatus() == null) { + entity.setStatus(0); + } + if (entity.getIsResell() == null) { + entity.setIsResell(0); + } + if (entity.getIsShow() == null) { + entity.setIsShow(0); + } + + boolean ok = this.save(entity); + result.put("success", ok); + result.put("message", ok ? "新增成功" : "新增失败"); + if (ok) { + result.put("data", this.getById(entity.getId())); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "新增异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map update(LbOrderRow entity) { + Map result = new HashMap<>(); + try { + if (entity.getId() == null) { + result.put("success", false); + result.put("message", "id不能为空"); + return result; + } + if (this.getById(entity.getId()) == null) { + result.put("success", false); + result.put("message", "记录不存在"); + return result; + } + boolean ok = this.updateById(entity); + result.put("success", ok); + result.put("message", ok ? "编辑成功" : "编辑失败"); + if (ok) { + result.put("data", this.getById(entity.getId())); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "编辑异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map deleteById(Long id) { + Map result = new HashMap<>(); + try { + boolean ok = this.removeById(id); + result.put("success", ok); + result.put("message", ok ? "删除成功" : "删除失败"); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "删除异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map pageQuery( + Integer current, + Integer size, + String isResell, + String orderSn, + Long buyerId, + Long sellerId, + Integer status, + Long merchandiseId) { + Map result = new HashMap<>(); + try { + if (current == null || current < 1) { + current = 1; + } + if (size == null || size < 1) { + size = 10; + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + if (isResell != null && !isResell.trim().isEmpty()) { + w.eq(LbOrderRow::getIsResell, isResell.trim()); + } + if (orderSn != null && !orderSn.trim().isEmpty()) { + w.like(LbOrderRow::getOrderSn, orderSn.trim()); + } + if (buyerId != null) { + w.eq(LbOrderRow::getBuyerId, buyerId); + } + if (sellerId != null) { + w.eq(LbOrderRow::getSellerId, sellerId); + } + if (status != null) { + w.eq(LbOrderRow::getStatus, status); + } + if (merchandiseId != null) { + w.eq(LbOrderRow::getMerchandiseId, merchandiseId); + } + w.orderByDesc(LbOrderRow::getId); + + Page page = this.page(new Page<>(current, size), w); + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", page.getRecords()); + result.put("total", page.getTotal()); + result.put("current", page.getCurrent()); + result.put("size", page.getSize()); + result.put("pages", page.getPages()); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "查询异常:" + e.getMessage()); + return result; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncFromHxrAdmin( + String buyTimeStart, String buyTimeEnd, String tenantId, Integer isResell) { + Map result = new HashMap<>(); + try { + if (tenantId == null || tenantId.trim().isEmpty()) { + result.put("success", false); + result.put("message", "tenantId不能为空"); + return result; + } + + + Optional opt = + hxrAdminOrderSelectService.fetchOrderSelect(buyTimeStart, buyTimeEnd, isResell); + if (opt.isEmpty()) { + result.put("success", false); + result.put("message", "未拉取到订单(请检查 hxr.admin cookie/phpsid、网络或后台返回)"); + result.put("synced", 0); + return result; + } + + HxrOrderSelectResponse body = opt.get(); + List rows = body.data(); + if (rows == null || rows.isEmpty()) { + result.put("success", true); + result.put("message", "接口成功,本页无订单数据"); + result.put("synced", 0); + result.put("remoteCount", body.count()); + return result; + } + + String tid = tenantId.trim(); + List entities = new ArrayList<>(rows.size()); + for (HxrOrderRow row : rows) { + entities.add(toLbOrderRow(row, tid)); + } + + boolean ok = this.saveOrUpdateBatch(entities); + result.put("success", ok); + result.put("message", ok ? "同步完成" : "批量保存失败"); + result.put("synced", ok ? entities.size() : 0); + result.put("remoteCount", body.count()); + result.put("allMoney", body.allMoney()); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "同步异常:" + e.getMessage()); + result.put("synced", 0); + return result; + } + } + + private static LbOrderRow toLbOrderRow(HxrOrderRow row, String tenantId) { + LbOrderRow e = new LbOrderRow(); + e.setId(row.id()); + e.setOldId(row.oldId()); + e.setTenantId(tenantId); + e.setSellerId(row.sellerId()); + e.setBuyerId(row.buyerId()); + e.setOrderSn(row.orderSn()); + e.setTotalMoney(row.totalMoney()); + e.setPayTime(row.payTime()); + e.setPayImg(row.payImg()); + e.setStatus(row.status()); + e.setIsResell(row.isResell()); + e.setIsShow(row.isShow()); + e.setConsignee(row.consignee()); + e.setPhone(row.phone()); + e.setProvince(row.province()); + e.setCity(row.city()); + e.setArea(row.area()); + e.setAddress(row.address()); + e.setMerchandiseId(row.merchandiseId()); + e.setConfirmTime(row.confirmTime()); + e.setBuyTime(row.buyTime()); + e.setCreatedAt(row.createdAt()); + e.setUpdatedAt(row.updatedAt()); + return e; + } +} diff --git a/src/main/resources/application-local-dingtalk.example.yml b/src/main/resources/application-local-dingtalk.example.yml new file mode 100644 index 0000000..2f818b7 --- /dev/null +++ b/src/main/resources/application-local-dingtalk.example.yml @@ -0,0 +1,8 @@ +# 复制为 application-local-dingtalk.yml 并填入 access-token(及可选 secret)。 +# application-local-dingtalk.yml 已在 .gitignore 中,不会被提交。 +lb: + assessment-apply: + dingtalk: + enabled: true + access-token: "从钉钉 Webhook URL 中 access_token= 后的值" + # secret: "若机器人安全设置为加签则必填" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 98d45f3..63ac78a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,15 @@ app: yihangyi: asr: enabled: true + +# hxrd 后台订单列表(LBAdminPullScheduler:每 50 分钟一次,仅工作日 18:10~23:59:59 执行,周末不跑)。默认关闭。 +hxr: + admin: + enabled: false + # 首次拉单延迟(毫秒),0 表示启动后尽快执行;集成测试会设为较大值避免与用例并发 + pull-initial-delay-ms: 0 + cookie: PHPSID=f3710baf6381da418aa832660ccb9d08 + phpsid: "f3710baf6381da418aa832660ccb9d08" # DashScope API配置 dashscope: api: @@ -89,6 +98,9 @@ logging: spring: + # 可选加载本地钉钉配置(见 application-local-dingtalk.example.yml,实际文件已 .gitignore) + config: + import: optional:classpath:application-local-dingtalk.yml autoconfigure: exclude: - dev.langchain4j.community.store.embedding.redis.spring.RedisEmbeddingStoreAutoConfiguration @@ -278,11 +290,13 @@ amap: # 申请评估 - 钉钉群机器人(自定义机器人 Webhook) lb: +# 本文件已加入 .gitignore,仅本机生效;勿将真实 token 提交到仓库。 +# 若机器人启用了「加签」,请增加 secret 行(SEC 开头)。 assessment-apply: dingtalk: - enabled: ${LB_ASSESSMENT_DINGTALK_ENABLED:true} - access-token: ${LB_ASSESSMENT_DINGTALK_ACCESS_TOKEN:} - secret: ${LB_ASSESSMENT_DINGTALK_SECRET:} + enabled: true + access-token: 65aad0554c700d12d2ea71f9c62a3214d5f8f8b2b24679d30489ce9c11a6105c + secret: "SECxxxxxxxx" # MinIO 对象存储配置 minio: diff --git a/src/test/java/com/rj/integration/HxrAdminOrderSelectHttpTest.java b/src/test/java/com/rj/integration/HxrAdminOrderSelectHttpTest.java new file mode 100644 index 0000000..f41d2e1 --- /dev/null +++ b/src/test/java/com/rj/integration/HxrAdminOrderSelectHttpTest.java @@ -0,0 +1,178 @@ +package com.rj.integration; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.rj.dto.hxr.HxrOrderRow; +import com.rj.dto.hxr.HxrOrderSelectResponse; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 手动调用 hxrd 后台「订单列表」JSON 接口(需有效 PHPSID)。 + *

+ * 推荐:将 {@code src/test/resources/hxr-order-integration.example.properties} 复制为 + * {@code hxr-order-integration.properties},填写 {@code hxr.order.cookie} 或 {@code hxr.order.phpsid} + * (该文件已加入 .gitignore)。 + *

+ * 覆盖优先级(从高到低):JVM 系统属性({@code -Dhxr.order.cookie} / {@code -Dhxr.order.phpsid}) + * > 环境变量 {@code HXR_ORDER_COOKIE} > classpath 配置文件 {@code hxr-order-integration.properties}。 + *

+ * 不要手动设置含 {@code br}、{@code zstd} 的 {@code Accept-Encoding}:{@link HttpClient} 只会对默认协商的编码自动解压, + * 否则服务端可能返回 Brotli/zstd,响应体会变成乱码。 + */ +class HxrAdminOrderSelectHttpTest { + + private static final String CONFIG_RESOURCE = "hxr-order-integration.properties"; + + private static final String PROP_COOKIE = "hxr.order.cookie"; + private static final String PROP_PHPSID = "hxr.order.phpsid"; + private static final String ENV_COOKIE = "HXR_ORDER_COOKIE"; + + private static final String BASE_URL = "https://hxrdhoutai.hxrdsm.cn/app/admin/order/select"; + + private static final String QUERY = + "?page=1&limit=20" + + "&order_sn%5B0%5D=like&order_sn%5B1%5D=" + + "&seller_id=&buyer_id=&status=&is_resell=0&is_show=" + + "&buy_time%5B0%5D=&buy_time%5B1%5D=&confirm_time%5B0%5D=&confirm_time%5B1%5D="; + + private static volatile Properties integrationProps; + + private static final ObjectMapper HXR_ORDER_JSON = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Test + void getOrderSelectJson() throws Exception { + String cookie = resolveCookie(); + Assumptions.assumeFalse( + cookie == null || cookie.isBlank(), + "跳过:请在 src/test/resources/ 下复制 " + + "hxr-order-integration.example.properties 为 " + + CONFIG_RESOURCE + + " 并填写 hxr.order.cookie 或 hxr.order.phpsid;或设置环境变量 " + + ENV_COOKIE + + " / VM 选项 -D" + + PROP_COOKIE + + " / -D" + + PROP_PHPSID); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + QUERY)) + .timeout(Duration.ofSeconds(60)) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") + .header("Cookie", cookie) + .header("Priority", "u=1, i") + .header("Referer", "https://hxrdhoutai.hxrdsm.cn/app/admin/order/index") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + System.out.println("HTTP " + response.statusCode()); + assertTrue( + response.statusCode() >= 200 && response.statusCode() < 300, + "unexpected status: " + response.statusCode()); + + HxrOrderSelectResponse body = HXR_ORDER_JSON.readValue(response.body(), HxrOrderSelectResponse.class); + assertEquals(0, body.code(), () -> "api code != 0, msg=" + body.msg() + ", body=" + response.body()); + for (HxrOrderRow row : body.data()) { + System.out.println(HXR_ORDER_JSON.writeValueAsString(row)); + } + } + + @Test + void parseSampleJson_printsOneLinePerOrder() throws Exception { + String json = + """ + {"code":0,"msg":"ok","count":7,"data":[{"id":358078,"old_id":null,"seller_id":97154,"buyer_id":97374,"order_sn":"47379117788104684482","total_money":"23423.95","pay_time":"2026-05-15 10:04:32","pay_img":"\\/upload\\/image\\/20260515\\/ec791c7a9ed2f5ade9450c0f50efd819_6a067f2f9317d.jpeg","status":2,"is_resell":0,"is_show":1,"consignee":"陈科亦","phone":"18810005315","province":"河北省","city":"廊坊市","area":"三河市","address":"燕郊开发区金谷爱舒荷","merchandise_id":344196,"confirm_time":"2026-05-15 10:08:04","buy_time":"2026-05-15 10:01:08","created_at":"2026-05-15 10:01:08","updated_at":"2026-05-15 10:08:04"},{"id":357968,"old_id":null,"seller_id":98719,"buyer_id":96030,"order_sn":"03069117788104035736","total_money":"27037.65","pay_time":"2026-05-15 10:02:48","pay_img":"\\/upload\\/image\\/20260515\\/37d6d7d09447d6c8ce41abd0727a39d5_6a067ec77a519.jpeg","status":1,"is_resell":0,"is_show":1,"consignee":"陈晓琴","phone":"17733627528","province":"河北省","city":"廊坊市","area":"三河市","address":"燕郊镇金谷爰舒荷十号楼","merchandise_id":344269,"confirm_time":null,"buy_time":"2026-05-15 10:00:03","created_at":"2026-05-15 10:00:03","updated_at":"2026-05-15 10:02:48"}],"all_money":"182851.70"} + """; + HxrOrderSelectResponse body = HXR_ORDER_JSON.readValue(json.trim(), HxrOrderSelectResponse.class); + assertEquals(0, body.code()); + assertEquals(2, body.data().size()); + for (HxrOrderRow row : body.data()) { + System.out.println(HXR_ORDER_JSON.writeValueAsString(row)); + } + } + + private static String resolveCookie() { + String fromFileCookie = firstNonBlank(integrationProps().getProperty(PROP_COOKIE)); + String fromFilePhpsid = firstNonBlank(integrationProps().getProperty(PROP_PHPSID)); + + String fromProp = firstNonBlank(System.getProperty(PROP_COOKIE)); + String phpsidProp = firstNonBlank(System.getProperty(PROP_PHPSID)); + String fromEnv = firstNonBlank(System.getenv(ENV_COOKIE)); + + if (fromProp != null) { + return fromProp; + } + if (phpsidProp != null) { + return "PHPSID=" + phpsidProp; + } + if (fromEnv != null) { + return fromEnv; + } + if (fromFileCookie != null) { + return fromFileCookie; + } + if (fromFilePhpsid != null) { + return "PHPSID=" + fromFilePhpsid; + } + return ""; + } + + private static String firstNonBlank(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static Properties integrationProps() { + if (integrationProps != null) { + return integrationProps; + } + synchronized (HxrAdminOrderSelectHttpTest.class) { + if (integrationProps != null) { + return integrationProps; + } + Properties p = new Properties(); + try (InputStream in = + HxrAdminOrderSelectHttpTest.class.getClassLoader().getResourceAsStream(CONFIG_RESOURCE)) { + if (in != null) { + try (InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + p.load(reader); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + integrationProps = p; + return integrationProps; + } + } +} diff --git a/src/test/java/com/rj/scheduler/LBAdminPullSchedulerIntegrationTest.java b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerIntegrationTest.java new file mode 100644 index 0000000..32b7c59 --- /dev/null +++ b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerIntegrationTest.java @@ -0,0 +1,94 @@ +package com.rj.scheduler; + +import com.rj.service.HxrAdminOrderSelectService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * {@link LBAdminPullScheduler#pullAdminOrders()} 集成测试:全量 Spring 上下文 + {@link LBAdminPullSchedulerTestClockConfiguration}, + * HTTP 拉单由 {@link MockitoBean} 替身,避免外网与真实 Cookie。 + * + *

另见:{@link LBAdminPullSchedulerWhenHxrDisabledIntegrationTest}、{@link LBAdminPullSchedulerWhenGlobalSchedulerOffIntegrationTest}。 + */ +@SpringBootTest +@TestPropertySource( + properties = { + "spring.task.scheduling.enabled=false", + "hxr.admin.pull-initial-delay-ms=604800000", + "app.scheduler.start=true", + "hxr.admin.enabled=true", + "app.timezone=Asia/Shanghai" + }) +@Import(LBAdminPullSchedulerTestClockConfiguration.class) +@ActiveProfiles("test") +class LBAdminPullSchedulerIntegrationTest { + + @Autowired + private LBAdminPullScheduler lbAdminPullScheduler; + + @Autowired + private LBAdminPullSchedulerTestClockConfiguration.MutableClock mutableClock; + + @MockitoBean + private HxrAdminOrderSelectService hxrAdminOrderSelectService; + + @BeforeEach + void resetInvocationHistory() { + Mockito.clearInvocations(hxrAdminOrderSelectService); + } + + @Test + void pullAdminOrders_eveningWindow_invokesOrderSelectService() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(20, 0), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService).fetchAndLogEachOrder(); + } + + @Test + void pullAdminOrders_exactly1810_invokesOrderSelectService() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(18, 10), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService).fetchAndLogEachOrder(); + } + + @Test + void pullAdminOrders_beforeWindow_doesNotInvoke() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(12, 0), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService, never()).fetchAndLogEachOrder(); + } + + @Test + void pullAdminOrders_saturdayEveningInTimeWindow_doesNotInvoke() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 16), LocalTime.of(20, 0), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService, never()).fetchAndLogEachOrder(); + } +} diff --git a/src/test/java/com/rj/scheduler/LBAdminPullSchedulerTestClockConfiguration.java b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerTestClockConfiguration.java new file mode 100644 index 0000000..d0d159f --- /dev/null +++ b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerTestClockConfiguration.java @@ -0,0 +1,48 @@ +package com.rj.scheduler; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + * 为 {@link LBAdminPullScheduler} 集成测试提供可拨 {@link Clock}({@link MutableClock})。 + */ +@TestConfiguration +public class LBAdminPullSchedulerTestClockConfiguration { + + @Bean + @Primary + MutableClock lbAdminPullTestMutableClock() { + return new MutableClock(); + } + + /** 测试中可切换时刻的 {@link Clock},用于固定 18:10~23:59 时间窗口。 */ + public static final class MutableClock extends Clock { + + private volatile Clock delegate = Clock.systemDefaultZone(); + + public void setTo(ZonedDateTime zdt) { + delegate = Clock.fixed(zdt.toInstant(), zdt.getZone()); + } + + @Override + public ZoneId getZone() { + return delegate.getZone(); + } + + @Override + public Clock withZone(ZoneId zone) { + return delegate.withZone(zone); + } + + @Override + public Instant instant() { + return delegate.instant(); + } + } +} diff --git a/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenGlobalSchedulerOffIntegrationTest.java b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenGlobalSchedulerOffIntegrationTest.java new file mode 100644 index 0000000..8c5fac7 --- /dev/null +++ b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenGlobalSchedulerOffIntegrationTest.java @@ -0,0 +1,51 @@ +package com.rj.scheduler; + +import com.rj.service.HxrAdminOrderSelectService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@TestPropertySource( + properties = { + "spring.task.scheduling.enabled=false", + "hxr.admin.pull-initial-delay-ms=604800000", + "app.scheduler.start=false", + "hxr.admin.enabled=true", + "app.timezone=Asia/Shanghai" + }) +@Import(LBAdminPullSchedulerTestClockConfiguration.class) +@ActiveProfiles("test") +class LBAdminPullSchedulerWhenGlobalSchedulerOffIntegrationTest { + + @Autowired + private LBAdminPullScheduler lbAdminPullScheduler; + + @Autowired + private LBAdminPullSchedulerTestClockConfiguration.MutableClock mutableClock; + + @MockitoBean + private HxrAdminOrderSelectService hxrAdminOrderSelectService; + + @Test + void pullAdminOrders_skipsWhenAppSchedulerStartFalse() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(20, 0), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService, never()).fetchAndLogEachOrder(); + } +} diff --git a/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenHxrDisabledIntegrationTest.java b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenHxrDisabledIntegrationTest.java new file mode 100644 index 0000000..1aae826 --- /dev/null +++ b/src/test/java/com/rj/scheduler/LBAdminPullSchedulerWhenHxrDisabledIntegrationTest.java @@ -0,0 +1,51 @@ +package com.rj.scheduler; + +import com.rj.service.HxrAdminOrderSelectService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@TestPropertySource( + properties = { + "spring.task.scheduling.enabled=false", + "hxr.admin.pull-initial-delay-ms=604800000", + "app.scheduler.start=true", + "hxr.admin.enabled=false", + "app.timezone=Asia/Shanghai" + }) +@Import(LBAdminPullSchedulerTestClockConfiguration.class) +@ActiveProfiles("test") +class LBAdminPullSchedulerWhenHxrDisabledIntegrationTest { + + @Autowired + private LBAdminPullScheduler lbAdminPullScheduler; + + @Autowired + private LBAdminPullSchedulerTestClockConfiguration.MutableClock mutableClock; + + @MockitoBean + private HxrAdminOrderSelectService hxrAdminOrderSelectService; + + @Test + void pullAdminOrders_eveningWindow_stillSkips() throws Exception { + mutableClock.setTo( + ZonedDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(20, 0), ZoneId.of("Asia/Shanghai"))); + + lbAdminPullScheduler.pullAdminOrders(); + + verify(hxrAdminOrderSelectService, never()).fetchAndLogEachOrder(); + } +} diff --git a/src/test/resources/hxr-order-integration.example.properties b/src/test/resources/hxr-order-integration.example.properties new file mode 100644 index 0000000..c1010cc --- /dev/null +++ b/src/test/resources/hxr-order-integration.example.properties @@ -0,0 +1,7 @@ +# 复制本文件为 hxr-order-integration.properties 后填写(后者已加入 .gitignore,勿提交真实 Cookie) +# 二选一: +# hxr.order.cookie=PHPSID=你的会话值 +# hxr.order.phpsid=仅填PHPSID的值(不含 PHPSID= 前缀) + +hxr.order.cookie= +# hxr.order.phpsid=