diff --git a/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java new file mode 100644 index 0000000..4502142 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java @@ -0,0 +1,313 @@ +package com.gxwebsoft.glt.controller; + +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.param.GltTicketOrderDeliveredParam; +import com.gxwebsoft.glt.param.GltTicketOrderParam; +import com.gxwebsoft.glt.service.GltTicketOrderService; +import com.gxwebsoft.shop.entity.ShopStoreRider; +import com.gxwebsoft.shop.entity.ShopUserAddress; +import com.gxwebsoft.shop.service.ShopStoreFenceService; +import com.gxwebsoft.shop.service.ShopStoreRiderService; +import com.gxwebsoft.shop.service.ShopUserAddressService; +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 送水订单控制器 + * + * @author 科技小王子 + * @since 2026-02-05 18:50:21 + */ +@Tag(name = "送水订单管理") +@RestController +@RequestMapping("/api/glt/glt-ticket-order") +public class GltTicketOrderController extends BaseController { + @Resource + private GltTicketOrderService gltTicketOrderService; + @Resource + private ShopUserAddressService shopUserAddressService; + @Resource + private ShopStoreFenceService shopStoreFenceService; + @Resource + private ShopStoreRiderService shopStoreRiderService; + + @Operation(summary = "分页查询送水订单") + @GetMapping("/page") + public ApiResult> page(GltTicketOrderParam param) { + // 使用关联查询 + return success(gltTicketOrderService.pageRel(param)); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "配送员端:分页查询我的送水订单") + @GetMapping("/rider/page") + public ApiResult> riderPage(GltTicketOrderParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录", null); + } + Integer tenantId = getTenantId(); + param.setTenantId(tenantId); + // 仅允许配送员访问 + requireActiveRider(loginUser.getUserId(), tenantId); + + param.setRiderId(loginUser.getUserId()); + if (param.getDeliveryStatus() == null) { + param.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING); + } + // 配送员端默认按期望配送时间优先 + if (StrUtil.isBlank(param.getSort())) { + param.setSort("sendTime asc, createTime desc"); + } + return success(gltTicketOrderService.pageRel(param)); + } + + @Operation(summary = "查询全部送水订单") + @GetMapping() + public ApiResult> list(GltTicketOrderParam param) { + // 使用关联查询 + return success(gltTicketOrderService.listRel(param)); + } + + @Operation(summary = "根据id查询送水订单") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + // 使用关联查询 + return success(gltTicketOrderService.getByIdRel(id)); + } + + @Operation(summary = "添加送水订单") + @PostMapping() + public ApiResult save(@RequestBody GltTicketOrder gltTicketOrder) { + // 下单:后端原子完成(扣水票 + 写核销记录 + 生成订单) + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + if (gltTicketOrder == null) { + return fail("订单参数不能为空"); + } + + // 地址快照:把用户关联的“详细地址”落到 glt_ticket_order.address,避免用户后续修改地址导致历史订单丢失 + ShopUserAddress userAddress = null; + if (gltTicketOrder.getAddressId() != null) { + userAddress = shopUserAddressService.getByIdRel(gltTicketOrder.getAddressId()); + } else { + userAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId()); + } + if (userAddress == null) { + return fail("请先添加收货地址"); + } + if (!loginUser.getUserId().equals(userAddress.getUserId())) { + return fail("收货地址不存在或无权限"); + } + if (loginUser.getTenantId() != null && userAddress.getTenantId() != null + && !loginUser.getTenantId().equals(userAddress.getTenantId())) { + return fail("收货地址不存在或无权限"); + } + gltTicketOrder.setAddressId(userAddress.getId()); + gltTicketOrder.setAddress(buildAddressSnapshot(userAddress)); + + // 下单时校验配送范围(电子围栏):不信任前端传经纬度,使用地址表坐标校验 + if (StrUtil.isBlank(userAddress.getLat()) || StrUtil.isBlank(userAddress.getLng())) { + return fail("收货地址坐标缺失,请重新选择收货地址"); + } + try { + double lat = Double.parseDouble(userAddress.getLat().trim()); + double lng = Double.parseDouble(userAddress.getLng().trim()); + shopStoreFenceService.validatePointInEnabledFences(loginUser.getTenantId(), lng, lat); + } catch (Exception e) { + return fail(e.getMessage() == null ? "收货地址不在配送范围内" : e.getMessage()); + } + + gltTicketOrderService.createWithWriteOff(gltTicketOrder, loginUser.getUserId(), loginUser.getTenantId()); + return success("下单成功"); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "配送员接单") + @PostMapping("/{id}/accept") + public ApiResult accept(@PathVariable("id") Integer id) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + Integer tenantId = getTenantId(); + requireActiveRider(loginUser.getUserId(), tenantId); + gltTicketOrderService.accept(id, loginUser.getUserId(), tenantId); + return success("接单成功"); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "配送员开始配送") + @PostMapping("/{id}/start") + public ApiResult start(@PathVariable("id") Integer id) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + Integer tenantId = getTenantId(); + requireActiveRider(loginUser.getUserId(), tenantId); + gltTicketOrderService.start(id, loginUser.getUserId(), tenantId); + return success("开始配送"); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "配送员确认送达") + @PostMapping("/{id}/delivered") + public ApiResult delivered(@PathVariable("id") Integer id, + @RequestBody(required = false) GltTicketOrderDeliveredParam body) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + Integer tenantId = getTenantId(); + requireActiveRider(loginUser.getUserId(), tenantId); + String sendEndImg = body == null ? null : body.getSendEndImg(); + // 配送员提成结算:在 service 内部按“拍照上传/用户确认收货”规则幂等处理。 + gltTicketOrderService.delivered(id, loginUser.getUserId(), tenantId, sendEndImg); + return success("确认送达"); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "用户确认收货") + @PostMapping("/{id}/confirm-receive") + public ApiResult confirmReceive(@PathVariable("id") Integer id) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + // 配送员提成结算:在 service 内部按规则幂等处理。 + gltTicketOrderService.confirmReceive(id, loginUser.getUserId(), getTenantId()); + return success("确认收货成功"); + } + + private ShopStoreRider requireActiveRider(Integer userId, Integer tenantId) { + if (userId == null) { + throw new BusinessException("请先登录"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + ShopStoreRider rider = shopStoreRiderService.getOne(new LambdaQueryWrapper() + .eq(ShopStoreRider::getUserId, userId) + .eq(ShopStoreRider::getTenantId, tenantId) + .eq(ShopStoreRider::getIsDelete, 0) + .last("limit 1")); + if (rider == null) { + throw new BusinessException("非配送员,无权限操作"); + } + if (rider.getStatus() == null || rider.getStatus() != 1) { + throw new BusinessException("配送员已禁用"); + } + return rider; + } + + private String buildAddressSnapshot(ShopUserAddress addr) { + if (addr == null) { + return null; + } + if (StrUtil.isNotBlank(addr.getFullAddress())) { + return addr.getFullAddress(); + } + // 兼容旧数据:fullAddress 为空时,拼接省市区 + 详细地址 + return StrUtil.blankToDefault( + StrUtil.join("", + StrUtil.nullToEmpty(addr.getProvince()), + StrUtil.nullToEmpty(addr.getCity()), + StrUtil.nullToEmpty(addr.getRegion()), + StrUtil.nullToEmpty(addr.getAddress()) + ), + addr.getAddress() + ); + } + + @PreAuthorize("hasAuthority('glt:gltTicketOrder:update')") + @OperationLog + @Operation(summary = "修改送水订单") + @PutMapping() + public ApiResult update(@RequestBody GltTicketOrder gltTicketOrder) { + if (gltTicketOrderService.updateById(gltTicketOrder)) { + Integer tenantId = getTenantId(); + // 后台指派配送员(直接改 riderId)时,同步商城订单为“已发货”(deliveryStatus=20) + if (gltTicketOrder != null + && gltTicketOrder.getId() != null + && gltTicketOrder.getRiderId() != null + && gltTicketOrder.getRiderId() > 0) { + gltTicketOrderService.markShopOrderShippedAfterRiderAssigned( + gltTicketOrder.getId(), + tenantId, + gltTicketOrder.getRiderId() + ); + } + // 后台直接改“已完成”(deliveryStatus=40)时,同步商城订单为“已完成”(orderStatus=1) + if (gltTicketOrder != null + && gltTicketOrder.getId() != null + && gltTicketOrder.getDeliveryStatus() != null + && gltTicketOrder.getDeliveryStatus() == GltTicketOrderService.DELIVERY_STATUS_FINISHED) { + gltTicketOrderService.markShopOrderCompletedAfterTicketFinished(gltTicketOrder.getId(), tenantId); + } + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('glt:gltTicketOrder:remove')") + @OperationLog + @Operation(summary = "删除送水订单") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (gltTicketOrderService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('glt:gltTicketOrder:save')") + @OperationLog + @Operation(summary = "批量添加送水订单") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (gltTicketOrderService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('glt:gltTicketOrder:update')") + @OperationLog + @Operation(summary = "批量修改送水订单") + @PutMapping("/batch") + public ApiResult removeBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(gltTicketOrderService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('glt:gltTicketOrder:remove')") + @OperationLog + @Operation(summary = "批量删除送水订单") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (gltTicketOrderService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java b/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java new file mode 100644 index 0000000..9038bb1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java @@ -0,0 +1,214 @@ +package com.gxwebsoft.glt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 送水订单 + * + * @author 科技小王子 + * @since 2026-02-05 18:50:20 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "GltTicketOrder对象", description = "送水订单") +public class GltTicketOrder implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @Schema(description = "用户水票ID") + private Integer userTicketId; + + @Schema(description = "订单编号") + @TableField(exist = false) + private String orderNo; + + @Schema(description = "门店ID") + private Integer storeId; + + @Schema(description = "门店名称") + @TableField(exist = false) + private String storeName; + + @Schema(description = "门店地址") + @TableField(exist = false) + private String storeAddress; + + @Schema(description = "门店手机号") + @TableField(exist = false) + private String storePhone; + + @Schema(description = "配送员") + private Integer riderId; + + @Schema(description = "配送员名称") + @TableField(exist = false) + private String riderName; + + @Schema(description = "配送员手机号") + @TableField(exist = false) + private String riderPhone; + + @Schema(description = "仓库ID") + private Integer warehouseId; + + @Schema(description = "仓库名称") + @TableField(exist = false) + private String warehouseName; + + @Schema(description = "仓库地址") + @TableField(exist = false) + private String warehouseAddress; + + @Schema(description = "仓库手机号") + @TableField(exist = false) + private String warehousePhone; + + @Schema(description = "关联收货地址") + private Integer addressId; + + @Schema(description = "收货地址") + private String address; + + @Schema(description = "省") + @TableField(exist = false) + private String province; + + @Schema(description = "市") + @TableField(exist = false) + private String city; + + @Schema(description = "区") + @TableField(exist = false) + private String region; + + @Schema(description = "买家留言") + private String buyerRemarks; + + @Schema(description = "用于统计") + private BigDecimal price; + + @Schema(description = "购买数量") + private Integer totalNum; + + @Schema(description = "配送时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private String sendTime; + + @Schema(description = "配送状态:10待配送、20配送中、30待客户确认、40已完成") + private Integer deliveryStatus; + + @Schema(description = "开始配送时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime sendStartTime; + + @Schema(description = "确认送达时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime sendEndTime; + + @Schema(description = "送达拍照留档图片URL") + private String sendEndImg; + + @Schema(description = "客户确认收货时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime receiveConfirmTime; + + @Schema(description = "确认方式:10客户手动确认、20配送照片自动确认、30超时自动确认") + private Integer receiveConfirmType; + + @Schema(description = "用户ID") + private Integer userId; + + @Schema(description = "昵称") + @TableField(exist = false) + private String nickname; + + @Schema(description = "手机号") + @TableField(exist = false) + private String phone; + + @Schema(description = "头像") + @TableField(exist = false) + private String avatar; + + @Schema(description = "收货人姓名") + @TableField(exist = false) + private String receiverName; + + @Schema(description = "收货人手机号") + @TableField(exist = false) + private String receiverPhone; + + @Schema(description = "收货省") + @TableField(exist = false) + private String receiverProvince; + + @Schema(description = "收货市") + @TableField(exist = false) + private String receiverCity; + + @Schema(description = "收货区") + @TableField(exist = false) + private String receiverRegion; + + @Schema(description = "收货详细地址") + @TableField(exist = false) + private String receiverAddress; + + @Schema(description = "收货完整地址") + @TableField(exist = false) + private String receiverFullAddress; + + @Schema(description = "收货纬度") + @TableField(exist = false) + private String receiverLat; + + @Schema(description = "收货经度") + @TableField(exist = false) + private String receiverLng; + + @Schema(description = "门店经纬度(lng,lat)") + @TableField(exist = false) + private String storeLngAndLat; + + @Schema(description = "仓库经纬度(lng,lat)") + @TableField(exist = false) + private String warehouseLngAndLat; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; + + @Schema(description = "租户id") + private Integer tenantId; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + +} diff --git a/src/main/java/com/gxwebsoft/glt/mapper/GltTicketOrderMapper.java b/src/main/java/com/gxwebsoft/glt/mapper/GltTicketOrderMapper.java new file mode 100644 index 0000000..8af4a07 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/mapper/GltTicketOrderMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.glt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.param.GltTicketOrderParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 送水订单Mapper + * + * @author 科技小王子 + * @since 2026-02-05 18:50:20 + */ +public interface GltTicketOrderMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") GltTicketOrderParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") GltTicketOrderParam param); + +} diff --git a/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml new file mode 100644 index 0000000..91312a4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml @@ -0,0 +1,108 @@ + + + + + + + SELECT a.*, b.name as storeName, b.address as storeAddress, b.phone as storePhone, b.lng_and_lat as storeLngAndLat, + w.name as warehouseName, w.address as warehouseAddress, w.phone as warehousePhone, w.lng_and_lat as warehouseLngAndLat, + c.real_name as riderName, c.mobile as riderPhone, + u.nickname, u.phone, u.avatar, + d.name as receiverName, d.phone as receiverPhone, + d.province as receiverProvince, d.city as receiverCity, d.region as receiverRegion, + d.address as receiverAddress, d.full_address as receiverFullAddress, d.lat as receiverLat, d.lng as receiverLng, + COALESCE(o.order_no, f.order_no) as orderNo + FROM glt_ticket_order a + LEFT JOIN shop_store b ON a.store_id = b.id + LEFT JOIN shop_store_warehouse w ON a.warehouse_id = w.id + LEFT JOIN shop_store_rider c ON a.rider_id = c.user_id + LEFT JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id + LEFT JOIN shop_user_address d ON a.address_id = d.id + LEFT JOIN glt_user_ticket f ON a.user_ticket_id = f.id + LEFT JOIN shop_order o ON f.order_id = o.order_id AND f.tenant_id = o.tenant_id AND o.deleted = 0 + + + + AND a.id = #{param.id} + + + AND a.user_ticket_id = #{param.userTicketId} + + + AND a.store_id = #{param.storeId} + + + AND a.rider_id = #{param.riderId} + + + AND a.delivery_status = #{param.deliveryStatus} + + + AND a.warehouse_id = #{param.warehouseId} + + + AND a.address_id = #{param.addressId} + + + AND a.address LIKE CONCAT('%', #{param.address}, '%') + + + AND a.buyer_remarks LIKE CONCAT('%', #{param.buyerRemarks}, '%') + + + AND a.price = #{param.price} + + + AND a.total_num = #{param.totalNum} + + + AND a.user_id = #{param.userId} + + + AND a.sort_number = #{param.sortNumber} + + + AND a.comments LIKE CONCAT('%', #{param.comments}, '%') + + + AND a.status = #{param.status} + + + AND a.tenant_id = #{param.tenantId} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND ( + a.address LIKE CONCAT('%', #{param.keywords}, '%') + OR a.buyer_remarks LIKE CONCAT('%', #{param.keywords}, '%') + OR a.comments LIKE CONCAT('%', #{param.keywords}, '%') + OR b.name LIKE CONCAT('%', #{param.keywords}, '%') + OR u.nickname LIKE CONCAT('%', #{param.keywords}, '%') + OR u.phone LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderDeliveredParam.java b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderDeliveredParam.java new file mode 100644 index 0000000..7158cd2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderDeliveredParam.java @@ -0,0 +1,18 @@ +package com.gxwebsoft.glt.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 配送员确认送达参数 + */ +@Data +public class GltTicketOrderDeliveredParam implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "送达拍照留档图片URL") + private String sendEndImg; +} + diff --git a/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java new file mode 100644 index 0000000..976a20b --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java @@ -0,0 +1,86 @@ +package com.gxwebsoft.glt.param; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * 送水订单查询参数 + * + * @author 科技小王子 + * @since 2026-02-05 18:50:19 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "GltTicketOrderParam对象", description = "送水订单查询参数") +public class GltTicketOrderParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @QueryField(type = QueryType.EQ) + private Integer id; + + @Schema(description = "用户水票ID") + @QueryField(type = QueryType.EQ) + private Integer userTicketId; + + @Schema(description = "门店ID") + @QueryField(type = QueryType.EQ) + private Integer storeId; + + @Schema(description = "配送员") + @QueryField(type = QueryType.EQ) + private Integer riderId; + + @Schema(description = "配送状态:10待配送、20配送中、30待客户确认、40已完成") + @QueryField(type = QueryType.EQ) + private Integer deliveryStatus; + + @Schema(description = "仓库ID") + @QueryField(type = QueryType.EQ) + private Integer warehouseId; + + @Schema(description = "关联收货地址") + @QueryField(type = QueryType.EQ) + private Integer addressId; + + @Schema(description = "收货地址") + private String address; + + @Schema(description = "买家留言") + private String buyerRemarks; + + @Schema(description = "用于统计") + @QueryField(type = QueryType.EQ) + private BigDecimal price; + + @Schema(description = "购买数量") + @QueryField(type = QueryType.EQ) + private Integer totalNum; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "排序(数字越小越靠前)") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态, 0正常, 1冻结") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @QueryField(type = QueryType.EQ) + private Integer deleted; + +} diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderService.java new file mode 100644 index 0000000..9f3fc81 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderService.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.glt.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.param.GltTicketOrderParam; + +import java.util.List; +import java.time.LocalDateTime; + +/** + * 送水订单Service + * + * @author 科技小王子 + * @since 2026-02-05 18:50:20 + */ +public interface GltTicketOrderService extends IService { + + int DELIVERY_STATUS_WAITING = 10; + int DELIVERY_STATUS_DELIVERING = 20; + int DELIVERY_STATUS_WAIT_CONFIRM = 30; + int DELIVERY_STATUS_FINISHED = 40; + + int RECEIVE_CONFIRM_TYPE_MANUAL = 10; + int RECEIVE_CONFIRM_TYPE_PHOTO = 20; + int RECEIVE_CONFIRM_TYPE_TIMEOUT = 30; + + /** + * 分页关联查询 + * + * @param param 查询参数 + * @return PageResult + */ + PageResult pageRel(GltTicketOrderParam param); + + /** + * 关联查询全部 + * + * @param param 查询参数 + * @return List + */ + List listRel(GltTicketOrderParam param); + + /** + * 根据id查询 + * + * @param id + * @return GltTicketOrder + */ + GltTicketOrder getByIdRel(Integer id); + + /** + * 下单(事务):校验水票 -> 扣减水票 -> 写核销记录 -> 创建送水订单。 + * + * @param gltTicketOrder 订单请求体 + * @param userId 当前登录用户ID + * @param tenantId 当前租户ID + * @return 创建后的订单(含id) + */ + GltTicketOrder createWithWriteOff(GltTicketOrder gltTicketOrder, Integer userId, Integer tenantId); + + /** + * 配送员接单(原子):仅当 riderId 为空(或0)且 deliveryStatus=10 时写入 riderId。 + */ + void accept(Integer id, Integer riderId, Integer tenantId); + + /** + * 指派/接单成功后,同步关联商城订单发货状态为“已发货”(deliveryStatus=20)。 + * + *

用于后台指派配送员(不走接单接口)等场景的状态兜底同步。

+ */ + void markShopOrderShippedAfterRiderAssigned(Integer ticketOrderId, Integer tenantId, Integer riderId); + + /** + * 送水订单完成后,同步关联商城订单为“已完成”(orderStatus=1)。 + * + *

用于后台直接改 deliveryStatus=40 等不经过 confirmReceive/autoConfirmTimeout 的兜底同步。

+ */ + void markShopOrderCompletedAfterTicketFinished(Integer ticketOrderId, Integer tenantId); + + /** + * 配送员开始配送:10 -> 20,并写 sendStartTime。 + */ + void start(Integer id, Integer riderId, Integer tenantId); + + /** + * 配送员确认送达:20 -> 30,并写 sendEndTime / sendEndImg。 + */ + void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg); + + /** + * 用户确认收货:30 -> 40,并写 receiveConfirmTime / receiveConfirmType=10。 + */ + void confirmReceive(Integer id, Integer userId, Integer tenantId); + + /** + * 超时自动确认收货: + * - 扫描已送达待确认(30)且送达时间(sendEndTime)超过指定小时数的订单 + * - 自动置为已完成(40),并写 receiveConfirmTime / receiveConfirmType=30 + * + * @param tenantId 租户ID + * @param now 当前时间 + * @param timeoutHours 超时小时数(如24) + * @param batchSize 每次处理条数上限 + * @return 本次自动确认条数 + */ + int autoConfirmTimeout(Integer tenantId, LocalDateTime now, int timeoutHours, int batchSize); + +} diff --git a/src/main/java/com/gxwebsoft/glt/service/GltUserTicketAutoReleaseService.java b/src/main/java/com/gxwebsoft/glt/service/GltUserTicketAutoReleaseService.java new file mode 100644 index 0000000..83a7fb3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltUserTicketAutoReleaseService.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.glt.service; + +import java.time.LocalDateTime; + +/** + * 冻结水票自动释放(到达 release_time 后执行:frozen -> available)。 + */ +public interface GltUserTicketAutoReleaseService { + + /** + * 释放到期的冻结水票 + * + * @param now 当前时间(用于测试/可控时钟) + * @param batchSize 单次处理上限 + * @return 成功释放的条数 + */ + int releaseDue(LocalDateTime now, int batchSize); + + /** + * 释放到期的冻结水票(使用系统当前时间) + */ + default int releaseDue(int batchSize) { + return releaseDue(LocalDateTime.now(), batchSize); + } +} + diff --git a/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java b/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java new file mode 100644 index 0000000..cca8400 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java @@ -0,0 +1,879 @@ +package com.gxwebsoft.glt.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.mapper.UserMapper; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.entity.GltUserTicket; +import com.gxwebsoft.glt.entity.GltUserTicketLog; +import com.gxwebsoft.glt.mapper.GltTicketOrderMapper; +import com.gxwebsoft.glt.mapper.GltUserTicketMapper; +import com.gxwebsoft.glt.param.GltTicketOrderParam; +import com.gxwebsoft.glt.service.GltTicketOrderService; +import com.gxwebsoft.glt.service.GltUserTicketLogService; +import com.gxwebsoft.glt.service.GltUserTicketService; +import com.gxwebsoft.shop.entity.ShopDealerCapital; +import com.gxwebsoft.shop.entity.ShopDealerUser; +import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.entity.ShopOrderGoods; +import com.gxwebsoft.shop.service.ShopDealerCapitalService; +import com.gxwebsoft.shop.service.ShopDealerUserService; +import com.gxwebsoft.shop.service.ShopOrderGoodsService; +import com.gxwebsoft.shop.service.ShopOrderService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 送水订单Service实现 + * + * @author 科技小王子 + * @since 2026-02-05 18:50:20 + */ +@Slf4j +@Service +public class GltTicketOrderServiceImpl extends ServiceImpl implements GltTicketOrderService { + + public static final int CHANGE_TYPE_WRITE_OFF = 20; + private static final BigDecimal RIDER_UNIT_COMMISSION = new BigDecimal("0.10"); + private static final int RIDER_COMMISSION_SCALE = 2; + private static final int TENANT_ID_10584 = 10584; + + @Resource + private GltUserTicketMapper gltUserTicketMapper; + + @Resource + private GltUserTicketService gltUserTicketService; + + @Resource + private GltUserTicketLogService gltUserTicketLogService; + + @Resource + private TransactionTemplate transactionTemplate; + + @Resource + private ShopDealerUserService shopDealerUserService; + + @Resource + private ShopDealerCapitalService shopDealerCapitalService; + + @Resource + private UserMapper userMapper; + + @Resource + private ShopOrderService shopOrderService; + + @Resource + private ShopOrderGoodsService shopOrderGoodsService; + + @Override + public PageResult pageRel(GltTicketOrderParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(GltTicketOrderParam param) { + List list = baseMapper.selectListRel(param); + // 排序 + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, create_time desc"); + return page.sortRecords(list); + } + + @Override + public GltTicketOrder getByIdRel(Integer id) { + GltTicketOrderParam param = new GltTicketOrderParam(); + param.setId(id); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public GltTicketOrder createWithWriteOff(GltTicketOrder gltTicketOrder, Integer userId, Integer tenantId) { + if (gltTicketOrder == null) { + throw new BusinessException("订单参数不能为空"); + } + if (userId == null) { + throw new BusinessException("请先登录"); + } + Integer userTicketId = gltTicketOrder.getUserTicketId(); + if (userTicketId == null) { + throw new BusinessException("userTicketId不能为空"); + } + int totalNum = gltTicketOrder.getTotalNum() == null ? 0 : gltTicketOrder.getTotalNum(); + if (totalNum <= 0) { + throw new BusinessException("totalNum必须大于0"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + + // 1) 校验水票归属当前用户 + 正常状态,并锁定记录,避免并发扣减导致日志不准确 + GltUserTicket userTicket = gltUserTicketMapper.selectByIdForUpdate(userTicketId, userId, tenantId); + if (userTicket == null) { + throw new BusinessException("水票不存在、已失效或无权限"); + } + int availableQty = userTicket.getAvailableQty() == null ? 0 : userTicket.getAvailableQty(); + int usedQty = userTicket.getUsedQty() == null ? 0 : userTicket.getUsedQty(); + if (availableQty < totalNum) { + throw new BusinessException("水票数量不足"); + } + + // 2) 更新 glt_user_ticket: availableQty -= totalNum, usedQty += totalNum + LocalDateTime now = LocalDateTime.now(); + int availableAfter = availableQty - totalNum; + int usedAfter = usedQty + totalNum; + userTicket.setAvailableQty(availableAfter); + userTicket.setUsedQty(usedAfter); + userTicket.setUpdateTime(now); + if (!gltUserTicketService.updateById(userTicket)) { + throw new BusinessException("扣减水票失败"); + } + + // 4) 插入 glt_ticket_order(storeId/addressId/totalNum/buyerRemarks…) + gltTicketOrder.setUserId(userId); + // 订单基础字段由后端兜底,避免前端误传/恶意传参 + gltTicketOrder.setStatus(0); + gltTicketOrder.setDeleted(0); + gltTicketOrder.setTenantId(tenantId); + if (gltTicketOrder.getDeliveryStatus() == null) { + gltTicketOrder.setDeliveryStatus(DELIVERY_STATUS_WAITING); + } + if (gltTicketOrder.getSortNumber() == null) { + gltTicketOrder.setSortNumber(0); + } + if (gltTicketOrder.getCreateTime() == null) { + gltTicketOrder.setCreateTime(now); + } + gltTicketOrder.setUpdateTime(now); + if (!this.save(gltTicketOrder)) { + throw new BusinessException("创建订单失败"); + } + + // 3) 插入 glt_user_ticket_log(核销记录) + GltUserTicketLog log = new GltUserTicketLog(); + log.setUserTicketId(userTicketId); + log.setChangeType(CHANGE_TYPE_WRITE_OFF); + log.setChangeAvailable(-totalNum); + log.setChangeFrozen(0); + log.setChangeUsed(totalNum); + log.setAvailableAfter(availableAfter); + log.setFrozenAfter(userTicket.getFrozenQty() == null ? 0 : userTicket.getFrozenQty()); + log.setUsedAfter(usedAfter); + log.setOrderId(gltTicketOrder.getId()); + log.setOrderNo(gltTicketOrder.getId() == null ? null : String.valueOf(gltTicketOrder.getId())); + log.setUserId(userId); + log.setSortNumber(0); + String comments = gltTicketOrder.getComments(); + if (StrUtil.isBlank(comments)) { + comments = gltTicketOrder.getBuyerRemarks(); + } + if (StrUtil.isBlank(comments)) { + comments = "水票下单核销"; + } + log.setComments(comments); + log.setStatus(0); + log.setDeleted(0); + log.setTenantId(tenantId); + log.setCreateTime(now); + log.setUpdateTime(now); + if (!gltUserTicketLogService.save(log)) { + throw new BusinessException("写入核销记录失败"); + } + + return gltTicketOrder; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void accept(Integer id, Integer riderId, Integer tenantId) { + if (id == null) { + throw new BusinessException("订单id不能为空"); + } + if (riderId == null) { + throw new BusinessException("配送员信息缺失"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + + // 原子接单:避免并发抢单 + LocalDateTime now = LocalDateTime.now(); + boolean ok = this.lambdaUpdate() + .set(GltTicketOrder::getRiderId, riderId) + .set(GltTicketOrder::getUpdateTime, now) + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .and(w -> w.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAITING) + .or().isNull(GltTicketOrder::getDeliveryStatus)) + .and(w -> w.isNull(GltTicketOrder::getRiderId).or().eq(GltTicketOrder::getRiderId, 0)) + .update(); + if (ok) { + // 接单成功后,同步商城订单发货状态:10未发货 -> 20已发货 + updateShopOrderDeliveryStatusAfterAccept(id, tenantId, riderId, now); + return; + } + + // 回查给出更明确的错误 + GltTicketOrder order = this.lambdaQuery() + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .one(); + if (order == null) { + throw new BusinessException("订单不存在"); + } + if (order.getRiderId() != null && order.getRiderId() > 0) { + throw new BusinessException("订单已被其他配送员接单"); + } + throw new BusinessException("订单状态不允许接单"); + } + + @Override + public void markShopOrderShippedAfterRiderAssigned(Integer ticketOrderId, Integer tenantId, Integer riderId) { + updateShopOrderDeliveryStatusAfterAccept(ticketOrderId, tenantId, riderId, LocalDateTime.now()); + } + + @Override + public void markShopOrderCompletedAfterTicketFinished(Integer ticketOrderId, Integer tenantId) { + updateShopOrderOrderStatusAfterTicketFinished(ticketOrderId, tenantId, LocalDateTime.now()); + } + + private void updateShopOrderDeliveryStatusAfterAccept(Integer ticketOrderId, Integer tenantId, Integer riderId, LocalDateTime now) { + if (ticketOrderId == null || tenantId == null) { + return; + } + + // 找到关联水票的商城订单(glt_user_ticket.orderId / orderNo) + GltTicketOrder ticketOrder = this.lambdaQuery() + .select(GltTicketOrder::getId, GltTicketOrder::getUserTicketId, GltTicketOrder::getRiderId) + .eq(GltTicketOrder::getId, ticketOrderId) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .last("limit 1") + .one(); + if (ticketOrder == null || ticketOrder.getUserTicketId() == null) { + return; + } + + Integer actualRiderId = (riderId != null && riderId > 0) ? riderId : ticketOrder.getRiderId(); + + GltUserTicket userTicket = gltUserTicketService.getOne( + new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, ticketOrder.getUserTicketId()) + .last("limit 1") + ); + if (userTicket == null) { + return; + } + + Integer shopOrderId = userTicket.getOrderId(); + String shopOrderNo = userTicket.getOrderNo(); + boolean resolvedByOrderGoodsId = false; + // 兼容历史数据:部分水票可能只写了 orderGoodsId(未写 orderId/orderNo),此处兜底通过 orderGoodsId 反查 ShopOrder.orderId。 + if (shopOrderId == null && !StringUtils.hasText(shopOrderNo) && userTicket.getOrderGoodsId() != null) { + ShopOrderGoods og = shopOrderGoodsService.getOne( + new LambdaQueryWrapper() + .select(ShopOrderGoods::getOrderId) + .eq(ShopOrderGoods::getTenantId, tenantId) + .eq(ShopOrderGoods::getId, userTicket.getOrderGoodsId()) + .last("limit 1") + ); + if (og != null) { + shopOrderId = og.getOrderId(); + resolvedByOrderGoodsId = shopOrderId != null; + } + } + if (shopOrderId == null && !StringUtils.hasText(shopOrderNo)) { + log.warn("同步商城订单发货状态失败:未找到关联商城订单 - tenantId={}, ticketOrderId={}, userTicketId={}, userTicket.orderId={}, userTicket.orderNo={}, userTicket.orderGoodsId={}", + tenantId, ticketOrderId, userTicket.getId(), userTicket.getOrderId(), userTicket.getOrderNo(), userTicket.getOrderGoodsId()); + return; + } + // 若是通过 orderGoodsId 兜底反查到 orderId,则顺便回填 glt_user_ticket.order_id/order_no,减少后续同步/查询依赖兜底分支。 + if (resolvedByOrderGoodsId && userTicket.getOrderId() == null && shopOrderId != null) { + if (!StringUtils.hasText(shopOrderNo)) { + ShopOrder order = shopOrderService.getOne( + new LambdaQueryWrapper() + .select(ShopOrder::getOrderNo) + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderId, shopOrderId) + .last("limit 1") + ); + if (order != null) { + shopOrderNo = order.getOrderNo(); + } + } + + LambdaUpdateWrapper backfill = new LambdaUpdateWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, userTicket.getId()); + backfill.set(GltUserTicket::getOrderId, shopOrderId); + if (!StringUtils.hasText(userTicket.getOrderNo()) && StringUtils.hasText(shopOrderNo)) { + backfill.set(GltUserTicket::getOrderNo, shopOrderNo); + } + backfill.set(GltUserTicket::getUpdateTime, now); + try { + gltUserTicketService.update(backfill); + } catch (Exception e) { + log.debug("回填水票关联商城订单信息失败(不影响主流程) - tenantId={}, userTicketId={}, orderId={}, orderNo={}", + tenantId, userTicket.getId(), shopOrderId, shopOrderNo, e); + } + } + + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + // deliveryStatus 已经是 20 时也可能需要补写/更新 riderId,因此条件包含 riderId 不一致的场景 + .and(w -> { + w.ne(ShopOrder::getDeliveryStatus, 20).or().isNull(ShopOrder::getDeliveryStatus); + if (actualRiderId != null && actualRiderId > 0) { + w.or().ne(ShopOrder::getRiderId, actualRiderId).or().isNull(ShopOrder::getRiderId); + } + }) + .set(ShopOrder::getDeliveryStatus, 20) + .set(ShopOrder::getUpdateTime, now); + if (actualRiderId != null && actualRiderId > 0) { + uw.set(ShopOrder::getRiderId, actualRiderId); + } + if (shopOrderId != null) { + uw.eq(ShopOrder::getOrderId, shopOrderId); + } else { + uw.eq(ShopOrder::getOrderNo, shopOrderNo); + } + + boolean updated = shopOrderService.update(uw); + if (updated) { + return; + } + + // 幂等:若已是 20,则视为成功;否则记录日志便于排查关联关系/数据缺失 + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getDeliveryStatus, 20); + if (actualRiderId != null && actualRiderId > 0) { + qw.eq(ShopOrder::getRiderId, actualRiderId); + } + if (shopOrderId != null) { + qw.eq(ShopOrder::getOrderId, shopOrderId); + } else { + qw.eq(ShopOrder::getOrderNo, shopOrderNo); + } + if (shopOrderService.count(qw) <= 0) { + log.warn("接单/指派成功但同步商城订单发货状态/配送员失败 - tenantId={}, ticketOrderId={}, riderId={}, shopOrderId={}, shopOrderNo={}", + tenantId, ticketOrderId, actualRiderId, shopOrderId, shopOrderNo); + } + } + + @Override + public void start(Integer id, Integer riderId, Integer tenantId) { + if (id == null) { + throw new BusinessException("订单id不能为空"); + } + if (riderId == null) { + throw new BusinessException("配送员信息缺失"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + + LocalDateTime now = LocalDateTime.now(); + boolean ok = this.lambdaUpdate() + .set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING) + .set(GltTicketOrder::getSendStartTime, now) + .set(GltTicketOrder::getUpdateTime, now) + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getRiderId, riderId) + .and(w -> w.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAITING) + .or().isNull(GltTicketOrder::getDeliveryStatus)) + .update(); + if (ok) { + return; + } + + GltTicketOrder order = this.lambdaQuery() + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .one(); + if (order == null) { + throw new BusinessException("订单不存在"); + } + if (!riderId.equals(order.getRiderId())) { + throw new BusinessException("无权限操作该订单"); + } + if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_DELIVERING) { + // 幂等:重复开始配送视为成功 + return; + } + throw new BusinessException("订单状态不允许开始配送"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg) { + if (id == null) { + throw new BusinessException("订单id不能为空"); + } + if (riderId == null) { + throw new BusinessException("配送员信息缺失"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + + LocalDateTime now = LocalDateTime.now(); + var update = this.lambdaUpdate() + .set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM) + .set(GltTicketOrder::getSendEndTime, now) + .set(GltTicketOrder::getUpdateTime, now); + if (StringUtils.hasText(sendEndImg)) { + update.set(GltTicketOrder::getSendEndImg, sendEndImg); + } + boolean ok = update + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getRiderId, riderId) + .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING) + .update(); + if (ok) { + // 配送员拍照上传送达后可触发提成结算(幂等,重复调用不会重复入账) + settleRiderCommissionIfEligible(id, tenantId, true); + // 配送员确认送达后,同步商城订单为“已完成”(orderStatus=1) + updateShopOrderOrderStatusAfterTicketFinished(id, tenantId, now); + return; + } + + GltTicketOrder order = this.lambdaQuery() + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .one(); + if (order == null) { + throw new BusinessException("订单不存在"); + } + if (!riderId.equals(order.getRiderId())) { + throw new BusinessException("无权限操作该订单"); + } + if (order.getDeliveryStatus() != null + && (order.getDeliveryStatus() == DELIVERY_STATUS_WAIT_CONFIRM + || order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED)) { + // 幂等:重复送达视为成功 + settleRiderCommissionIfEligible(id, tenantId, true); + updateShopOrderOrderStatusAfterTicketFinished(id, tenantId, LocalDateTime.now()); + return; + } + throw new BusinessException("订单状态不允许确认送达"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void confirmReceive(Integer id, Integer userId, Integer tenantId) { + if (id == null) { + throw new BusinessException("订单id不能为空"); + } + if (userId == null) { + throw new BusinessException("请先登录"); + } + if (tenantId == null) { + throw new BusinessException("租户信息缺失"); + } + + LocalDateTime now = LocalDateTime.now(); + boolean ok = this.lambdaUpdate() + .set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_FINISHED) + .set(GltTicketOrder::getReceiveConfirmTime, now) + .set(GltTicketOrder::getReceiveConfirmType, RECEIVE_CONFIRM_TYPE_MANUAL) + .set(GltTicketOrder::getUpdateTime, now) + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getUserId, userId) + .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM) + .update(); + if (ok) { + // 用户确认收货完成后触发配送员提成结算(幂等,重复调用不会重复入账) + settleRiderCommissionIfEligible(id, tenantId, false); + // 送水订单完成后,同步商城订单为“已完成”(orderStatus=1) + updateShopOrderOrderStatusAfterTicketFinished(id, tenantId, now); + return; + } + + GltTicketOrder order = this.lambdaQuery() + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .one(); + if (order == null) { + throw new BusinessException("订单不存在"); + } + if (!userId.equals(order.getUserId())) { + throw new BusinessException("无权限操作该订单"); + } + if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED) { + // 幂等:重复确认收货视为成功 + settleRiderCommissionIfEligible(id, tenantId, false); + updateShopOrderOrderStatusAfterTicketFinished(id, tenantId, LocalDateTime.now()); + return; + } + throw new BusinessException("订单状态不允许确认收货"); + } + + @Override + public int autoConfirmTimeout(Integer tenantId, LocalDateTime now, int timeoutHours, int batchSize) { + if (tenantId == null) { + return 0; + } + if (now == null) { + now = LocalDateTime.now(); + } + int hours = Math.max(timeoutHours, 1); + int limit = Math.max(batchSize, 1); + LocalDateTime deadline = now.minusHours(hours); + + List candidates = this.lambdaQuery() + .select(GltTicketOrder::getId) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM) + .isNotNull(GltTicketOrder::getSendEndTime) + .le(GltTicketOrder::getSendEndTime, deadline) + .orderByAsc(GltTicketOrder::getSendEndTime) + .orderByAsc(GltTicketOrder::getId) + .last("limit " + limit) + .list(); + if (candidates == null || candidates.isEmpty()) { + return 0; + } + + int confirmed = 0; + for (GltTicketOrder item : candidates) { + Integer id = item != null ? item.getId() : null; + if (id == null) { + continue; + } + try { + final LocalDateTime nowFinal = now; + final LocalDateTime deadlineFinal = deadline; + Boolean ok = transactionTemplate.execute(status -> { + boolean updated = this.lambdaUpdate() + .set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_FINISHED) + .set(GltTicketOrder::getReceiveConfirmTime, nowFinal) + .set(GltTicketOrder::getReceiveConfirmType, RECEIVE_CONFIRM_TYPE_TIMEOUT) + .set(GltTicketOrder::getUpdateTime, nowFinal) + .eq(GltTicketOrder::getId, id) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM) + .le(GltTicketOrder::getSendEndTime, deadlineFinal) + .update(); + if (!updated) { + return false; + } + // 超时自动确认收货后,也按“完成”逻辑触发配送员提成结算(幂等)。 + settleRiderCommissionIfEligible(id, tenantId, false); + updateShopOrderOrderStatusAfterTicketFinished(id, tenantId, nowFinal); + return true; + }); + if (Boolean.TRUE.equals(ok)) { + confirmed++; + } + } catch (Exception e) { + log.warn("送水订单超时自动确认收货失败 - tenantId={}, ticketOrderId={}", tenantId, id, e); + } + } + return confirmed; + } + + private void updateShopOrderOrderStatusAfterTicketFinished(Integer ticketOrderId, Integer tenantId, LocalDateTime now) { + if (ticketOrderId == null || tenantId == null) { + return; + } + if (now == null) { + now = LocalDateTime.now(); + } + + // 找到关联水票的商城订单(glt_user_ticket.orderId / orderNo) + GltTicketOrder ticketOrder = this.lambdaQuery() + .select(GltTicketOrder::getId, GltTicketOrder::getUserTicketId) + .eq(GltTicketOrder::getId, ticketOrderId) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .last("limit 1") + .one(); + if (ticketOrder == null || ticketOrder.getUserTicketId() == null) { + return; + } + + GltUserTicket userTicket = gltUserTicketService.getOne( + new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, ticketOrder.getUserTicketId()) + .last("limit 1") + ); + if (userTicket == null) { + return; + } + + Integer shopOrderId = userTicket.getOrderId(); + String shopOrderNo = userTicket.getOrderNo(); + boolean resolvedByOrderGoodsId = false; + // 兼容历史数据:部分水票可能只写了 orderGoodsId(未写 orderId/orderNo),此处兜底通过 orderGoodsId 反查 ShopOrder.orderId。 + if (shopOrderId == null && !StringUtils.hasText(shopOrderNo) && userTicket.getOrderGoodsId() != null) { + ShopOrderGoods og = shopOrderGoodsService.getOne( + new LambdaQueryWrapper() + .select(ShopOrderGoods::getOrderId) + .eq(ShopOrderGoods::getTenantId, tenantId) + .eq(ShopOrderGoods::getId, userTicket.getOrderGoodsId()) + .last("limit 1") + ); + if (og != null) { + shopOrderId = og.getOrderId(); + resolvedByOrderGoodsId = shopOrderId != null; + } + } + if (shopOrderId == null && !StringUtils.hasText(shopOrderNo)) { + log.warn("送水订单完成但未找到关联商城订单,无法同步完成状态 - tenantId={}, ticketOrderId={}, userTicketId={}, userTicket.orderId={}, userTicket.orderNo={}, userTicket.orderGoodsId={}", + tenantId, ticketOrderId, userTicket.getId(), userTicket.getOrderId(), userTicket.getOrderNo(), userTicket.getOrderGoodsId()); + return; + } + // 若是通过 orderGoodsId 兜底反查到 orderId,则顺便回填 glt_user_ticket.order_id/order_no,减少后续同步/查询依赖兜底分支。 + if (resolvedByOrderGoodsId && userTicket.getOrderId() == null && shopOrderId != null) { + if (!StringUtils.hasText(shopOrderNo)) { + ShopOrder order = shopOrderService.getOne( + new LambdaQueryWrapper() + .select(ShopOrder::getOrderNo) + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderId, shopOrderId) + .last("limit 1") + ); + if (order != null) { + shopOrderNo = order.getOrderNo(); + } + } + + LambdaUpdateWrapper backfill = new LambdaUpdateWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, userTicket.getId()); + backfill.set(GltUserTicket::getOrderId, shopOrderId); + if (!StringUtils.hasText(userTicket.getOrderNo()) && StringUtils.hasText(shopOrderNo)) { + backfill.set(GltUserTicket::getOrderNo, shopOrderNo); + } + backfill.set(GltUserTicket::getUpdateTime, now); + try { + gltUserTicketService.update(backfill); + } catch (Exception e) { + log.debug("回填水票关联商城订单信息失败(不影响主流程) - tenantId={}, userTicketId={}, orderId={}, orderNo={}", + tenantId, userTicket.getId(), shopOrderId, shopOrderNo, e); + } + } + + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .and(w -> w.ne(ShopOrder::getOrderStatus, 1).or().isNull(ShopOrder::getOrderStatus)) + .set(ShopOrder::getOrderStatus, 1) + .set(ShopOrder::getUpdateTime, now); + if (shopOrderId != null) { + uw.eq(ShopOrder::getOrderId, shopOrderId); + } else { + uw.eq(ShopOrder::getOrderNo, shopOrderNo); + } + + boolean updated = shopOrderService.update(uw); + if (updated) { + return; + } + + // 幂等:若已是 1,则视为成功;否则记录日志便于排查关联关系/数据缺失 + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderStatus, 1); + if (shopOrderId != null) { + qw.eq(ShopOrder::getOrderId, shopOrderId); + } else { + qw.eq(ShopOrder::getOrderNo, shopOrderNo); + } + if (shopOrderService.count(qw) <= 0) { + log.warn("送水订单完成但同步商城订单完成状态失败 - tenantId={}, ticketOrderId={}, shopOrderId={}, shopOrderNo={}", + tenantId, ticketOrderId, shopOrderId, shopOrderNo); + } + } + + private void settleRiderCommissionIfEligible(Integer ticketOrderId, Integer tenantId, boolean requirePhoto) { + if (ticketOrderId == null || tenantId == null) { + return; + } + // 目前仅租户10584启用该提成规则,避免影响其他租户历史逻辑。 + if (tenantId != TENANT_ID_10584) { + return; + } + + transactionTemplate.executeWithoutResult(status -> { + // 锁定送水订单行:避免并发下重复结算(如:配送员送达&用户确认收货同时触发) + GltTicketOrder order = this.lambdaQuery() + .eq(GltTicketOrder::getId, ticketOrderId) + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .last("limit 1 for update") + .one(); + if (order == null) { + return; + } + + Integer riderId = order.getRiderId(); + if (riderId == null || riderId <= 0) { + return; + } + + Integer deliveryStatus = order.getDeliveryStatus(); + if (requirePhoto) { + // 配送员拍照上传触发:至少需要到“待客户确认”或“已完成”状态,且存在送达照片。 + if (deliveryStatus == null || (deliveryStatus != DELIVERY_STATUS_WAIT_CONFIRM && deliveryStatus != DELIVERY_STATUS_FINISHED)) { + return; + } + if (!StringUtils.hasText(order.getSendEndImg())) { + return; + } + } else { + // 用户确认收货触发:必须为“已完成”状态。 + if (deliveryStatus == null || deliveryStatus != DELIVERY_STATUS_FINISHED) { + return; + } + } + + int qty = order.getTotalNum() == null ? 0 : order.getTotalNum(); + if (qty <= 0) { + return; + } + + BigDecimal money = RIDER_UNIT_COMMISSION + .multiply(BigDecimal.valueOf(qty)) + .setScale(RIDER_COMMISSION_SCALE, RoundingMode.HALF_UP); + if (money.signum() <= 0) { + return; + } + + String orderNo = "gltTicketOrder:" + order.getId(); + String comments = "配送员提成(ticketOrderId=" + order.getId() + ",unit=" + RIDER_UNIT_COMMISSION + ",qty=" + qty + ")"; + + // 幂等:同一送水订单同一配送员只结算一次 + boolean already = shopDealerCapitalService.count( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, tenantId) + .eq(ShopDealerCapital::getFlowType, 10) + .eq(ShopDealerCapital::getUserId, riderId) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .likeRight(ShopDealerCapital::getComments, "配送员提成(ticketOrderId=" + order.getId() + ",") + ) > 0; + if (already) { + return; + } + + // 送水订单提成:先入冻结金额 freeze_money(与分销订单佣金一致) + LocalDateTime now = LocalDateTime.now(); + boolean updated = shopDealerUserService.update( + new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ShopDealerUser::getTenantId, tenantId) + .eq(ShopDealerUser::getUserId, riderId) + .setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString()) + .setSql("total_money = IFNULL(total_money,0) + " + money.toPlainString()) + .set(ShopDealerUser::getUpdateTime, now) + ); + + if (!updated) { + // 配送员可能未开通分销账户:创建后再尝试入账一次(与分销结算逻辑保持一致) + ShopDealerUser existed = shopDealerUserService.getOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ShopDealerUser::getTenantId, tenantId) + .eq(ShopDealerUser::getUserId, riderId) + .last("limit 1") + ); + if (existed == null) { + ShopDealerUser newDealerUser = new ShopDealerUser(); + newDealerUser.setTenantId(tenantId); + newDealerUser.setUserId(riderId); + newDealerUser.setType(0); + newDealerUser.setIsDelete(0); + newDealerUser.setSortNumber(0); + newDealerUser.setFirstNum(0); + newDealerUser.setSecondNum(0); + newDealerUser.setThirdNum(0); + newDealerUser.setMoney(BigDecimal.ZERO); + newDealerUser.setFreezeMoney(BigDecimal.ZERO); + newDealerUser.setTotalMoney(BigDecimal.ZERO); + try { + User sysUser = userMapper.selectByIdIgnoreTenant(riderId); + if (sysUser != null) { + newDealerUser.setRealName(sysUser.getRealName() != null ? sysUser.getRealName() : sysUser.getNickname()); + newDealerUser.setMobile(sysUser.getPhone()); + } + } catch (Exception ignore) { + // 基础信息补齐失败不影响入账 + } + newDealerUser.setCreateTime(now); + newDealerUser.setUpdateTime(now); + shopDealerUserService.save(newDealerUser); + } + + updated = shopDealerUserService.update( + new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ShopDealerUser::getTenantId, tenantId) + .eq(ShopDealerUser::getUserId, riderId) + .setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString()) + .setSql("total_money = IFNULL(total_money,0) + " + money.toPlainString()) + .set(ShopDealerUser::getUpdateTime, now) + ); + if (!updated) { + log.warn("配送员提成入账失败:未找到/创建分销账户 - tenantId={}, ticketOrderId={}, riderId={}", tenantId, order.getId(), riderId); + return; + } + } + + ShopDealerCapital cap = new ShopDealerCapital(); + cap.setUserId(riderId); + cap.setOrderNo(orderNo); + cap.setFlowType(10); + cap.setMoney(money); + cap.setComments(comments); + cap.setToUserId(order.getUserId()); + cap.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); + cap.setTenantId(tenantId); + cap.setCreateTime(now); + cap.setUpdateTime(now); + shopDealerCapitalService.save(cap); + }); + } + +} diff --git a/src/main/java/com/gxwebsoft/glt/service/impl/GltUserTicketAutoReleaseServiceImpl.java b/src/main/java/com/gxwebsoft/glt/service/impl/GltUserTicketAutoReleaseServiceImpl.java new file mode 100644 index 0000000..fb576e4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/impl/GltUserTicketAutoReleaseServiceImpl.java @@ -0,0 +1,132 @@ +package com.gxwebsoft.glt.service.impl; + +import com.gxwebsoft.glt.entity.GltUserTicket; +import com.gxwebsoft.glt.entity.GltUserTicketLog; +import com.gxwebsoft.glt.entity.GltUserTicketRelease; +import com.gxwebsoft.glt.mapper.GltUserTicketLogMapper; +import com.gxwebsoft.glt.mapper.GltUserTicketMapper; +import com.gxwebsoft.glt.mapper.GltUserTicketReleaseMapper; +import com.gxwebsoft.glt.service.GltUserTicketAutoReleaseService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 冻结水票自动释放实现: + * - 读取到期且待释放的 release 记录(FOR UPDATE 加锁,防止重复处理) + * - 释放成功:更新 user_ticket 数量 & 将 release.status 置为 1,并写入流水 + * - 释放失败:将 release.status 置为 2(数据异常/冻结不足等) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GltUserTicketAutoReleaseServiceImpl implements GltUserTicketAutoReleaseService { + + /** + * 变更类型:冻结释放 + *

+ * 现有发放类型为 10(见 GltTicketIssueService.CHANGE_TYPE_ISSUE),这里取 11。 + */ + private static final int CHANGE_TYPE_RELEASE = 11; + + /** release.status:待释放 */ + private static final int RELEASE_STATUS_PENDING = 0; + /** release.status:已释放 */ + private static final int RELEASE_STATUS_DONE = 1; + /** release.status:释放失败(数据异常/冻结不足等,需人工处理) */ + private static final int RELEASE_STATUS_FAILED = 2; + + private final GltUserTicketReleaseMapper releaseMapper; + private final GltUserTicketMapper userTicketMapper; + private final GltUserTicketLogMapper userTicketLogMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public int releaseDue(LocalDateTime now, int batchSize) { + int limit = Math.max(batchSize, 1); + List dueList = releaseMapper.selectDueForUpdate(now, limit); + if (dueList == null || dueList.isEmpty()) { + return 0; + } + + int success = 0; + for (GltUserTicketRelease rel : dueList) { + if (rel.getId() == null || rel.getStatus() == null || rel.getStatus() != RELEASE_STATUS_PENDING) { + continue; + } + + Integer qtyObj = rel.getReleaseQty(); + if (qtyObj == null || qtyObj <= 0) { + markFailed(rel.getId(), now, "releaseQty无效"); + continue; + } + int qty = qtyObj; + + if (rel.getUserTicketId() == null || rel.getUserId() == null || rel.getTenantId() == null) { + markFailed(rel.getId(), now, "缺少userTicketId/userId/tenantId"); + continue; + } + + long userTicketIdLong = rel.getUserTicketId(); + if (userTicketIdLong > Integer.MAX_VALUE || userTicketIdLong < 1) { + markFailed(rel.getId(), now, "userTicketId超范围"); + continue; + } + Integer userTicketId = (int) userTicketIdLong; + + // 先释放冻结数量(条件更新,确保 frozen_qty >= qty) + int updated = userTicketMapper.releaseFrozenQty( + userTicketId, + rel.getUserId(), + rel.getTenantId(), + qty, + now + ); + if (updated <= 0) { + markFailed(rel.getId(), now, "冻结不足/水票不存在/状态不符"); + continue; + } + + // 写入流水(可用 +qty,冻结 -qty) + GltUserTicket ticket = userTicketMapper.selectById(userTicketId); + GltUserTicketLog logRow = new GltUserTicketLog(); + logRow.setUserTicketId(userTicketId); + logRow.setChangeType(CHANGE_TYPE_RELEASE); + logRow.setChangeAvailable(qty); + logRow.setChangeFrozen(-qty); + logRow.setChangeUsed(0); + if (ticket != null) { + logRow.setAvailableAfter(ticket.getAvailableQty()); + logRow.setFrozenAfter(ticket.getFrozenQty()); + logRow.setUsedAfter(ticket.getUsedQty()); + } + logRow.setOrderId(null); + logRow.setOrderNo(null); + logRow.setUserId(rel.getUserId()); + logRow.setSortNumber(0); + logRow.setComments("冻结水票到期释放"); + logRow.setStatus(0); + logRow.setDeleted(0); + logRow.setTenantId(rel.getTenantId()); + logRow.setCreateTime(now); + logRow.setUpdateTime(now); + userTicketLogMapper.insert(logRow); + + // 标记释放记录已完成(放在最后:若流水失败则回滚) + releaseMapper.updateStatus(rel.getId(), RELEASE_STATUS_DONE, now); + + success++; + } + + return success; + } + + private void markFailed(Long releaseId, LocalDateTime now, String reason) { + releaseMapper.updateStatus(releaseId, RELEASE_STATUS_FAILED, now); + log.warn("冻结水票释放标记失败 - releaseId={}, reason={}", releaseId, reason); + } +} diff --git a/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java new file mode 100644 index 0000000..79f1438 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java @@ -0,0 +1,417 @@ +package com.gxwebsoft.glt.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.common.core.annotation.IgnoreTenant; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.entity.GltTicketTemplate; +import com.gxwebsoft.glt.entity.GltUserTicket; +import com.gxwebsoft.glt.service.GltTicketOrderService; +import com.gxwebsoft.glt.service.GltTicketTemplateService; +import com.gxwebsoft.glt.service.GltUserTicketService; +import com.gxwebsoft.shop.entity.ShopDealerCapital; +import com.gxwebsoft.shop.entity.ShopDealerOrder; +import com.gxwebsoft.shop.entity.ShopDealerUser; +import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.service.ShopDealerCapitalService; +import com.gxwebsoft.shop.service.ShopDealerOrderService; +import com.gxwebsoft.shop.service.ShopDealerUserService; +import com.gxwebsoft.shop.service.ShopOrderService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 租户10584:分销佣金解冻任务 + * + *

规则:

+ *

1) 送水套餐(formId in 水票模板 goodsId):订单号关联的水票产生了第一次送水订单,且该第一次送水订单状态=已完成(40) -> 解冻。

+ *

2) 非送水套餐(formId not in 水票模板 goodsId):订单已确认收货(orderStatus=1) -> 解冻。

+ * + *

实现策略:以 ShopDealerCapital(flowType=10) 的“佣金明细”为解冻粒度, + * 每条佣金明细对应生成一条 ShopDealerCapital(flowType=50) 作为幂等标记,并执行 + * ShopDealerUser.freezeMoney -> ShopDealerUser.money 的转移。

+ */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "dealer.commission.unfreeze10584", name = "enabled", havingValue = "true", matchIfMissing = true) +public class DealerCommissionUnfreeze10584Task { + + private static final int TENANT_ID = 10584; + + private static final int ORDER_STATUS_CONFIRMED_RECEIVE = 1; + + private static final int MAX_ELIGIBLE_ORDER_NOS_PER_RUN = 200; + private static final int MAX_ELIGIBLE_TICKET_ORDERS_PER_RUN = 200; + private static final int MAX_CAPITALS_PER_RUN = 500; + + @Resource + private TransactionTemplate transactionTemplate; + + @Resource + private ShopOrderService shopOrderService; + + @Resource + private ShopDealerCapitalService shopDealerCapitalService; + + @Resource + private ShopDealerOrderService shopDealerOrderService; + + @Resource + private ShopDealerUserService shopDealerUserService; + + @Resource + private GltUserTicketService gltUserTicketService; + + @Resource + private GltTicketOrderService gltTicketOrderService; + + @Resource + private GltTicketTemplateService gltTicketTemplateService; + + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled(cron = "${dealer.commission.unfreeze10584.cron:0/30 * * * * ?}") + @IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤") + public void run() { + if (!running.compareAndSet(false, true)) { + log.warn("分销佣金解冻任务仍在执行中,本轮跳过 - tenantId={}", TENANT_ID); + return; + } + + try { + Set waterFormIds = loadWaterFormIds(); + if (waterFormIds.isEmpty()) { + // 送水/非送水的判断依赖模板 goodsId;拿不到会导致误判,宁可跳过本轮。 + log.warn("分销佣金解冻任务跳过:未找到水票模板 goodsId - tenantId={}", TENANT_ID); + return; + } + + // 先按“最近确认收货”的订单扫描,避免总是卡在很早的历史订单上。 + Set eligibleOrderNos = new HashSet<>(); + eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(waterFormIds, true)); + eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder(waterFormIds)); + + if (eligibleOrderNos.isEmpty()) { + return; + } + + List capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos); + if (capitals.isEmpty()) { + // 若本轮没有取到佣金明细,回退再按“最早确认收货”的订单扫一轮,尽量覆盖历史遗留未解冻。 + eligibleOrderNos.clear(); + eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(waterFormIds, false)); + eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder(waterFormIds)); + capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos); + } + + if (capitals.isEmpty()) { + return; + } + + int unfrozen = 0; + for (ShopDealerCapital cap : capitals) { + try { + boolean ok = unfreezeOneCapitalIfNeeded(cap); + if (ok) { + unfrozen++; + } + } catch (Exception e) { + log.error("解冻佣金失败,将在下次任务重试 - tenantId={}, capitalId={}, orderNo={}, userId={}", + TENANT_ID, cap != null ? cap.getId() : null, cap != null ? cap.getOrderNo() : null, cap != null ? cap.getUserId() : null, e); + } + } + + if (unfrozen > 0) { + log.info("分销佣金解冻完成 - tenantId={}, eligibleOrderNos={}, scannedCapitals={}, unfrozen={}", + TENANT_ID, eligibleOrderNos.size(), capitals.size(), unfrozen); + } + } finally { + running.set(false); + } + } + + private Set loadWaterFormIds() { + return gltTicketTemplateService.list( + new LambdaQueryWrapper() + .eq(GltTicketTemplate::getTenantId, TENANT_ID) + .eq(GltTicketTemplate::getDeleted, 0) + .isNotNull(GltTicketTemplate::getGoodsId) + ).stream() + .map(GltTicketTemplate::getGoodsId) + .collect(java.util.stream.Collectors.toSet()); + } + + private List findEligibleNonWaterOrderNos(Set waterFormIds, boolean newestFirst) { + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getPayStatus, true) + .eq(ShopOrder::getOrderStatus, ORDER_STATUS_CONFIRMED_RECEIVE) + .and(w -> w.notIn(ShopOrder::getFormId, waterFormIds).or().isNull(ShopOrder::getFormId)) + .isNotNull(ShopOrder::getOrderNo); + + if (newestFirst) { + qw.orderByDesc(ShopOrder::getUpdateTime).orderByDesc(ShopOrder::getOrderId); + } else { + qw.orderByAsc(ShopOrder::getUpdateTime).orderByAsc(ShopOrder::getOrderId); + } + qw.last("limit " + MAX_ELIGIBLE_ORDER_NOS_PER_RUN); + + return shopOrderService.list(qw).stream() + .map(ShopOrder::getOrderNo) + .filter(s -> s != null && !s.isBlank()) + .toList(); + } + + private Set findEligibleWaterOrderNosByFirstFinishedTicketOrder(Set waterFormIds) { + // 缓存减少 DB 往返:userTicketId -> 是否“第一次送水单已完成” + Map firstFinishedCache = new HashMap<>(); + Map userTicketOrderNoCache = new HashMap<>(); + + List finishedTicketOrders = gltTicketOrderService.list( + new LambdaQueryWrapper() + .eq(GltTicketOrder::getTenantId, TENANT_ID) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getDeliveryStatus, GltTicketOrderService.DELIVERY_STATUS_FINISHED) + .isNotNull(GltTicketOrder::getUserTicketId) + .orderByDesc(GltTicketOrder::getId) + .last("limit " + MAX_ELIGIBLE_TICKET_ORDERS_PER_RUN) + ); + + Set orderNos = new HashSet<>(); + for (GltTicketOrder ticketOrder : finishedTicketOrders) { + Integer userTicketId = ticketOrder.getUserTicketId(); + if (userTicketId == null) { + continue; + } + + boolean firstFinished = firstFinishedCache.computeIfAbsent(userTicketId, id -> isFirstTicketOrderFinished(id)); + if (!firstFinished) { + continue; + } + + String orderNo = userTicketOrderNoCache.computeIfAbsent(userTicketId, id -> findOrderNoByUserTicketId(id)); + if (orderNo == null || orderNo.isBlank()) { + continue; + } + + // 再校验一次确实是送水套餐订单,避免误关联 + ShopOrder shopOrder = shopOrderService.getOne( + new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getPayStatus, true) + .eq(ShopOrder::getOrderNo, orderNo) + .last("limit 1") + ); + if (shopOrder == null || shopOrder.getFormId() == null || !waterFormIds.contains(shopOrder.getFormId())) { + continue; + } + + orderNos.add(orderNo); + if (orderNos.size() >= MAX_ELIGIBLE_ORDER_NOS_PER_RUN) { + break; + } + } + return orderNos; + } + + private boolean isFirstTicketOrderFinished(Integer userTicketId) { + if (userTicketId == null) { + return false; + } + GltTicketOrder first = gltTicketOrderService.getOne( + new LambdaQueryWrapper() + .eq(GltTicketOrder::getTenantId, TENANT_ID) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getUserTicketId, userTicketId) + .orderByAsc(GltTicketOrder::getId) + .last("limit 1") + ); + return first != null && first.getDeliveryStatus() != null && first.getDeliveryStatus() == GltTicketOrderService.DELIVERY_STATUS_FINISHED; + } + + private String findOrderNoByUserTicketId(Integer userTicketId) { + if (userTicketId == null) { + return null; + } + GltUserTicket userTicket = gltUserTicketService.getOne( + new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, TENANT_ID) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, userTicketId) + .last("limit 1") + ); + return userTicket != null ? userTicket.getOrderNo() : null; + } + + private List findCapitalsByEligibleOrderNos(Set eligibleOrderNos) { + if (eligibleOrderNos == null || eligibleOrderNos.isEmpty()) { + return List.of(); + } + return shopDealerCapitalService.list( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 10) + .in(ShopDealerCapital::getOrderNo, eligibleOrderNos) + .isNotNull(ShopDealerCapital::getOrderNo) + .orderByAsc(ShopDealerCapital::getId) + .last("limit " + MAX_CAPITALS_PER_RUN) + ); + } + + private boolean unfreezeOneCapitalIfNeeded(ShopDealerCapital cap) { + if (cap == null) { + return false; + } + Integer capitalId = cap.getId(); + Integer dealerUserId = cap.getUserId(); + String orderNo = cap.getOrderNo(); + BigDecimal amount = cap.getMoney(); + if (capitalId == null || dealerUserId == null || orderNo == null || orderNo.isBlank() || amount == null || amount.signum() <= 0) { + return false; + } + + String markerComment = buildUnfreezeMarkerComment(capitalId); + + // 快速幂等检查(避免每条都进事务) + boolean already = shopDealerCapitalService.count( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 50) + .eq(ShopDealerCapital::getUserId, dealerUserId) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .eq(ShopDealerCapital::getComments, markerComment) + ) > 0; + if (already) { + return false; + } + + return Boolean.TRUE.equals(transactionTemplate.execute(status -> { + LocalDateTime now = LocalDateTime.now(); + // 锁定分销商账户行,避免多实例并发导致同一条佣金重复解冻。 + ShopDealerUser dealerUser = shopDealerUserService.getOne( + new LambdaQueryWrapper() + .eq(ShopDealerUser::getTenantId, TENANT_ID) + .eq(ShopDealerUser::getUserId, dealerUserId) + .last("limit 1 for update") + ); + if (dealerUser == null) { + log.warn("解冻失败:未找到分销账户 - tenantId={}, orderNo={}, dealerUserId={}, amount={}", + TENANT_ID, orderNo, dealerUserId, amount); + return false; + } + + // RR 隔离级别下,先锁 user 行,再用锁定读检查 marker,避免“读到旧快照”导致重复解冻。 + ShopDealerCapital existedMarker = shopDealerCapitalService.getOne( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 50) + .eq(ShopDealerCapital::getUserId, dealerUserId) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .eq(ShopDealerCapital::getComments, markerComment) + .last("limit 1 for update") + ); + if (existedMarker != null) { + return false; + } + + BigDecimal freezeMoney = dealerUser.getFreezeMoney() != null ? dealerUser.getFreezeMoney() : BigDecimal.ZERO; + if (freezeMoney.compareTo(amount) < 0) { + log.warn("解冻失败:冻结金额不足 - tenantId={}, orderNo={}, dealerUserId={}, freezeMoney={}, amount={}", + TENANT_ID, orderNo, dealerUserId, freezeMoney, amount); + return false; + } + + BigDecimal moneyVal = dealerUser.getMoney() != null ? dealerUser.getMoney() : BigDecimal.ZERO; + dealerUser.setFreezeMoney(freezeMoney.subtract(amount)); + dealerUser.setMoney(moneyVal.add(amount)); + dealerUser.setUpdateTime(now); + if (!shopDealerUserService.updateById(dealerUser)) { + log.warn("解冻失败:更新分销账户失败 - tenantId={}, orderNo={}, dealerUserId={}, amount={}", + TENANT_ID, orderNo, dealerUserId, amount); + return false; + } + + ShopDealerCapital marker = new ShopDealerCapital(); + marker.setUserId(dealerUserId); + marker.setOrderNo(orderNo); + marker.setFlowType(50); + marker.setMoney(amount); + marker.setComments(markerComment); + marker.setToUserId(cap.getToUserId()); + marker.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); + marker.setTenantId(TENANT_ID); + marker.setCreateTime(now); + marker.setUpdateTime(now); + shopDealerCapitalService.save(marker); + + // 佣金全部解冻完成后,将分销订单状态置为“已解冻”(0)。 + // 以当前任务生成的 flowType=50 marker 数量作为完成度判断,避免提前将订单置为已解冻。 + setDealerOrderUnfrozenIfCompleted(orderNo, now); + + log.info("佣金解冻成功 - tenantId={}, orderNo={}, dealerUserId={}, amount={}, capitalId={}", + TENANT_ID, orderNo, dealerUserId, amount, capitalId); + return true; + })); + } + + private void setDealerOrderUnfrozenIfCompleted(String orderNo, LocalDateTime now) { + if (orderNo == null || orderNo.isBlank()) { + return; + } + + long totalCommissions = shopDealerCapitalService.count( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 10) + .eq(ShopDealerCapital::getOrderNo, orderNo) + ); + if (totalCommissions <= 0) { + return; + } + + long unfrozenMarkers = shopDealerCapitalService.count( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 50) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .like(ShopDealerCapital::getComments, "佣金解冻(capitalId=") + ); + + if (unfrozenMarkers < totalCommissions) { + return; + } + + boolean updated = shopDealerOrderService.update( + new LambdaUpdateWrapper() + .eq(ShopDealerOrder::getTenantId, TENANT_ID) + .eq(ShopDealerOrder::getOrderNo, orderNo) + .set(ShopDealerOrder::getIsUnfreeze, 1) + .set(ShopDealerOrder::getUnfreezeTime, now) + .set(ShopDealerOrder::getUpdateTime, now) + ); + if (!updated) { + log.warn("已完成佣金解冻,但更新分销订单isUnfreeze失败/无记录 - tenantId={}, orderNo={}", TENANT_ID, orderNo); + } + } + + private String buildUnfreezeMarkerComment(Integer capitalId) { + return "佣金解冻(capitalId=" + capitalId + ")"; + } +} diff --git a/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoConfirm10584Task.java b/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoConfirm10584Task.java new file mode 100644 index 0000000..4866fce --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoConfirm10584Task.java @@ -0,0 +1,55 @@ +package com.gxwebsoft.glt.task; + +import com.gxwebsoft.common.core.annotation.IgnoreTenant; +import com.gxwebsoft.glt.service.GltTicketOrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 租户10584:送水订单超时自动确认收货任务 + * + *

扫描已送达待确认(30)且送达时间超过24小时的订单,自动置为已完成(40)。

+ *

自动确认后会触发配送员提成结算(幂等)。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "glt.ticket-order.auto-confirm10584", name = "enabled", havingValue = "true", matchIfMissing = true) +public class GltTicketOrderAutoConfirm10584Task { + + private static final int TENANT_ID = 10584; + private static final int TIMEOUT_HOURS = 24; + + private final GltTicketOrderService gltTicketOrderService; + + @Value("${glt.ticket-order.auto-confirm10584.batch-size:200}") + private int batchSize; + + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled(cron = "${glt.ticket-order.auto-confirm10584.cron:0/33 * * * * ?}") + @IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤") + public void run() { + if (!running.compareAndSet(false, true)) { + log.warn("送水订单超时自动确认任务仍在执行中,本轮跳过 - tenantId={}", TENANT_ID); + return; + } + + try { + LocalDateTime now = LocalDateTime.now(); + int confirmed = gltTicketOrderService.autoConfirmTimeout(TENANT_ID, now, TIMEOUT_HOURS, Math.max(batchSize, 1)); + if (confirmed > 0) { + log.info("送水订单超时自动确认完成 - tenantId={}, confirmed={}, now={}", TENANT_ID, confirmed, now); + } + } finally { + running.set(false); + } + } +} diff --git a/src/main/java/com/gxwebsoft/glt/task/GltUserTicketAutoReleaseTask.java b/src/main/java/com/gxwebsoft/glt/task/GltUserTicketAutoReleaseTask.java new file mode 100644 index 0000000..91855db --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/GltUserTicketAutoReleaseTask.java @@ -0,0 +1,51 @@ +package com.gxwebsoft.glt.task; + +import com.gxwebsoft.common.core.annotation.IgnoreTenant; +import com.gxwebsoft.glt.service.GltUserTicketAutoReleaseService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 冻结水票自动释放任务: + * - 扫描 glt_user_ticket_release 中到期且待释放(status=0)的记录 + * - 释放成功:frozen -> available,并将 release.status 置为 1 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "glt.ticket.auto-release", name = "enabled", havingValue = "true", matchIfMissing = true) +public class GltUserTicketAutoReleaseTask { + + private final GltUserTicketAutoReleaseService autoReleaseService; + + @Value("${glt.ticket.auto-release.batch-size:200}") + private int batchSize; + + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled(cron = "${glt.ticket.auto-release.cron:0 */10 * * * ?}") + @IgnoreTenant("定时任务无登录态,需忽略租户隔离;释放记录自带 tenantId,更新时会校验 tenantId") + public void run() { + if (!running.compareAndSet(false, true)) { + log.warn("冻结水票自动释放任务仍在执行中,本轮跳过"); + return; + } + + try { + LocalDateTime now = LocalDateTime.now(); + int released = autoReleaseService.releaseDue(now, Math.max(batchSize, 1)); + if (released > 0) { + log.info("冻结水票自动释放完成 - released={}, now={}", released, now); + } + } finally { + running.set(false); + } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopStoreFenceController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopStoreFenceController.java new file mode 100644 index 0000000..d44a813 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopStoreFenceController.java @@ -0,0 +1,123 @@ +package com.gxwebsoft.shop.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.shop.service.ShopStoreFenceService; +import com.gxwebsoft.shop.entity.ShopStoreFence; +import com.gxwebsoft.shop.param.ShopStoreFenceParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.annotation.OperationLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 黄家明_电子围栏控制器 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Tag(name = "黄家明_电子围栏管理") +@RestController +@RequestMapping("/api/shop/shop-store-fence") +public class ShopStoreFenceController extends BaseController { + @Resource + private ShopStoreFenceService shopStoreFenceService; + + @PreAuthorize("hasAuthority('shop:shopStoreFence:list')") + @Operation(summary = "分页查询黄家明_电子围栏") + @GetMapping("/page") + public ApiResult> page(ShopStoreFenceParam param) { + // 使用关联查询 + return success(shopStoreFenceService.pageRel(param)); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:list')") + @Operation(summary = "查询全部黄家明_电子围栏") + @GetMapping() + public ApiResult> list(ShopStoreFenceParam param) { + // 使用关联查询 + return success(shopStoreFenceService.listRel(param)); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:list')") + @Operation(summary = "根据id查询黄家明_电子围栏") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + // 使用关联查询 + return success(shopStoreFenceService.getByIdRel(id)); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:save')") + @OperationLog + @Operation(summary = "添加黄家明_电子围栏") + @PostMapping() + public ApiResult save(@RequestBody ShopStoreFence shopStoreFence) { + if (shopStoreFenceService.save(shopStoreFence)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:update')") + @OperationLog + @Operation(summary = "修改黄家明_电子围栏") + @PutMapping() + public ApiResult update(@RequestBody ShopStoreFence shopStoreFence) { + if (shopStoreFenceService.updateById(shopStoreFence)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:remove')") + @OperationLog + @Operation(summary = "删除黄家明_电子围栏") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (shopStoreFenceService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:save')") + @OperationLog + @Operation(summary = "批量添加黄家明_电子围栏") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (shopStoreFenceService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:update')") + @OperationLog + @Operation(summary = "批量修改黄家明_电子围栏") + @PutMapping("/batch") + public ApiResult removeBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(shopStoreFenceService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreFence:remove')") + @OperationLog + @Operation(summary = "批量删除黄家明_电子围栏") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (shopStoreFenceService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopStoreWarehouseController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopStoreWarehouseController.java new file mode 100644 index 0000000..f7e2041 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopStoreWarehouseController.java @@ -0,0 +1,126 @@ +package com.gxwebsoft.shop.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.shop.service.ShopStoreWarehouseService; +import com.gxwebsoft.shop.entity.ShopStoreWarehouse; +import com.gxwebsoft.shop.param.ShopStoreWarehouseParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.annotation.OperationLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 仓库控制器 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Tag(name = "仓库管理") +@RestController +@RequestMapping("/api/shop/shop-store-warehouse") +public class ShopStoreWarehouseController extends BaseController { + @Resource + private ShopStoreWarehouseService shopStoreWarehouseService; + + @Operation(summary = "分页查询仓库") + @GetMapping("/page") + public ApiResult> page(ShopStoreWarehouseParam param) { + // 使用关联查询 + return success(shopStoreWarehouseService.pageRel(param)); + } + + @Operation(summary = "查询全部仓库") + @GetMapping() + public ApiResult> list(ShopStoreWarehouseParam param) { + // 使用关联查询 + return success(shopStoreWarehouseService.listRel(param)); + } + + @Operation(summary = "根据id查询仓库") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + // 使用关联查询 + return success(shopStoreWarehouseService.getByIdRel(id)); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:save')") + @OperationLog + @Operation(summary = "添加仓库") + @PostMapping() + public ApiResult save(@RequestBody ShopStoreWarehouse shopStoreWarehouse) { + // 记录当前登录用户id + User loginUser = getLoginUser(); + if (loginUser != null) { + shopStoreWarehouse.setUserId(loginUser.getUserId()); + } + if (shopStoreWarehouseService.save(shopStoreWarehouse)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:update')") + @OperationLog + @Operation(summary = "修改仓库") + @PutMapping() + public ApiResult update(@RequestBody ShopStoreWarehouse shopStoreWarehouse) { + if (shopStoreWarehouseService.updateById(shopStoreWarehouse)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:remove')") + @OperationLog + @Operation(summary = "删除仓库") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (shopStoreWarehouseService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:save')") + @OperationLog + @Operation(summary = "批量添加仓库") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (shopStoreWarehouseService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:update')") + @OperationLog + @Operation(summary = "批量修改仓库") + @PutMapping("/batch") + public ApiResult removeBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(shopStoreWarehouseService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('shop:shopStoreWarehouse:remove')") + @OperationLog + @Operation(summary = "批量删除仓库") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (shopStoreWarehouseService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/WxPayNotifyAliasController.java b/src/main/java/com/gxwebsoft/shop/controller/WxPayNotifyAliasController.java new file mode 100644 index 0000000..6a27350 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/WxPayNotifyAliasController.java @@ -0,0 +1,47 @@ +package com.gxwebsoft.shop.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * 微信支付回调别名入口(兼容历史 notify_url) + * + * 说明: + * - 旧代码曾使用 /api/system/wx-pay/notify/{tenantId} 或 /api/shop/wx-pay/notify/{tenantId} + * - 微信支付“请求重入”要求 notify_url 等参数与首次一致,因此需要保留旧回调地址可用 + */ +@Tag(name = "微信支付回调(别名)") +@RestController +public class WxPayNotifyAliasController { + + @Resource + private ShopOrderController shopOrderController; + + @Operation(summary = "微信支付回调别名(兼容旧回调地址)") + @PostMapping({"/api/system/wx-pay/notify/{tenantId}", "/api/shop/wx-pay/notify/{tenantId}"}) + public String wxNotifyAlias(@RequestHeader Map header, + @RequestBody String body, + @PathVariable("tenantId") Integer tenantId) { + // ShopOrderController.wxNotify 读取的是小写 header key,这里做一次兼容转换 + Map lower = new HashMap<>(); + if (header != null) { + header.forEach((k, v) -> { + if (k != null) { + lower.put(k.toLowerCase(Locale.ROOT), v); + } + }); + } + return shopOrderController.wxNotify(lower, body, tenantId); + } +} + diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopStoreFence.java b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreFence.java new file mode 100644 index 0000000..b2e8254 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreFence.java @@ -0,0 +1,69 @@ +package com.gxwebsoft.shop.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import java.io.Serializable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 黄家明_电子围栏 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "ShopStoreFence对象", description = "黄家明_电子围栏") +public class ShopStoreFence implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @Schema(description = "围栏名称") + private String name; + + @Schema(description = "类型 0圆形 1方形") + private Integer type; + + @Schema(description = "定位") + private String location; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "区域") + private String district; + + @Schema(description = "电子围栏轮廓") + private String points; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @Schema(description = "租户id") + private Integer tenantId; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + +} diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopStoreWarehouse.java b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreWarehouse.java new file mode 100644 index 0000000..ed8b25d --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreWarehouse.java @@ -0,0 +1,81 @@ +package com.gxwebsoft.shop.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import java.io.Serializable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 仓库 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "ShopStoreWarehouse对象", description = "仓库") +public class ShopStoreWarehouse implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @Schema(description = "仓库名称") + private String name; + + @Schema(description = "唯一标识") + private String code; + + @Schema(description = "类型 中心仓,区域仓,门店仓") + private String type; + + @Schema(description = "仓库地址") + private String address; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "联系电话") + private String phone; + + @Schema(description = "所在省份") + private String province; + + @Schema(description = "所在城市") + private String city; + + @Schema(description = "所在辖区") + private String region; + + @Schema(description = "经纬度") + private String lngAndLat; + + @Schema(description = "用户ID") + private Integer userId; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "排序号") + private Integer sortNumber; + + @Schema(description = "是否删除") + private Integer isDelete; + + @Schema(description = "租户id") + private Integer tenantId; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreFenceMapper.java b/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreFenceMapper.java new file mode 100644 index 0000000..8df7994 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreFenceMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.shop.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.shop.entity.ShopStoreFence; +import com.gxwebsoft.shop.param.ShopStoreFenceParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 黄家明_电子围栏Mapper + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +public interface ShopStoreFenceMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") ShopStoreFenceParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") ShopStoreFenceParam param); + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreWarehouseMapper.java b/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreWarehouseMapper.java new file mode 100644 index 0000000..f576163 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/ShopStoreWarehouseMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.shop.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.shop.entity.ShopStoreWarehouse; +import com.gxwebsoft.shop.param.ShopStoreWarehouseParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 仓库Mapper + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +public interface ShopStoreWarehouseMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") ShopStoreWarehouseParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") ShopStoreWarehouseParam param); + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreFenceMapper.xml b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreFenceMapper.xml new file mode 100644 index 0000000..abb20b9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreFenceMapper.xml @@ -0,0 +1,66 @@ + + + + + + + SELECT a.* + FROM shop_store_fence a + + + AND a.id = #{param.id} + + + AND a.name LIKE CONCAT('%', #{param.name}, '%') + + + AND a.type = #{param.type} + + + AND a.location LIKE CONCAT('%', #{param.location}, '%') + + + AND a.longitude LIKE CONCAT('%', #{param.longitude}, '%') + + + AND a.latitude LIKE CONCAT('%', #{param.latitude}, '%') + + + AND a.district LIKE CONCAT('%', #{param.district}, '%') + + + AND a.points LIKE CONCAT('%', #{param.points}, '%') + + + AND a.sort_number = #{param.sortNumber} + + + AND a.comments LIKE CONCAT('%', #{param.comments}, '%') + + + AND a.status = #{param.status} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreWarehouseMapper.xml b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreWarehouseMapper.xml new file mode 100644 index 0000000..32fea88 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopStoreWarehouseMapper.xml @@ -0,0 +1,78 @@ + + + + + + + SELECT a.* + FROM shop_store_warehouse a + + + AND a.id = #{param.id} + + + AND a.name LIKE CONCAT('%', #{param.name}, '%') + + + AND a.code LIKE CONCAT('%', #{param.code}, '%') + + + AND a.type LIKE CONCAT('%', #{param.type}, '%') + + + AND a.address LIKE CONCAT('%', #{param.address}, '%') + + + AND a.real_name LIKE CONCAT('%', #{param.realName}, '%') + + + AND a.phone LIKE CONCAT('%', #{param.phone}, '%') + + + AND a.province LIKE CONCAT('%', #{param.province}, '%') + + + AND a.city LIKE CONCAT('%', #{param.city}, '%') + + + AND a.region LIKE CONCAT('%', #{param.region}, '%') + + + AND a.lng_and_lat LIKE CONCAT('%', #{param.lngAndLat}, '%') + + + AND a.user_id = #{param.userId} + + + AND a.comments LIKE CONCAT('%', #{param.comments}, '%') + + + AND a.sort_number = #{param.sortNumber} + + + AND a.is_delete = #{param.isDelete} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/shop/param/ShopStoreFenceParam.java b/src/main/java/com/gxwebsoft/shop/param/ShopStoreFenceParam.java new file mode 100644 index 0000000..f6608dc --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/param/ShopStoreFenceParam.java @@ -0,0 +1,62 @@ +package com.gxwebsoft.shop.param; + +import java.math.BigDecimal; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 黄家明_电子围栏查询参数 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:24 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "ShopStoreFenceParam对象", description = "黄家明_电子围栏查询参数") +public class ShopStoreFenceParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Integer id; + + @Schema(description = "围栏名称") + private String name; + + @Schema(description = "类型 0圆形 1方形") + @QueryField(type = QueryType.EQ) + private Integer type; + + @Schema(description = "定位") + private String location; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "区域") + private String district; + + @Schema(description = "电子围栏轮廓") + private String points; + + @Schema(description = "排序(数字越小越靠前)") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态, 0正常, 1冻结") + @QueryField(type = QueryType.EQ) + private Integer status; + +} diff --git a/src/main/java/com/gxwebsoft/shop/param/ShopStoreWarehouseParam.java b/src/main/java/com/gxwebsoft/shop/param/ShopStoreWarehouseParam.java new file mode 100644 index 0000000..3b412d6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/param/ShopStoreWarehouseParam.java @@ -0,0 +1,74 @@ +package com.gxwebsoft.shop.param; + +import java.math.BigDecimal; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 仓库查询参数 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "ShopStoreWarehouseParam对象", description = "仓库查询参数") +public class ShopStoreWarehouseParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Integer id; + + @Schema(description = "仓库名称") + private String name; + + @Schema(description = "唯一标识") + private String code; + + @Schema(description = "类型 中心仓,区域仓,门店仓") + private String type; + + @Schema(description = "仓库地址") + private String address; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "联系电话") + private String phone; + + @Schema(description = "所在省份") + private String province; + + @Schema(description = "所在城市") + private String city; + + @Schema(description = "所在辖区") + private String region; + + @Schema(description = "经纬度") + private String lngAndLat; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "排序号") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "是否删除") + @QueryField(type = QueryType.EQ) + private Integer isDelete; + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java b/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java new file mode 100644 index 0000000..b930b30 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java @@ -0,0 +1,62 @@ +package com.gxwebsoft.shop.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.shop.entity.ShopStoreFence; +import com.gxwebsoft.shop.param.ShopStoreFenceParam; + +import java.util.List; + +/** + * 黄家明_电子围栏Service + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +public interface ShopStoreFenceService extends IService { + + /** + * 分页关联查询 + * + * @param param 查询参数 + * @return PageResult + */ + PageResult pageRel(ShopStoreFenceParam param); + + /** + * 关联查询全部 + * + * @param param 查询参数 + * @return List + */ + List listRel(ShopStoreFenceParam param); + + /** + * 根据id查询 + * + * @param id 自增ID + * @return ShopStoreFence + */ + ShopStoreFence getByIdRel(Integer id); + + /** + * 当前租户是否配置了任一启用围栏(status=0)。 + */ + boolean hasEnabledFences(Integer tenantId); + + /** + * 校验坐标是否落在任一启用围栏内。 + *

+ * 约定: + * - 围栏按 tenantId + status=0 过滤; + * - 支持多个围栏:命中任意一个即通过; + * - 无围栏配置:直接放行; + * - 围栏 points 异常:抛出异常(避免误送)。 + * + * @param tenantId 租户ID + * @param lng 经度 + * @param lat 纬度 + */ + void validatePointInEnabledFences(Integer tenantId, double lng, double lat); + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopStoreWarehouseService.java b/src/main/java/com/gxwebsoft/shop/service/ShopStoreWarehouseService.java new file mode 100644 index 0000000..1a1c0bb --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/ShopStoreWarehouseService.java @@ -0,0 +1,42 @@ +package com.gxwebsoft.shop.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.shop.entity.ShopStoreWarehouse; +import com.gxwebsoft.shop.param.ShopStoreWarehouseParam; + +import java.util.List; + +/** + * 仓库Service + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +public interface ShopStoreWarehouseService extends IService { + + /** + * 分页关联查询 + * + * @param param 查询参数 + * @return PageResult + */ + PageResult pageRel(ShopStoreWarehouseParam param); + + /** + * 关联查询全部 + * + * @param param 查询参数 + * @return List + */ + List listRel(ShopStoreWarehouseParam param); + + /** + * 根据id查询 + * + * @param id 自增ID + * @return ShopStoreWarehouse + */ + ShopStoreWarehouse getByIdRel(Integer id); + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopWechatShippingSyncService.java b/src/main/java/com/gxwebsoft/shop/service/ShopWechatShippingSyncService.java new file mode 100644 index 0000000..cebc186 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/ShopWechatShippingSyncService.java @@ -0,0 +1,24 @@ +package com.gxwebsoft.shop.service; + +import com.gxwebsoft.shop.entity.ShopExpress; +import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.entity.ShopOrderDelivery; + +/** + * 微信小程序“发货信息管理”同步服务。 + * + *

用于将系统内发货/无需物流状态同步到微信小程序后台,避免人工在后台录入。

+ */ +public interface ShopWechatShippingSyncService { + + /** + * 实物快递发货同步到微信后台(上传运单号/快递公司)。 + */ + boolean uploadExpressShippingInfo(ShopOrder order, ShopOrderDelivery orderDelivery, ShopExpress express); + + /** + * 无需物流/自提发货同步到微信后台(上传无需物流)。 + */ + boolean uploadNoLogisticsShippingInfo(ShopOrder order); +} + diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java new file mode 100644 index 0000000..7d597cc --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.shop.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.shop.mapper.ShopStoreFenceMapper; +import com.gxwebsoft.shop.service.ShopStoreFenceService; +import com.gxwebsoft.shop.entity.ShopStoreFence; +import com.gxwebsoft.shop.param.ShopStoreFenceParam; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.shop.util.GeoFenceUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 黄家明_电子围栏Service实现 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Service +public class ShopStoreFenceServiceImpl extends ServiceImpl implements ShopStoreFenceService { + private static final Logger log = LoggerFactory.getLogger(ShopStoreFenceServiceImpl.class); + + @Override + public PageResult pageRel(ShopStoreFenceParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(ShopStoreFenceParam param) { + List list = baseMapper.selectListRel(param); + // 排序 + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, create_time desc"); + return page.sortRecords(list); + } + + @Override + public ShopStoreFence getByIdRel(Integer id) { + ShopStoreFenceParam param = new ShopStoreFenceParam(); + param.setId(id); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + public boolean hasEnabledFences(Integer tenantId) { + if (tenantId == null) { + return false; + } + return this.count(new LambdaQueryWrapper() + .eq(ShopStoreFence::getTenantId, tenantId) + .eq(ShopStoreFence::getStatus, 0)) > 0; + } + + @Override + public void validatePointInEnabledFences(Integer tenantId, double lng, double lat) { + if (tenantId == null) { + // tenantId 缺失时不做围栏校验,避免误伤;上层应保证 tenantId 正确传入 + return; + } + + List fences = this.list(new LambdaQueryWrapper() + .eq(ShopStoreFence::getTenantId, tenantId) + .eq(ShopStoreFence::getStatus, 0) + .orderByAsc(ShopStoreFence::getSortNumber) + .orderByDesc(ShopStoreFence::getCreateTime)); + + // 无围栏配置:默认放行 + if (fences == null || fences.isEmpty()) { + return; + } + + for (ShopStoreFence fence : fences) { + if (fence == null) { + continue; + } + List polygon; + try { + polygon = GeoFenceUtil.parsePolygonPoints(fence.getPoints()); + } catch (Exception e) { + // points 异常:直接拒单并记录日志,避免误送 + log.error("围栏 points 解析失败,tenantId={}, fenceId={}, points={}", + tenantId, fence.getId(), fence.getPoints(), e); + throw new BusinessException("配送范围配置异常,请联系商家"); + } + + if (polygon == null || polygon.size() < 3) { + log.error("围栏 points 点数不足,tenantId={}, fenceId={}, points={}", + tenantId, fence.getId(), fence.getPoints()); + throw new BusinessException("配送范围配置异常,请联系商家"); + } + + if (GeoFenceUtil.containsInclusive(polygon, lng, lat)) { + return; // 命中任一围栏即通过 + } + } + + throw new BusinessException("收货地址不在配送范围内"); + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreWarehouseServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreWarehouseServiceImpl.java new file mode 100644 index 0000000..7fa7713 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreWarehouseServiceImpl.java @@ -0,0 +1,47 @@ +package com.gxwebsoft.shop.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.shop.mapper.ShopStoreWarehouseMapper; +import com.gxwebsoft.shop.service.ShopStoreWarehouseService; +import com.gxwebsoft.shop.entity.ShopStoreWarehouse; +import com.gxwebsoft.shop.param.ShopStoreWarehouseParam; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 仓库Service实现 + * + * @author 科技小王子 + * @since 2026-02-07 18:10:25 + */ +@Service +public class ShopStoreWarehouseServiceImpl extends ServiceImpl implements ShopStoreWarehouseService { + + @Override + public PageResult pageRel(ShopStoreWarehouseParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(ShopStoreWarehouseParam param) { + List list = baseMapper.selectListRel(param); + // 排序 + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, create_time desc"); + return page.sortRecords(list); + } + + @Override + public ShopStoreWarehouse getByIdRel(Integer id) { + ShopStoreWarehouseParam param = new ShopStoreWarehouseParam(); + param.setId(id); + return param.getOne(baseMapper.selectListRel(param)); + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopWechatShippingSyncServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopWechatShippingSyncServiceImpl.java new file mode 100644 index 0000000..663682c --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopWechatShippingSyncServiceImpl.java @@ -0,0 +1,231 @@ +package com.gxwebsoft.shop.service.impl; + +import cn.binarywang.wx.miniapp.bean.shop.request.shipping.OrderKeyBean; +import cn.binarywang.wx.miniapp.bean.shop.request.shipping.PayerBean; +import cn.binarywang.wx.miniapp.bean.shop.request.shipping.ShippingListBean; +import cn.binarywang.wx.miniapp.bean.shop.request.shipping.WxMaOrderShippingInfoUploadRequest; +import cn.binarywang.wx.miniapp.constant.WxMaApiUrlConstants; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateTime; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.gxwebsoft.common.core.utils.RedisUtil; +import com.gxwebsoft.common.system.entity.Payment; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.common.system.service.WxMiniappAccessTokenService; +import com.gxwebsoft.shop.entity.ShopExpress; +import com.gxwebsoft.shop.entity.ShopGoods; +import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.entity.ShopOrderDelivery; +import com.gxwebsoft.shop.entity.ShopOrderGoods; +import com.gxwebsoft.shop.service.ShopGoodsService; +import com.gxwebsoft.shop.service.ShopOrderGoodsService; +import com.gxwebsoft.shop.service.ShopWechatShippingSyncService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 同步系统发货信息到微信小程序后台(发货信息录入)。 + */ +@Slf4j +@Service +public class ShopWechatShippingSyncServiceImpl implements ShopWechatShippingSyncService { + + private static final int ORDER_NUMBER_TYPE_OUT_TRADE_NO = 1; + private static final int ORDER_NUMBER_TYPE_TRANSACTION_ID = 2; + + // 这两个值在项目原有注释代码中已经使用过(实物快递)。 + private static final int LOGISTICS_TYPE_PHYSICAL = 1; + private static final int DELIVERY_MODE_EXPRESS = 1; + + // 无需物流/自提:微信侧会在“发货信息录入”里变为已发货(具体枚举以微信接口为准)。 + private static final int LOGISTICS_TYPE_NO_LOGISTICS = 3; + private static final int DELIVERY_MODE_NO_LOGISTICS = 3; + + private static final Gson GSON = new Gson(); + + @Resource + private WxMiniappAccessTokenService wxMiniappAccessTokenService; + @Resource + private UserService userService; + @Resource + private ShopOrderGoodsService shopOrderGoodsService; + @Resource + private ShopGoodsService shopGoodsService; + @Resource + private RedisUtil redisUtil; + + @Override + public boolean uploadExpressShippingInfo(ShopOrder order, ShopOrderDelivery orderDelivery, ShopExpress express) { + if (order == null || order.getOrderId() == null) { + return false; + } + if (orderDelivery == null || StrUtil.isBlank(orderDelivery.getExpressNo())) { + log.warn("上传微信发货信息跳过:缺少运单号 - orderId={}", order.getOrderId()); + return false; + } + if (express == null || StrUtil.isBlank(express.getWxCode())) { + log.warn("上传微信发货信息跳过:缺少微信快递公司编码(wxCode) - orderId={}", order.getOrderId()); + return false; + } + + List shippingList = new ArrayList<>(); + ShippingListBean item = new ShippingListBean(); + item.setTrackingNo(orderDelivery.getExpressNo()); + item.setExpressCompany(express.getWxCode()); + item.setItemDesc(buildItemDesc(order.getOrderId())); + shippingList.add(item); + + return doUpload(order, LOGISTICS_TYPE_PHYSICAL, DELIVERY_MODE_EXPRESS, shippingList); + } + + @Override + public boolean uploadNoLogisticsShippingInfo(ShopOrder order) { + if (order == null || order.getOrderId() == null) { + return false; + } + // 无需物流情况下通常不需要 shipping_list + return doUpload(order, LOGISTICS_TYPE_NO_LOGISTICS, DELIVERY_MODE_NO_LOGISTICS, Collections.emptyList()); + } + + private boolean doUpload(ShopOrder order, int logisticsType, int deliveryMode, List shippingList) { + // 仅对微信支付订单尝试同步(微信后台“待发货”来自微信支付交易) + if (!ObjectUtil.equals(order.getPayType(), 1) && !ObjectUtil.equals(order.getPayType(), 102)) { + return false; + } + if (!Boolean.TRUE.equals(order.getPayStatus())) { + return false; + } + if (order.getTenantId() == null) { + return false; + } + + // payer openid:必须是下单用户,不是后台操作员 + User buyer = userService.getByIdIgnoreTenant(order.getUserId()); + if (buyer == null || StrUtil.isBlank(buyer.getOpenid())) { + log.warn("上传微信发货信息失败:买家openid为空 - orderId={}, userId={}", order.getOrderId(), order.getUserId()); + return false; + } + + String accessToken; + try { + accessToken = wxMiniappAccessTokenService.getAccessToken(order.getTenantId()); + } catch (Exception e) { + log.error("获取小程序access_token失败 - orderId={}, tenantId={}", order.getOrderId(), order.getTenantId(), e); + return false; + } + + OrderKeyBean orderKey = buildOrderKey(order); + if (orderKey == null) { + log.warn("上传微信发货信息跳过:无法构建order_key - orderId={}", order.getOrderId()); + return false; + } + + WxMaOrderShippingInfoUploadRequest uploadRequest = new WxMaOrderShippingInfoUploadRequest(); + uploadRequest.setOrderKey(orderKey); + uploadRequest.setLogisticsType(logisticsType); + uploadRequest.setDeliveryMode(deliveryMode); + uploadRequest.setIsAllDelivered(true); + if (shippingList != null && !shippingList.isEmpty()) { + uploadRequest.setShippingList(shippingList); + } + uploadRequest.setUploadTime(new DateTime().toString(DatePattern.UTC_WITH_ZONE_OFFSET_PATTERN)); + + PayerBean payerBean = new PayerBean(); + payerBean.setOpenid(buyer.getOpenid()); + uploadRequest.setPayer(payerBean); + + String url = WxMaApiUrlConstants.OrderShipping.UPLOAD_SHIPPING_INFO + "?access_token=" + accessToken; + String body = GSON.toJson(uploadRequest); + + try { + String resp = HttpRequest.post(url) + .header("Content-Type", "application/json") + .body(body) + .timeout(10000) + .execute() + .body(); + JsonObject json = JsonParser.parseString(resp).getAsJsonObject(); + int errcode = json.has("errcode") ? json.get("errcode").getAsInt() : -1; + String errmsg = json.has("errmsg") ? json.get("errmsg").getAsString() : resp; + if (errcode == 0) { + log.info("✅ 微信发货信息同步成功 - orderId={}, logisticsType={}, deliveryMode={}", + order.getOrderId(), logisticsType, deliveryMode); + return true; + } + log.error("❌ 微信发货信息同步失败 - orderId={}, errcode={}, errmsg={}, req={}", + order.getOrderId(), errcode, errmsg, body); + return false; + } catch (Exception e) { + log.error("❌ 微信发货信息同步异常 - orderId={}, req={}", order.getOrderId(), body, e); + return false; + } + } + + private OrderKeyBean buildOrderKey(ShopOrder order) { + if (StrUtil.isNotBlank(order.getTransactionId())) { + OrderKeyBean key = new OrderKeyBean(); + key.setOrderNumberType(ORDER_NUMBER_TYPE_TRANSACTION_ID); + key.setTransactionId(order.getTransactionId()); + return key; + } + + // transactionId 为空时,尝试使用 out_trade_no + mchid + if (StrUtil.isBlank(order.getOrderNo())) { + return null; + } + + Payment payment = loadWechatPaymentConfig(order.getTenantId()); + if (payment == null || StrUtil.isBlank(payment.getMchId())) { + return null; + } + + OrderKeyBean key = new OrderKeyBean(); + key.setOrderNumberType(ORDER_NUMBER_TYPE_OUT_TRADE_NO); + key.setOutTradeNo(order.getOrderNo()); + key.setMchId(payment.getMchId()); + return key; + } + + private Payment loadWechatPaymentConfig(Integer tenantId) { + if (tenantId == null) { + return null; + } + // 与微信支付回调一致:Payment:1:{tenantId} + String key = "Payment:1:" + tenantId; + try { + return redisUtil.get(key, Payment.class); + } catch (Exception e) { + log.warn("读取支付配置失败 - key={}", key, e); + return null; + } + } + + private String buildItemDesc(Integer orderId) { + try { + List orderGoodsList = shopOrderGoodsService.getListByOrderId(orderId); + if (orderGoodsList == null || orderGoodsList.isEmpty()) { + return "订单商品"; + } + ShopGoods shopGoods = shopGoodsService.getById(orderGoodsList.get(0).getGoodsId()); + String itemDesc = shopGoods != null && StrUtil.isNotBlank(shopGoods.getName()) ? shopGoods.getName() : "订单商品"; + if (orderGoodsList.size() > 1) { + itemDesc += "等" + orderGoodsList.size() + "件商品"; + } + return itemDesc; + } catch (Exception e) { + log.warn("构建微信发货 item_desc 失败 - orderId={}", orderId, e); + return "订单商品"; + } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java b/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java new file mode 100644 index 0000000..40c1401 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java @@ -0,0 +1,224 @@ +package com.gxwebsoft.shop.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 电子围栏(多边形)相关工具: + * - points 字符串解析(兼容常见格式:JSON数组 / "lng,lat;lng,lat" / 纯数字逗号序列) + * - 点在多边形内判断(边界视为“在围栏内”) + */ +public final class GeoFenceUtil { + + private GeoFenceUtil() { + } + + public static final class Point { + public final double lng; + public final double lat; + + public Point(double lng, double lat) { + this.lng = lng; + this.lat = lat; + } + } + + private static final ObjectMapper OM = new ObjectMapper(); + + /** + * 解析围栏 points 字段为多边形点列表。 + *

+ * 支持格式示例: + * - [[lng,lat],[lng,lat],...] + * - [{"lng":..,"lat":..}, ...] / [{"longitude":..,"latitude":..}, ...] + * - "lng,lat;lng,lat;..." + * - "lng,lat|lng,lat|..." + * - "lng,lat,lng,lat,lng,lat,..."(纯逗号序列,偶数个数字) + */ + public static List parsePolygonPoints(String pointsRaw) { + if (pointsRaw == null) { + return Collections.emptyList(); + } + String s = pointsRaw.trim(); + if (s.isEmpty()) { + return Collections.emptyList(); + } + + // JSON array + if (s.startsWith("[")) { + try { + JsonNode root = OM.readTree(s); + if (!root.isArray()) { + throw new IllegalArgumentException("points 不是数组"); + } + List pts = new ArrayList<>(); + for (JsonNode node : root) { + if (node == null || node.isNull()) { + continue; + } + if (node.isArray() && node.size() >= 2) { + double a = node.get(0).asDouble(); + double b = node.get(1).asDouble(); + pts.add(normalizeLngLat(a, b)); + continue; + } + if (node.isObject()) { + JsonNode lngNode = node.get("lng"); + if (lngNode == null) lngNode = node.get("longitude"); + JsonNode latNode = node.get("lat"); + if (latNode == null) latNode = node.get("latitude"); + if (lngNode == null || latNode == null) { + throw new IllegalArgumentException("points 对象缺少 lng/lat 字段"); + } + pts.add(normalizeLngLat(lngNode.asDouble(), latNode.asDouble())); + continue; + } + throw new IllegalArgumentException("points 数组元素格式不支持"); + } + return trimClosingPoint(pts); + } catch (Exception e) { + throw new IllegalArgumentException("points JSON 解析失败: " + e.getMessage(), e); + } + } + + // 非 JSON:尝试按分隔符拆分 + String normalized = s.replace("\n", "").replace("\r", "").trim(); + String[] pairTokens; + if (normalized.contains(";")) { + pairTokens = normalized.split(";+"); + return trimClosingPoint(parsePairs(pairTokens)); + } + if (normalized.contains("|")) { + pairTokens = normalized.split("\\|+"); + return trimClosingPoint(parsePairs(pairTokens)); + } + + // 纯逗号序列:lng,lat,lng,lat,... + if (normalized.contains(",")) { + String[] nums = normalized.split(",+"); + List ds = new ArrayList<>(); + for (String num : nums) { + String t = num.trim(); + if (t.isEmpty()) continue; + ds.add(Double.parseDouble(t)); + } + if (ds.size() % 2 != 0) { + throw new IllegalArgumentException("points 逗号序列数字个数必须为偶数"); + } + List pts = new ArrayList<>(); + for (int i = 0; i < ds.size(); i += 2) { + pts.add(normalizeLngLat(ds.get(i), ds.get(i + 1))); + } + return trimClosingPoint(pts); + } + + throw new IllegalArgumentException("points 格式不支持"); + } + + private static List parsePairs(String[] pairTokens) { + List pts = new ArrayList<>(); + for (String token : pairTokens) { + if (token == null) continue; + String t = token.trim(); + if (t.isEmpty()) continue; + String[] parts = t.split("[,\\s]+"); + if (parts.length < 2) { + throw new IllegalArgumentException("points 点格式错误: " + t); + } + double a = Double.parseDouble(parts[0].trim()); + double b = Double.parseDouble(parts[1].trim()); + pts.add(normalizeLngLat(a, b)); + } + return pts; + } + + /** + * 归一化输入的两个数为 (lng,lat)。 + * 若可明显判断是 (lat,lng) 则自动交换(仅在一个维度超出经纬常规范围时触发)。 + */ + private static Point normalizeLngLat(double first, double second) { + boolean firstLooksLat = Math.abs(first) <= 90; + boolean secondLooksLat = Math.abs(second) <= 90; + boolean firstLooksLng = Math.abs(first) <= 180; + boolean secondLooksLng = Math.abs(second) <= 180; + + // 明显是 (lat,lng):first 像纬度且 second 像经度,但 second 不像纬度 + if (firstLooksLat && secondLooksLng && !secondLooksLat) { + return new Point(second, first); + } + // 默认按 (lng,lat) + if (!firstLooksLng || !secondLooksLat) { + // 仍返回默认顺序,让上层决定是否拒单(避免隐式容错导致误判) + return new Point(first, second); + } + return new Point(first, second); + } + + private static List trimClosingPoint(List pts) { + if (pts == null || pts.size() < 2) { + return pts == null ? Collections.emptyList() : pts; + } + Point first = pts.get(0); + Point last = pts.get(pts.size() - 1); + if (nearlyEqual(first.lng, last.lng, 1e-12) && nearlyEqual(first.lat, last.lat, 1e-12)) { + return new ArrayList<>(pts.subList(0, pts.size() - 1)); + } + return pts; + } + + /** + * 点在多边形内判断(ray casting),边界视为 inside。 + */ + public static boolean containsInclusive(List polygon, double lng, double lat) { + if (polygon == null || polygon.size() < 3) { + return false; + } + final double eps = 1e-12; + boolean inside = false; + int n = polygon.size(); + for (int i = 0, j = n - 1; i < n; j = i++) { + Point pi = polygon.get(i); + Point pj = polygon.get(j); + + // 边界:点在线段上 + if (pointOnSegment(lng, lat, pj.lng, pj.lat, pi.lng, pi.lat, eps)) { + return true; + } + + // 射线与边相交:以 lat 为 y + boolean intersect = ((pi.lat > lat) != (pj.lat > lat)) + && (lng < (pj.lng - pi.lng) * (lat - pi.lat) / (pj.lat - pi.lat + 0.0) + pi.lng); + if (intersect) { + inside = !inside; + } + } + return inside; + } + + private static boolean pointOnSegment(double px, double py, + double ax, double ay, + double bx, double by, + double eps) { + // 叉积为 0 表示共线 + double cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax); + if (Math.abs(cross) > eps) { + return false; + } + // 点积判断是否在线段范围内 + double dot = (px - ax) * (bx - ax) + (py - ay) * (by - ay); + if (dot < -eps) { + return false; + } + double lenSq = (bx - ax) * (bx - ax) + (by - ay) * (by - ay); + return dot - lenSq <= eps; + } + + private static boolean nearlyEqual(double a, double b, double eps) { + return Math.abs(a - b) <= eps; + } +} + diff --git a/src/main/resources/application-glt.yml b/src/main/resources/application-glt.yml new file mode 100644 index 0000000..ca0f52c --- /dev/null +++ b/src/main/resources/application-glt.yml @@ -0,0 +1,83 @@ +# 生产环境配置 + +# 数据源配置 +spring: + datasource: + url: jdbc:mysql://1Panel-mysql-XsWW:3306/modules?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: modules + password: tYmmMGh5wpwXR3ae + driver-class-name: com.mysql.cj.jdbc.Driver + type: com.alibaba.druid.pool.DruidDataSource + druid: + remove-abandoned: true + + # redis + redis: + database: 0 + host: 1Panel-redis-GmNr + port: 6379 + password: redis_t74P8C + +# 日志配置 +logging: + file: + name: websoft-modules.log + level: + root: WARN + com.gxwebsoft: ERROR + com.baomidou.mybatisplus: ERROR + +socketio: + host: 0.0.0.0 #IP地址 + +# MQTT配置 +mqtt: + enabled: true # 启用MQTT服务 + host: tcp://132.232.214.96:1883 + username: swdev + password: Sw20250523 + client-id-prefix: hjm_car_ + topic: /SW_GPS/# + qos: 2 + connection-timeout: 10 + keep-alive-interval: 20 + auto-reconnect: true + +# 框架配置 +config: + # 文件服务器 + file-server: https://file-s209.shoplnk.cn + # 生产环境接口 + server-url: https://glt-server.websoft.top/api + upload-path: /www/wwwroot/file.ws + + # 阿里云OSS云存储 + endpoint: https://oss-cn-shenzhen.aliyuncs.com + accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP + accessKeySecret: BiDkpS7UXj72HWwDWaFZxiXjNFBNCM + bucketName: oss-gxwebsoft + bucketDomain: https://oss.wsdns.cn + aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com + +# 生产环境证书配置 +certificate: + load-mode: VOLUME # 生产环境从Docker挂载卷加载 + cert-root-path: /www/wwwroot/file.ws + +# 支付配置缓存 +payment: + cache: + # 支付配置缓存键前缀,生产环境使用 Payment:1* 格式 + key-prefix: "Payment:1" + # 缓存过期时间(小时) + expire-hours: 24 +# 阿里云翻译配置 +aliyun: + translate: + access-key-id: LTAI5tEsyhW4GCKbds1qsopg + access-key-secret: zltFlQrYVAoq2KMFDWgLa3GhkMNeyO + endpoint: mt.cn-hangzhou.aliyuncs.com +wechatpay: + transfer: + scene-id: 1005 + scene-report-infos-json: '[{"info_type":"岗位类型","info_content":"配送员"},{"info_type":"报酬说明","info_content":"12月份配送费"}]'