Compare commits
2 Commits
8faa23e9c3
...
0725c94bbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0725c94bbe | ||
|
|
5438a8e1f0 |
@@ -3,15 +3,20 @@ package com.starry.admin.modules.withdraw.controller;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsBackfillService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse;
|
||||
import com.starry.admin.modules.withdraw.vo.WithdrawalRequestQueryVo;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
@@ -40,6 +45,10 @@ public class AdminWithdrawalController {
|
||||
private ITenantAlipayConfigService tenantAlipayConfigService;
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IEarningsBackfillService earningsBackfillService;
|
||||
@Resource
|
||||
private IEarningsBackfillLogService backfillLogService;
|
||||
|
||||
@ApiOperation("分页查询提现请求")
|
||||
@PostMapping("/requests/listByPage")
|
||||
@@ -114,6 +123,23 @@ public class AdminWithdrawalController {
|
||||
return TypedR.ok(summary);
|
||||
}
|
||||
|
||||
@ApiOperation("收益补算(试运行或执行)")
|
||||
@PostMapping("/earnings/backfill")
|
||||
public TypedR<EarningsBackfillResponse> backfill(@RequestBody EarningsBackfillRequest request) {
|
||||
return TypedR.ok(earningsBackfillService.backfill(request));
|
||||
}
|
||||
|
||||
@ApiOperation("收益补算历史记录")
|
||||
@GetMapping("/earnings/backfill/logs")
|
||||
public TypedR<List<EarningsBackfillLogEntity>> listBackfillLogs() {
|
||||
List<EarningsBackfillLogEntity> logs = backfillLogService.lambdaQuery()
|
||||
.eq(EarningsBackfillLogEntity::getTenantId, SecurityUtils.getTenantId())
|
||||
.orderByDesc(EarningsBackfillLogEntity::getCreatedTime)
|
||||
.last("limit 20")
|
||||
.list();
|
||||
return TypedR.ok(logs);
|
||||
}
|
||||
|
||||
@ApiOperation("查询当前租户是否已配置支付宝")
|
||||
@GetMapping("/alipay/config/present")
|
||||
public TypedR<Boolean> hasAlipayConfig() {
|
||||
|
||||
@@ -36,7 +36,6 @@ public class WxWithdrawController {
|
||||
@Data
|
||||
public static class CreateWithdrawRequest {
|
||||
private BigDecimal amount;
|
||||
private String destAccount; // 临时:支付宝登录号/账号
|
||||
}
|
||||
|
||||
@ClerkUserLogin
|
||||
@@ -73,8 +72,7 @@ public class WxWithdrawController {
|
||||
throw new CustomException("提现金额必须大于0");
|
||||
}
|
||||
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
||||
WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getDestAccount(),
|
||||
body.getAmount());
|
||||
WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getAmount());
|
||||
return TypedR.ok(req);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.starry.admin.modules.withdraw.controller;
|
||||
|
||||
import com.starry.admin.common.aspect.ClerkUserLogin;
|
||||
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.common.result.TypedR;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.Data;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/wx/withdraw/payee")
|
||||
public class WxWithdrawPayeeController {
|
||||
|
||||
private static final String DEFAULT_CHANNEL = "ALIPAY_QR";
|
||||
|
||||
@Resource
|
||||
private IClerkPayeeProfileService clerkPayeeProfileService;
|
||||
|
||||
@ClerkUserLogin
|
||||
@GetMapping
|
||||
public TypedR<PayeeProfileResponse> getProfile() {
|
||||
PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo();
|
||||
ClerkPayeeProfileEntity profile = clerkPayeeProfileService.getByClerk(clerk.getTenantId(), clerk.getId());
|
||||
return TypedR.ok(toResponse(profile));
|
||||
}
|
||||
|
||||
@ClerkUserLogin
|
||||
@PostMapping
|
||||
public TypedR<PayeeProfileResponse> upsert(@RequestBody PayeeProfileRequest body) {
|
||||
if (body == null || !StringUtils.hasText(body.getQrCodeUrl())) {
|
||||
throw new CustomException("请上传支付宝收款二维码");
|
||||
}
|
||||
PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo();
|
||||
String channel = StringUtils.hasText(body.getChannel()) ? body.getChannel() : DEFAULT_CHANNEL;
|
||||
String displayName = StringUtils.hasText(body.getDisplayName()) ? body.getDisplayName() : clerk.getNickname();
|
||||
ClerkPayeeProfileEntity profile = clerkPayeeProfileService.upsertProfile(
|
||||
clerk.getTenantId(), clerk.getId(), channel, body.getQrCodeUrl(), displayName, clerk.getId());
|
||||
return TypedR.ok(toResponse(profile));
|
||||
}
|
||||
|
||||
@ClerkUserLogin
|
||||
@PostMapping("/confirm")
|
||||
public TypedR<PayeeProfileResponse> confirm() {
|
||||
PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo();
|
||||
ClerkPayeeProfileEntity profile = clerkPayeeProfileService.confirmProfile(
|
||||
clerk.getTenantId(), clerk.getId(), clerk.getId());
|
||||
if (profile == null || !StringUtils.hasText(profile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款二维码");
|
||||
}
|
||||
return TypedR.ok(toResponse(profile));
|
||||
}
|
||||
|
||||
private PayeeProfileResponse toResponse(ClerkPayeeProfileEntity profile) {
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
PayeeProfileResponse resp = new PayeeProfileResponse();
|
||||
resp.setChannel(profile.getChannel());
|
||||
resp.setQrCodeUrl(profile.getQrCodeUrl());
|
||||
resp.setDisplayName(profile.getDisplayName());
|
||||
resp.setLastConfirmedAt(profile.getLastConfirmedAt());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PayeeProfileRequest {
|
||||
private String channel;
|
||||
private String qrCodeUrl;
|
||||
private String displayName;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PayeeProfileResponse {
|
||||
private String channel;
|
||||
private String qrCodeUrl;
|
||||
private String displayName;
|
||||
private java.time.LocalDateTime lastConfirmedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_clerk_payee_profile")
|
||||
public class ClerkPayeeProfileEntity extends BaseEntity<ClerkPayeeProfileEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
/**
|
||||
* 收款渠道(目前固定 ALIPAY_QR)
|
||||
*/
|
||||
private String channel;
|
||||
private String qrCodeUrl;
|
||||
private String displayName;
|
||||
private LocalDateTime lastConfirmedAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_backfill_log")
|
||||
public class EarningsBackfillLogEntity extends BaseEntity<EarningsBackfillLogEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private String operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime backfillBeginTime;
|
||||
private LocalDateTime backfillEndTime;
|
||||
private String clerkIds;
|
||||
private Boolean dryRun;
|
||||
private Integer ordersChecked;
|
||||
private Integer createdCount;
|
||||
private Integer skippedExisting;
|
||||
private Integer warningCount;
|
||||
private String warnings;
|
||||
private String comment;
|
||||
}
|
||||
@@ -24,4 +24,5 @@ public class WithdrawalRequestEntity extends BaseEntity<WithdrawalRequestEntity>
|
||||
private String outBizNo;
|
||||
private String providerRef;
|
||||
private String failureReason;
|
||||
private String payeeSnapshot;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ClerkPayeeProfileMapper extends BaseMapper<ClerkPayeeProfileEntity> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsBackfillLogMapper extends BaseMapper<EarningsBackfillLogEntity> {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
|
||||
public interface IClerkPayeeProfileService extends IService<ClerkPayeeProfileEntity> {
|
||||
ClerkPayeeProfileEntity getByClerk(String tenantId, String clerkId);
|
||||
|
||||
ClerkPayeeProfileEntity upsertProfile(String tenantId, String clerkId, String channel, String qrCodeUrl, String displayName, String operator);
|
||||
|
||||
ClerkPayeeProfileEntity confirmProfile(String tenantId, String clerkId, String operator);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
|
||||
|
||||
public interface IEarningsBackfillLogService extends IService<EarningsBackfillLogEntity> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse;
|
||||
|
||||
public interface IEarningsBackfillService {
|
||||
EarningsBackfillResponse backfill(EarningsBackfillRequest request);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public interface IWithdrawalService extends IService<WithdrawalRequestEntity> {
|
||||
WithdrawalRequestEntity createWithdrawaRequest(String clerkId, String destAccount, BigDecimal amount);
|
||||
WithdrawalRequestEntity createWithdrawaRequest(String clerkId, BigDecimal amount);
|
||||
|
||||
void markManualSuccess(String requestId, String operatorBy);
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.mapper.ClerkPayeeProfileMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class ClerkPayeeProfileServiceImpl extends ServiceImpl<ClerkPayeeProfileMapper, ClerkPayeeProfileEntity>
|
||||
implements IClerkPayeeProfileService {
|
||||
|
||||
private static final String DEFAULT_CHANNEL = "ALIPAY_QR";
|
||||
|
||||
@Override
|
||||
public ClerkPayeeProfileEntity getByClerk(String tenantId, String clerkId) {
|
||||
return lambdaQuery()
|
||||
.eq(ClerkPayeeProfileEntity::getTenantId, tenantId)
|
||||
.eq(ClerkPayeeProfileEntity::getClerkId, clerkId)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClerkPayeeProfileEntity upsertProfile(String tenantId, String clerkId, String channel,
|
||||
String qrCodeUrl, String displayName, String operator) {
|
||||
String resolvedChannel = StringUtils.hasText(channel) ? channel : DEFAULT_CHANNEL;
|
||||
ClerkPayeeProfileEntity profile = getByClerk(tenantId, clerkId);
|
||||
if (profile == null) {
|
||||
profile = new ClerkPayeeProfileEntity();
|
||||
profile.setId(IdUtils.getUuid());
|
||||
profile.setTenantId(tenantId);
|
||||
profile.setClerkId(clerkId);
|
||||
}
|
||||
profile.setChannel(resolvedChannel);
|
||||
profile.setQrCodeUrl(qrCodeUrl);
|
||||
profile.setDisplayName(displayName);
|
||||
profile.setLastConfirmedAt(null); // force confirmation before next withdrawal
|
||||
profile.setUpdatedBy(operator);
|
||||
if (profile.getCreatedBy() == null) {
|
||||
profile.setCreatedBy(operator);
|
||||
}
|
||||
this.saveOrUpdate(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClerkPayeeProfileEntity confirmProfile(String tenantId, String clerkId, String operator) {
|
||||
ClerkPayeeProfileEntity profile = getByClerk(tenantId, clerkId);
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
profile.setLastConfirmedAt(LocalDateTime.now());
|
||||
profile.setUpdatedBy(operator);
|
||||
updateById(profile);
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsBackfillLogMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EarningsBackfillLogServiceImpl extends ServiceImpl<EarningsBackfillLogMapper, EarningsBackfillLogEntity>
|
||||
implements IEarningsBackfillLogService {}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsBackfillService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.vo.BackfillOrderPreview;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest;
|
||||
import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsBackfillServiceImpl implements IEarningsBackfillService {
|
||||
|
||||
private static final DateTimeFormatter ISO_DATE_TIME = DateTimeFormatter.ISO_DATE_TIME;
|
||||
private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Resource
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IEarningsBackfillLogService backfillLogService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public EarningsBackfillResponse backfill(EarningsBackfillRequest request) {
|
||||
if (request == null) {
|
||||
throw new CustomException("参数不能为空");
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (tenantId == null) {
|
||||
throw new CustomException("租户上下文为空");
|
||||
}
|
||||
LocalDateTime begin = parseDate(request.getBeginTime());
|
||||
LocalDateTime end = parseDate(request.getEndTime());
|
||||
if (begin != null && end != null && begin.isAfter(end)) {
|
||||
throw new CustomException("起始时间不能晚于结束时间");
|
||||
}
|
||||
boolean dryRun = Boolean.TRUE.equals(request.getDryRun());
|
||||
|
||||
LambdaQueryWrapper<PlayOrderInfoEntity> query = new LambdaQueryWrapper<>();
|
||||
query.eq(PlayOrderInfoEntity::getTenantId, tenantId)
|
||||
.eq(PlayOrderInfoEntity::getOrderStatus, "3");
|
||||
if (begin != null) {
|
||||
query.ge(PlayOrderInfoEntity::getOrderEndTime, begin);
|
||||
}
|
||||
if (end != null) {
|
||||
query.le(PlayOrderInfoEntity::getOrderEndTime, end);
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(request.getClerkIds())) {
|
||||
query.in(PlayOrderInfoEntity::getAcceptBy, request.getClerkIds());
|
||||
}
|
||||
List<PlayOrderInfoEntity> orders = orderInfoService.list(query);
|
||||
if (orders.size() > 2000) {
|
||||
throw new CustomException("符合条件的订单过多,请缩小时间范围或筛选店员后再试");
|
||||
}
|
||||
|
||||
int ordersChecked = 0;
|
||||
int createdCount = 0;
|
||||
int skippedExisting = 0;
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<BackfillOrderPreview> previews = new ArrayList<>();
|
||||
final int previewLimit = 50;
|
||||
|
||||
for (PlayOrderInfoEntity order : orders) {
|
||||
ordersChecked++;
|
||||
BigDecimal amount = resolveAmount(order, warnings);
|
||||
if (amount == null) {
|
||||
continue;
|
||||
}
|
||||
if (hasExistingEarnings(tenantId, order.getId())) {
|
||||
skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
if (previews.size() < previewLimit) {
|
||||
BackfillOrderPreview preview = new BackfillOrderPreview();
|
||||
preview.setOrderId(order.getId());
|
||||
preview.setClerkId(order.getAcceptBy());
|
||||
preview.setAmount(amount);
|
||||
preview.setOrderEndTime(order.getOrderEndTime());
|
||||
previews.add(preview);
|
||||
}
|
||||
if (dryRun) {
|
||||
createdCount++;
|
||||
continue;
|
||||
}
|
||||
earningsService.createFromOrder(order);
|
||||
if (hasExistingEarnings(tenantId, order.getId())) {
|
||||
createdCount++;
|
||||
} else {
|
||||
addWarning(warnings, "订单 " + order.getId() + " 创建收益失败");
|
||||
}
|
||||
}
|
||||
|
||||
EarningsBackfillResponse response = new EarningsBackfillResponse();
|
||||
response.setDryRun(dryRun);
|
||||
response.setOrdersChecked(ordersChecked);
|
||||
response.setCreatedCount(createdCount);
|
||||
response.setSkippedExisting(skippedExisting);
|
||||
response.setWarnings(warnings);
|
||||
response.setOrders(previews);
|
||||
|
||||
// persist log
|
||||
EarningsBackfillLogEntity log = new EarningsBackfillLogEntity();
|
||||
log.setId(IdUtils.getUuid());
|
||||
log.setTenantId(tenantId);
|
||||
log.setOperatorId(safe(SecurityUtils::getUserId));
|
||||
log.setOperatorName(safe(SecurityUtils::getUsername));
|
||||
log.setBackfillBeginTime(begin);
|
||||
log.setBackfillEndTime(end);
|
||||
if (!CollectionUtils.isEmpty(request.getClerkIds())) {
|
||||
log.setClerkIds(JSON.toJSONString(request.getClerkIds()));
|
||||
}
|
||||
log.setDryRun(dryRun);
|
||||
log.setOrdersChecked(ordersChecked);
|
||||
log.setCreatedCount(createdCount);
|
||||
log.setSkippedExisting(skippedExisting);
|
||||
log.setWarningCount(warnings.size());
|
||||
if (!warnings.isEmpty()) {
|
||||
log.setWarnings(JSON.toJSONString(warnings));
|
||||
}
|
||||
log.setComment(request.getComment());
|
||||
log.setCreatedBy(log.getOperatorId());
|
||||
log.setUpdatedBy(log.getOperatorId());
|
||||
backfillLogService.save(log);
|
||||
response.setLogId(log.getId());
|
||||
return response;
|
||||
}
|
||||
|
||||
private boolean hasExistingEarnings(String tenantId, String orderId) {
|
||||
Long count = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getTenantId, tenantId)
|
||||
.eq(EarningsLineEntity::getOrderId, orderId)
|
||||
.count();
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private BigDecimal resolveAmount(PlayOrderInfoEntity order, List<String> warnings) {
|
||||
if (order == null) {
|
||||
return null;
|
||||
}
|
||||
if (order.getAcceptBy() == null) {
|
||||
addWarning(warnings, "订单 " + order.getId() + " 无接单人,跳过");
|
||||
return null;
|
||||
}
|
||||
BigDecimal amount = order.getEstimatedRevenue() != null ? order.getEstimatedRevenue()
|
||||
: (order.getFinalAmount() != null ? order.getFinalAmount() : BigDecimal.ZERO);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
addWarning(warnings, "订单 " + order.getId() + " 收益金额为0,跳过");
|
||||
return null;
|
||||
}
|
||||
if (order.getOrderEndTime() == null) {
|
||||
addWarning(warnings, "订单 " + order.getId() + " 缺少结束时间,跳过");
|
||||
return null;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
private void addWarning(List<String> warnings, String message) {
|
||||
if (warnings.size() >= 50) {
|
||||
return;
|
||||
}
|
||||
warnings.add(message);
|
||||
}
|
||||
|
||||
private LocalDateTime parseDate(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
List<DateTimeFormatter> formatters = new ArrayList<>();
|
||||
formatters.add(ISO_DATE_TIME);
|
||||
formatters.add(SIMPLE_DATE_TIME);
|
||||
for (DateTimeFormatter formatter : formatters) {
|
||||
try {
|
||||
return LocalDateTime.parse(trimmed, formatter);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
}
|
||||
throw new CustomException("无法解析时间:" + value);
|
||||
}
|
||||
|
||||
private interface SupplierWithException<T> {
|
||||
T get() throws Exception;
|
||||
}
|
||||
|
||||
private String safe(SupplierWithException<String> supplier) {
|
||||
try {
|
||||
return supplier.get();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
LocalDateTime endTime = orderInfo.getOrderEndTime();
|
||||
if (endTime == null) return;
|
||||
LocalDateTime unlockTime = endTime.plusHours(freezeHours);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
line.setId(IdUtils.getUuid());
|
||||
@@ -45,7 +46,7 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setUnlockTime(unlockTime);
|
||||
line.setStatus("frozen");
|
||||
line.setStatus(unlockTime.isAfter(now) ? "frozen" : "available");
|
||||
this.save(line);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +1,122 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.modules.withdraw.vo.PayeeSnapshotVo;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper, WithdrawalRequestEntity>
|
||||
implements IWithdrawalService {
|
||||
|
||||
private static final long PAYEE_CONFIRMATION_MAX_MINUTES = 10L;
|
||||
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IWithdrawalLogService withdrawalLogService;
|
||||
@Resource
|
||||
private IClerkPayeeProfileService clerkPayeeProfileService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WithdrawalRequestEntity createWithdrawaRequest(String clerkId, String destAccount, BigDecimal amount) {
|
||||
public WithdrawalRequestEntity createWithdrawaRequest(String clerkId, BigDecimal amount) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new CustomException("提现金额必须大于0");
|
||||
}
|
||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, LocalDateTime.now());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款码");
|
||||
}
|
||||
if (payeeProfile.getLastConfirmedAt() == null) {
|
||||
throw new CustomException("请确认本次使用的收款码");
|
||||
}
|
||||
Duration sinceConfirm = Duration.between(payeeProfile.getLastConfirmedAt(), now);
|
||||
if (sinceConfirm.isNegative() || sinceConfirm.toMinutes() > PAYEE_CONFIRMATION_MAX_MINUTES) {
|
||||
throw new CustomException("收款码确认已过期,请重新确认");
|
||||
}
|
||||
|
||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
||||
if (available.compareTo(amount) < 0) {
|
||||
throw new CustomException("可提现余额不足");
|
||||
}
|
||||
// pick and reserve lines
|
||||
List<EarningsLineEntity> lines = earningsService.findWithdrawable(clerkId, amount, LocalDateTime.now());
|
||||
List<EarningsLineEntity> lines = earningsService.findWithdrawable(clerkId, amount, now);
|
||||
if (lines.isEmpty()) throw new CustomException("可提现余额不足");
|
||||
|
||||
// Reserve lines FIRST with temp ID (fail fast before creating request)
|
||||
String tempWithdrawalId = "TEMP_" + IdUtils.getUuid();
|
||||
int reservedCount = 0;
|
||||
for (EarningsLineEntity line : lines) {
|
||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||
if (!updated) {
|
||||
// Another request already took this line
|
||||
throw new CustomException("部分收益已被其他提现请求锁定,请重试");
|
||||
}
|
||||
reservedCount++;
|
||||
}
|
||||
|
||||
// Only NOW create the request (after successful reservation)
|
||||
LocalDateTime confirmedAt = payeeProfile.getLastConfirmedAt();
|
||||
String snapshotJson = buildPayeeSnapshot(payeeProfile, confirmedAt);
|
||||
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
req.setId(IdUtils.getUuid());
|
||||
req.setClerkId(clerkId);
|
||||
req.setTenantId(SecurityUtils.getTenantId());
|
||||
req.setTenantId(tenantId);
|
||||
req.setAmount(amount);
|
||||
req.setFee(BigDecimal.ZERO); // fee on tenant; not deducted from clerk
|
||||
req.setNetAmount(amount);
|
||||
req.setDestAccount(destAccount);
|
||||
req.setDestAccount(payeeProfile.getDisplayName());
|
||||
req.setPayeeSnapshot(snapshotJson);
|
||||
req.setStatus("pending");
|
||||
req.setOutBizNo(req.getId());
|
||||
this.save(req);
|
||||
|
||||
// Update temp ID to real ID
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getWithdrawalId, tempWithdrawalId)
|
||||
.set(EarningsLineEntity::getWithdrawalId, req.getId()));
|
||||
|
||||
// log created
|
||||
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
|
||||
"CREATED", null, req.getStatus(), "提现申请创建", null);
|
||||
|
||||
int reservedCount = 0;
|
||||
for (EarningsLineEntity line : lines) {
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getWithdrawalId, req.getId()));
|
||||
reservedCount++;
|
||||
}
|
||||
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
|
||||
"PAYEE_CONFIRMED", req.getStatus(), req.getStatus(), "店员确认收款码", snapshotJson);
|
||||
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
|
||||
"RESERVED", req.getStatus(), req.getStatus(),
|
||||
"冻结收益已预留,条数=" + reservedCount + ", 金额=" + amount, null);
|
||||
|
||||
// reset confirmation to force next withdrawal to re-confirm
|
||||
payeeProfile.setLastConfirmedAt(null);
|
||||
payeeProfile.setUpdatedBy(clerkId);
|
||||
clerkPayeeProfileService.updateById(payeeProfile);
|
||||
|
||||
// 自动打款未实现,等待运营手动处理
|
||||
return req;
|
||||
}
|
||||
@@ -116,4 +165,13 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
// Not implemented yet
|
||||
throw new UnsupportedOperationException("Alipay payout not implemented");
|
||||
}
|
||||
|
||||
private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) {
|
||||
PayeeSnapshotVo snapshot = new PayeeSnapshotVo();
|
||||
snapshot.setChannel(profile.getChannel());
|
||||
snapshot.setQrCodeUrl(profile.getQrCodeUrl());
|
||||
snapshot.setDisplayName(profile.getDisplayName());
|
||||
snapshot.setConfirmedAt(confirmedAt);
|
||||
return JSON.toJSONString(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class BackfillOrderPreview {
|
||||
private String orderId;
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private LocalDateTime orderEndTime;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class EarningsBackfillRequest {
|
||||
private String beginTime;
|
||||
private String endTime;
|
||||
private List<String> clerkIds;
|
||||
private Boolean dryRun;
|
||||
private String comment;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class EarningsBackfillResponse {
|
||||
private boolean dryRun;
|
||||
private int ordersChecked;
|
||||
private int createdCount;
|
||||
private int skippedExisting;
|
||||
private List<String> warnings;
|
||||
private String logId;
|
||||
private List<BackfillOrderPreview> orders;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PayeeSnapshotVo {
|
||||
private String channel;
|
||||
private String qrCodeUrl;
|
||||
private String displayName;
|
||||
private LocalDateTime confirmedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Remove duplicate earnings lines per tenant/order/clerk/type and add unique constraint
|
||||
|
||||
DELETE FROM `play_earnings_line`
|
||||
WHERE `id` IN (
|
||||
SELECT dup_id FROM (
|
||||
SELECT id AS dup_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY tenant_id, order_id, clerk_id, earning_type
|
||||
ORDER BY created_time, id
|
||||
) AS rn
|
||||
FROM `play_earnings_line`
|
||||
WHERE deleted = 0
|
||||
) ranked
|
||||
WHERE ranked.rn > 1
|
||||
);
|
||||
|
||||
ALTER TABLE `play_earnings_line`
|
||||
ADD UNIQUE KEY `uk_tenant_order_clerk_type` (`tenant_id`, `order_id`, `clerk_id`, `earning_type`, `deleted`);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Clerk payee QR profile and withdrawal snapshot
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `play_clerk_payee_profile` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
|
||||
`channel` varchar(32) NOT NULL DEFAULT 'ALIPAY_QR' COMMENT '收款渠道(例如:ALIPAY_QR)',
|
||||
`qr_code_url` varchar(512) NOT NULL COMMENT '收款二维码地址',
|
||||
`display_name` varchar(64) DEFAULT NULL COMMENT '收款码显示名称',
|
||||
`last_confirmed_at` datetime DEFAULT NULL COMMENT '最近确认时间',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_clerk_payee` (`tenant_id`, `clerk_id`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='店员提现收款码档案';
|
||||
|
||||
ALTER TABLE `play_withdrawal_request`
|
||||
ADD COLUMN `payee_snapshot` text COMMENT '提现时的收款码快照(JSON)';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Ensure each active tenant has a default freeze policy (7 days)
|
||||
|
||||
INSERT INTO `play_freeze_policy` (`id`, `tenant_id`, `clerk_id`, `freeze_hours`, `created_by`, `created_time`, `updated_by`, `updated_time`, `deleted`, `version`)
|
||||
SELECT
|
||||
REPLACE(UUID(), '-', ''),
|
||||
t.`tenant_id`,
|
||||
NULL,
|
||||
168,
|
||||
'migration_v8_default_freeze',
|
||||
NOW(),
|
||||
'migration_v8_default_freeze',
|
||||
NOW(),
|
||||
0,
|
||||
1
|
||||
FROM `sys_tenant` t
|
||||
WHERE (t.`deleted` IS NULL OR t.`deleted` = 0)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `play_freeze_policy` p
|
||||
WHERE p.`tenant_id` = t.`tenant_id`
|
||||
AND p.`clerk_id` IS NULL
|
||||
AND (p.`deleted` IS NULL OR p.`deleted` = 0)
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Audit log for earnings backfill executions
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_backfill_log` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`operator_id` varchar(32) DEFAULT NULL COMMENT '操作人ID',
|
||||
`operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称',
|
||||
`backfill_begin_time` datetime DEFAULT NULL COMMENT '查询起始时间',
|
||||
`backfill_end_time` datetime DEFAULT NULL COMMENT '查询结束时间',
|
||||
`clerk_ids` text COMMENT '指定店员ID集合(JSON)',
|
||||
`dry_run` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否试运行',
|
||||
`orders_checked` int NOT NULL DEFAULT '0' COMMENT '检查订单数',
|
||||
`created_count` int NOT NULL DEFAULT '0' COMMENT '新增收益条目数',
|
||||
`skipped_existing` int NOT NULL DEFAULT '0' COMMENT '跳过已存在收益条目数',
|
||||
`warning_count` int NOT NULL DEFAULT '0' COMMENT '警告数量',
|
||||
`warnings` text COMMENT '警告明细(JSON)',
|
||||
`comment` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_backfill_tenant_time` (`tenant_id`, `created_time`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='收益补算执行记录';
|
||||
|
||||
Reference in New Issue
Block a user