Compare commits

...

2 Commits

Author SHA1 Message Date
irving
0725c94bbe 修复:防止提现创建中的竞态条件和重复收益
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
- 在 earnings_line 表添加唯一约束 (tenant_id, order_id, clerk_id, earning_type, deleted)
- 重排提现创建流程:先预留收益行,成功后才创建提现请求
- 在收益行预留时添加状态验证,检测并发修改
- 使用临时提现ID进行预留,创建请求后替换为真实ID
- 添加唯一约束前先清理重复的收益记录(V10 迁移)

此修复解决了关键的竞态条件问题:并发提现可能创建没有资金支持的孤儿请求记录。
修复后确保快速失败行为 - 如果收益行已被占用,提现请求永远不会被创建。
2025-10-13 22:45:53 -04:00
irving
5438a8e1f0 功能:新增收款码档案和收益补算功能
- 新增店员收款码档案管理(上传二维码和确认收款码)
- 提现请求新增收款码快照用于审计追踪
- 新增收益补算服务用于历史订单数据补录
- 新增收益补算日志表用于审计追踪
- 优化收益创建逻辑,根据解冻时间设置正确的初始状态
- 更新提现流程,要求店员确认收款码后才能提现
- 新增数据库迁移脚本 V7-V9
2025-10-13 22:13:51 -04:00
25 changed files with 710 additions and 19 deletions

View File

@@ -3,15 +3,20 @@ package com.starry.admin.modules.withdraw.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; 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.IEarningsService;
import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService; import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo;
import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo; 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.modules.withdraw.vo.WithdrawalRequestQueryVo;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR; import com.starry.common.result.TypedR;
@@ -40,6 +45,10 @@ public class AdminWithdrawalController {
private ITenantAlipayConfigService tenantAlipayConfigService; private ITenantAlipayConfigService tenantAlipayConfigService;
@Resource @Resource
private IEarningsService earningsService; private IEarningsService earningsService;
@Resource
private IEarningsBackfillService earningsBackfillService;
@Resource
private IEarningsBackfillLogService backfillLogService;
@ApiOperation("分页查询提现请求") @ApiOperation("分页查询提现请求")
@PostMapping("/requests/listByPage") @PostMapping("/requests/listByPage")
@@ -114,6 +123,23 @@ public class AdminWithdrawalController {
return TypedR.ok(summary); 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("查询当前租户是否已配置支付宝") @ApiOperation("查询当前租户是否已配置支付宝")
@GetMapping("/alipay/config/present") @GetMapping("/alipay/config/present")
public TypedR<Boolean> hasAlipayConfig() { public TypedR<Boolean> hasAlipayConfig() {

View File

@@ -36,7 +36,6 @@ public class WxWithdrawController {
@Data @Data
public static class CreateWithdrawRequest { public static class CreateWithdrawRequest {
private BigDecimal amount; private BigDecimal amount;
private String destAccount; // 临时:支付宝登录号/账号
} }
@ClerkUserLogin @ClerkUserLogin
@@ -73,8 +72,7 @@ public class WxWithdrawController {
throw new CustomException("提现金额必须大于0"); throw new CustomException("提现金额必须大于0");
} }
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId(); String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getDestAccount(), WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getAmount());
body.getAmount());
return TypedR.ok(req); return TypedR.ok(req);
} }

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -24,4 +24,5 @@ public class WithdrawalRequestEntity extends BaseEntity<WithdrawalRequestEntity>
private String outBizNo; private String outBizNo;
private String providerRef; private String providerRef;
private String failureReason; private String failureReason;
private String payeeSnapshot;
} }

View File

@@ -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> {}

View File

@@ -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> {}

View File

@@ -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);
}

View File

@@ -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> {}

View File

@@ -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);
}

View File

@@ -5,7 +5,7 @@ import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import java.math.BigDecimal; import java.math.BigDecimal;
public interface IWithdrawalService extends IService<WithdrawalRequestEntity> { 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); void markManualSuccess(String requestId, String operatorBy);

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}
}

View File

@@ -36,6 +36,7 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
LocalDateTime endTime = orderInfo.getOrderEndTime(); LocalDateTime endTime = orderInfo.getOrderEndTime();
if (endTime == null) return; if (endTime == null) return;
LocalDateTime unlockTime = endTime.plusHours(freezeHours); LocalDateTime unlockTime = endTime.plusHours(freezeHours);
LocalDateTime now = LocalDateTime.now();
EarningsLineEntity line = new EarningsLineEntity(); EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid()); line.setId(IdUtils.getUuid());
@@ -45,7 +46,7 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
line.setAmount(amount); line.setAmount(amount);
line.setEarningType(EarningsType.ORDER); line.setEarningType(EarningsType.ORDER);
line.setUnlockTime(unlockTime); line.setUnlockTime(unlockTime);
line.setStatus("frozen"); line.setStatus(unlockTime.isAfter(now) ? "frozen" : "available");
this.save(line); this.save(line);
} }

View File

@@ -1,73 +1,122 @@
package com.starry.admin.modules.withdraw.service.impl; package com.starry.admin.modules.withdraw.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException; 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.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper; 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.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService; 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.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Service @Service
public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper, WithdrawalRequestEntity> public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper, WithdrawalRequestEntity>
implements IWithdrawalService { implements IWithdrawalService {
private static final long PAYEE_CONFIRMATION_MAX_MINUTES = 10L;
@Resource @Resource
private IEarningsService earningsService; private IEarningsService earningsService;
@Resource @Resource
private IWithdrawalLogService withdrawalLogService; private IWithdrawalLogService withdrawalLogService;
@Resource
private IClerkPayeeProfileService clerkPayeeProfileService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @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) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("提现金额必须大于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) { if (available.compareTo(amount) < 0) {
throw new CustomException("可提现余额不足"); throw new CustomException("可提现余额不足");
} }
// pick and reserve lines // 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("可提现余额不足"); 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(); WithdrawalRequestEntity req = new WithdrawalRequestEntity();
req.setId(IdUtils.getUuid()); req.setId(IdUtils.getUuid());
req.setClerkId(clerkId); req.setClerkId(clerkId);
req.setTenantId(SecurityUtils.getTenantId()); req.setTenantId(tenantId);
req.setAmount(amount); req.setAmount(amount);
req.setFee(BigDecimal.ZERO); // fee on tenant; not deducted from clerk req.setFee(BigDecimal.ZERO); // fee on tenant; not deducted from clerk
req.setNetAmount(amount); req.setNetAmount(amount);
req.setDestAccount(destAccount); req.setDestAccount(payeeProfile.getDisplayName());
req.setPayeeSnapshot(snapshotJson);
req.setStatus("pending"); req.setStatus("pending");
req.setOutBizNo(req.getId()); req.setOutBizNo(req.getId());
this.save(req); 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 // log created
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(), withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
"CREATED", null, req.getStatus(), "提现申请创建", null); "CREATED", null, req.getStatus(), "提现申请创建", null);
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
int reservedCount = 0; "PAYEE_CONFIRMED", req.getStatus(), req.getStatus(), "店员确认收款码", snapshotJson);
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(), withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
"RESERVED", req.getStatus(), req.getStatus(), "RESERVED", req.getStatus(), req.getStatus(),
"冻结收益已预留,条数=" + reservedCount + ", 金额=" + amount, null); "冻结收益已预留,条数=" + reservedCount + ", 金额=" + amount, null);
// reset confirmation to force next withdrawal to re-confirm
payeeProfile.setLastConfirmedAt(null);
payeeProfile.setUpdatedBy(clerkId);
clerkPayeeProfileService.updateById(payeeProfile);
// 自动打款未实现,等待运营手动处理 // 自动打款未实现,等待运营手动处理
return req; return req;
} }
@@ -116,4 +165,13 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
// Not implemented yet // Not implemented yet
throw new UnsupportedOperationException("Alipay payout not implemented"); 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);
}
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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`);

View File

@@ -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';

View File

@@ -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)
);

View File

@@ -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='收益补算执行记录';