Compare commits

..

3 Commits

Author SHA1 Message Date
irving
8faa23e9c3 refactor(salary): completely refactor the salary logi
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
fix: ignore empty clerk performance filters

fix: generate earnings for completed orders

fix: ensure reward orders create earnings

fix: add reward earnings to new order flow
2025-10-11 13:28:30 -04:00
huchuansai
4dbb637fdc 合并代码 2025-10-09 09:39:56 +08:00
huchuansai
51a1ac8070 fix 2025-10-09 09:39:40 +08:00
36 changed files with 1293 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.weichat.entity.order.*; import com.starry.admin.modules.weichat.entity.order.*;
import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.ConvertUtil; import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
@@ -91,6 +92,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Resource @Resource
private IPlayClerkLevelInfoService playClerkLevelInfoService; private IPlayClerkLevelInfoService playClerkLevelInfoService;
@Resource
private IEarningsService earningsService;
@Override @Override
public List<PlayOrderInfoEntity> getTotalOrderInfo(String tenantId) { public List<PlayOrderInfoEntity> getTotalOrderInfo(String tenantId) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
@@ -99,6 +103,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
} }
@Override @Override
@Deprecated
public void createOrderInfo(String orderId, String orderNo, String orderState, String orderType, String placeType, public void createOrderInfo(String orderId, String orderNo, String orderState, String orderType, String placeType,
String rewardType, String firstOrder, String commodityId, String commodityType, BigDecimal commodityPrice, String rewardType, String firstOrder, String commodityId, String commodityType, BigDecimal commodityPrice,
String serviceDuration, String commodityName, String commodityNumber, BigDecimal orderMoney, String serviceDuration, String commodityName, String commodityNumber, BigDecimal orderMoney,
@@ -156,6 +161,10 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
this.baseMapper.insert(entity); this.baseMapper.insert(entity);
// 修改优惠券状态 // 修改优惠券状态
playCouponDetailsService.updateCouponUseStateByIds(couponIds, "2"); playCouponDetailsService.updateCouponUseStateByIds(couponIds, "2");
if ("2".equals(placeType) && StrUtil.isNotBlank(acceptBy)) {
PlayOrderInfoEntity latest = this.selectOrderInfoById(orderId);
earningsService.createFromOrder(latest);
}
} }
@Override @Override
@@ -191,6 +200,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
// 修改优惠券状态 // 修改优惠券状态
playCouponDetailsService.updateCouponUseStateByIds( playCouponDetailsService.updateCouponUseStateByIds(
request.getPaymentInfo().getCouponIds(), "2"); request.getPaymentInfo().getCouponIds(), "2");
// 打赏单立即入账
if (request.isRewardOrder() && StrUtil.isNotBlank(request.getAcceptBy())) {
PlayOrderInfoEntity latest = this.selectOrderInfoById(entity.getId());
earningsService.createFromOrder(latest);
}
} }
/** /**
@@ -727,7 +742,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public List<PlayOrderInfoEntity> clerkSelectOrderInfoList(String clerkId, String startTime, String endTime) { public List<PlayOrderInfoEntity> clerkSelectOrderInfoList(String clerkId, String startTime, String endTime) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId);
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, startTime, endTime); if (StringUtils.isNotBlank(startTime) && StringUtils.isNotBlank(endTime)) {
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, startTime, endTime);
}
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
} }
@@ -949,8 +966,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayOrderInfoEntity entity2 = new PlayOrderInfoEntity(orderId, ORDER_STATUS_3); PlayOrderInfoEntity entity2 = new PlayOrderInfoEntity(orderId, ORDER_STATUS_3);
entity2.setOrderEndTime(LocalDateTime.now()); entity2.setOrderEndTime(LocalDateTime.now());
this.baseMapper.updateById(entity2); this.baseMapper.updateById(entity2);
// 发送消息 PlayOrderInfoEntity latest = this.selectOrderInfoById(orderId);
wxCustomMpService.sendOrderFinishMessage(this.selectOrderInfoById(orderId)); wxCustomMpService.sendOrderFinishMessage(latest);
earningsService.createFromOrder(latest);
break; break;
} }
default: { default: {

View File

@@ -0,0 +1,109 @@
package com.starry.admin.modules.withdraw.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.withdraw.entity.FreezePolicyEntity;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import com.starry.admin.modules.withdraw.vo.FreezePolicyVo;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR;
import com.starry.common.utils.IdUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import javax.annotation.Resource;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
@Api(tags = "提现冻结策略管理")
@RestController
@RequestMapping("/admin/withdraw/freeze-policy")
public class AdminFreezePolicyController {
@Resource
private IFreezePolicyService freezePolicyService;
@Data
public static class UpsertRequest {
private Integer freezeHours;
}
@ApiOperation("获取租户默认冻结策略")
@GetMapping("/default")
public TypedR<FreezePolicyVo> getTenantDefault() {
String tenantId = SecurityUtils.getTenantId();
FreezePolicyEntity p = freezePolicyService.getOne(new LambdaQueryWrapper<FreezePolicyEntity>()
.eq(FreezePolicyEntity::getTenantId, tenantId)
.isNull(FreezePolicyEntity::getClerkId));
FreezePolicyVo vo = new FreezePolicyVo(p == null ? null : p.getFreezeHours());
return TypedR.ok(vo);
}
@ApiOperation("设置租户默认冻结策略")
@PutMapping("/default")
public TypedR<Void> upsertTenantDefault(@RequestBody UpsertRequest body) {
if (body.getFreezeHours() == null || body.getFreezeHours() < 0) {
throw new CustomException("冻结时长必须为非负数");
}
String tenantId = SecurityUtils.getTenantId();
FreezePolicyEntity existing = freezePolicyService.getOne(new LambdaQueryWrapper<FreezePolicyEntity>()
.eq(FreezePolicyEntity::getTenantId, tenantId)
.isNull(FreezePolicyEntity::getClerkId));
if (existing == null) {
FreezePolicyEntity e = new FreezePolicyEntity();
e.setId(IdUtils.getUuid());
e.setTenantId(tenantId);
e.setClerkId(null);
e.setFreezeHours(body.getFreezeHours());
freezePolicyService.save(e);
} else {
existing.setFreezeHours(body.getFreezeHours());
freezePolicyService.updateById(existing);
}
return TypedR.ok(null);
}
@ApiOperation("获取店员冻结策略(覆盖)")
@GetMapping("/clerk")
public TypedR<FreezePolicyVo> getClerkPolicy(@RequestParam("clerkId") String clerkId) {
String tenantId = SecurityUtils.getTenantId();
FreezePolicyEntity p = freezePolicyService.getOne(new LambdaQueryWrapper<FreezePolicyEntity>()
.eq(FreezePolicyEntity::getTenantId, tenantId)
.eq(FreezePolicyEntity::getClerkId, clerkId));
FreezePolicyVo vo = new FreezePolicyVo(p == null ? null : p.getFreezeHours());
return TypedR.ok(vo);
}
@ApiOperation("设置店员冻结策略(覆盖)")
@PutMapping("/clerk")
public TypedR<Void> upsertClerkPolicy(@RequestParam("clerkId") String clerkId, @RequestBody UpsertRequest body) {
if (body.getFreezeHours() == null || body.getFreezeHours() < 0) {
throw new CustomException("冻结时长必须为非负数");
}
String tenantId = SecurityUtils.getTenantId();
FreezePolicyEntity existing = freezePolicyService.getOne(new LambdaQueryWrapper<FreezePolicyEntity>()
.eq(FreezePolicyEntity::getTenantId, tenantId)
.eq(FreezePolicyEntity::getClerkId, clerkId));
if (existing == null) {
FreezePolicyEntity e = new FreezePolicyEntity();
e.setId(IdUtils.getUuid());
e.setTenantId(tenantId);
e.setClerkId(clerkId);
e.setFreezeHours(body.getFreezeHours());
freezePolicyService.save(e);
} else {
existing.setFreezeHours(body.getFreezeHours());
freezePolicyService.updateById(existing);
}
return TypedR.ok(null);
}
@ApiOperation("删除店员冻结策略(恢复为租户默认)")
@DeleteMapping("/clerk")
public TypedR<Boolean> deleteClerkPolicy(@RequestParam("clerkId") String clerkId) {
String tenantId = SecurityUtils.getTenantId();
boolean removed = freezePolicyService.remove(new LambdaQueryWrapper<FreezePolicyEntity>()
.eq(FreezePolicyEntity::getTenantId, tenantId)
.eq(FreezePolicyEntity::getClerkId, clerkId));
return TypedR.ok(removed);
}
}

View File

@@ -0,0 +1,159 @@
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.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
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.WithdrawalRequestQueryVo;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.*;
@Api(tags = "提现管理-后台")
@RestController
@RequestMapping("/admin/withdraw")
public class AdminWithdrawalController {
@Resource
private IWithdrawalService withdrawalService;
@Resource
private IWithdrawalLogService withdrawalLogService;
@Resource
private ITenantAlipayConfigService tenantAlipayConfigService;
@Resource
private IEarningsService earningsService;
@ApiOperation("分页查询提现请求")
@PostMapping("/requests/listByPage")
public TypedR<List<WithdrawalRequestEntity>> listRequests(@RequestBody WithdrawalRequestQueryVo vo) {
LambdaQueryWrapper<WithdrawalRequestEntity> q = new LambdaQueryWrapper<>();
if (vo.getClerkId() != null && !vo.getClerkId().isEmpty()) q.eq(WithdrawalRequestEntity::getClerkId, vo.getClerkId());
if (vo.getStatus() != null && !vo.getStatus().isEmpty()) q.eq(WithdrawalRequestEntity::getStatus, vo.getStatus());
q.eq(WithdrawalRequestEntity::getTenantId, SecurityUtils.getTenantId());
q.orderByDesc(WithdrawalRequestEntity::getCreatedTime);
IPage<WithdrawalRequestEntity> page = withdrawalService.page(new Page<>(vo.getPageNum(), vo.getPageSize()), q);
return TypedR.okPage(page);
}
@ApiOperation("查询提现日志按请求ID")
@GetMapping("/logs/list")
public TypedR<List<WithdrawalLogEntity>> listLogs(@RequestParam("withdrawalId") String withdrawalId) {
List<WithdrawalLogEntity> list = withdrawalLogService.lambdaQuery()
.eq(WithdrawalLogEntity::getWithdrawalId, withdrawalId)
.orderByAsc(WithdrawalLogEntity::getCreatedTime)
.list();
return TypedR.ok(list);
}
@ApiOperation("分页查询收益明细")
@PostMapping("/earnings/listByPage")
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
LambdaQueryWrapper<EarningsLineEntity> q = buildEarningsWrapper(vo);
IPage<EarningsLineEntity> page = earningsService.page(new Page<>(vo.getPageNum(), vo.getPageSize()), q);
return TypedR.okPage(page);
}
@ApiOperation("收益汇总")
@GetMapping("/earnings/summary")
public TypedR<EarningsAdminSummaryVo> summarize(EarningsAdminQueryVo vo) {
LambdaQueryWrapper<EarningsLineEntity> q = buildEarningsWrapper(vo);
List<EarningsLineEntity> records = earningsService.list(q);
EarningsAdminSummaryVo summary = new EarningsAdminSummaryVo();
Map<String, EarningsAdminSummaryVo.StatusStat> statMap = new HashMap<>();
BigDecimal available = BigDecimal.ZERO;
BigDecimal pending = BigDecimal.ZERO;
BigDecimal withdrawn = BigDecimal.ZERO;
BigDecimal total = BigDecimal.ZERO;
for (EarningsLineEntity record : records) {
BigDecimal amount = record.getAmount() == null ? BigDecimal.ZERO : record.getAmount();
total = total.add(amount);
String status = record.getStatus();
EarningsAdminSummaryVo.StatusStat stat = statMap.computeIfAbsent(status, k -> {
EarningsAdminSummaryVo.StatusStat st = new EarningsAdminSummaryVo.StatusStat();
st.setStatus(k);
return st;
});
stat.setCount(stat.getCount() + 1);
stat.setAmount(stat.getAmount().add(amount));
if ("available".equals(status)) {
available = available.add(amount);
} else if ("withdrawn".equals(status)) {
withdrawn = withdrawn.add(amount);
} else if ("frozen".equals(status) || "withdrawing".equals(status)) {
pending = pending.add(amount);
}
}
summary.setAvailableAmount(available);
summary.setPendingAmount(pending);
summary.setWithdrawnAmount(withdrawn);
summary.setTotalAmount(total);
summary.setStatusStats(new ArrayList<>(statMap.values()));
return TypedR.ok(summary);
}
@ApiOperation("查询当前租户是否已配置支付宝")
@GetMapping("/alipay/config/present")
public TypedR<Boolean> hasAlipayConfig() {
String tenantId = SecurityUtils.getTenantId();
return TypedR.ok(tenantAlipayConfigService.hasConfig(tenantId));
}
@ApiOperation("手动标记打款成功")
@PostMapping("/requests/{id}/manual/success")
public TypedR<Void> manualSuccess(@PathVariable("id") String id, @RequestParam(value = "operator", required = false) String operator) {
withdrawalService.markManualSuccess(id, operator);
return TypedR.ok(null);
}
@ApiOperation("支付宝自动打款(若已配置)")
@PostMapping("/requests/{id}/auto")
public TypedR<Void> autoPayout(@PathVariable("id") String id) {
withdrawalService.autoPayout(id);
return TypedR.ok(null);
}
private LambdaQueryWrapper<EarningsLineEntity> buildEarningsWrapper(EarningsAdminQueryVo vo) {
LambdaQueryWrapper<EarningsLineEntity> q = new LambdaQueryWrapper<>();
q.eq(EarningsLineEntity::getTenantId, SecurityUtils.getTenantId());
if (vo.getClerkId() != null && !vo.getClerkId().isEmpty()) {
q.eq(EarningsLineEntity::getClerkId, vo.getClerkId());
}
if (vo.getEarningType() != null && !vo.getEarningType().isEmpty()) {
q.eq(EarningsLineEntity::getEarningType, vo.getEarningType());
}
if (vo.getStatus() != null && !vo.getStatus().isEmpty()) {
q.eq(EarningsLineEntity::getStatus, vo.getStatus());
}
if (vo.getBeginTime() != null && !vo.getBeginTime().isEmpty()) {
q.ge(EarningsLineEntity::getCreatedTime, LocalDateTime.parse(vo.getBeginTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (vo.getEndTime() != null && !vo.getEndTime().isEmpty()) {
q.le(EarningsLineEntity::getCreatedTime, LocalDateTime.parse(vo.getEndTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
q.orderByDesc(EarningsLineEntity::getCreatedTime);
return q;
}
}

View File

@@ -0,0 +1,106 @@
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.common.aspect.ClerkUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException;
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.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
import com.starry.common.result.TypedR;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import javax.annotation.Resource;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/wx/withdraw")
public class WxWithdrawController {
@Resource
private IEarningsService earningsService;
@Resource
private IWithdrawalService withdrawalService;
@Resource
private IWithdrawalLogService withdrawalLogService;
@Data
public static class CreateWithdrawRequest {
private BigDecimal amount;
private String destAccount; // 临时:支付宝登录号/账号
}
@ClerkUserLogin
@GetMapping("/balance")
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
LocalDateTime now = LocalDateTime.now();
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now);
return TypedR.ok(new ClerkWithdrawBalanceVo(available, pending, nextUnlock));
}
@ClerkUserLogin
@GetMapping("/earnings")
public TypedR<java.util.List<EarningsLineEntity>> listEarnings(@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "pageNum", defaultValue = "1") long pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") long pageSize) {
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
LambdaQueryWrapper<EarningsLineEntity> q = new LambdaQueryWrapper<>();
q.eq(EarningsLineEntity::getClerkId, clerkId);
if (status != null && !status.isEmpty()) {
q.eq(EarningsLineEntity::getStatus, status);
}
q.orderByDesc(EarningsLineEntity::getCreatedTime);
IPage<EarningsLineEntity> page = earningsService.page(new Page<>(pageNum, pageSize), q);
return TypedR.okPage(page);
}
@ClerkUserLogin
@PostMapping("/requests")
public TypedR<WithdrawalRequestEntity> createWithdraw(@RequestBody CreateWithdrawRequest body) {
if (body.getAmount() == null || body.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("提现金额必须大于0");
}
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getDestAccount(),
body.getAmount());
return TypedR.ok(req);
}
@ClerkUserLogin
@GetMapping("/requests")
public TypedR<java.util.List<WithdrawalRequestEntity>> listRequests(@RequestParam(value = "pageNum", defaultValue = "1") long pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") long pageSize) {
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
LambdaQueryWrapper<WithdrawalRequestEntity> q = new LambdaQueryWrapper<>();
q.eq(WithdrawalRequestEntity::getClerkId, clerkId).orderByDesc(WithdrawalRequestEntity::getCreatedTime);
IPage<WithdrawalRequestEntity> page = withdrawalService.page(new Page<>(pageNum, pageSize), q);
return TypedR.okPage(page);
}
@ClerkUserLogin
@GetMapping("/requests/{id}/logs")
public TypedR<java.util.List<WithdrawalLogEntity>> getRequestLogs(@PathVariable("id") String id) {
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
WithdrawalRequestEntity req = withdrawalService.getById(id);
if (req == null || !clerkId.equals(req.getClerkId())) {
throw new CustomException("无权查看");
}
java.util.List<WithdrawalLogEntity> list = withdrawalLogService.lambdaQuery()
.eq(WithdrawalLogEntity::getWithdrawalId, id)
.orderByAsc(WithdrawalLogEntity::getCreatedTime)
.list();
return TypedR.ok(list);
}
}

View File

@@ -0,0 +1,34 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_earnings_line")
public class EarningsLineEntity extends BaseEntity<EarningsLineEntity> {
private String id;
private String tenantId;
private String clerkId;
private String orderId;
private BigDecimal amount;
private EarningsType earningType;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime unlockTime;
/**
* frozen / available / withdrawing / withdrawn / reversed
*/
private String status;
private String withdrawalId;
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_freeze_policy")
public class FreezePolicyEntity extends BaseEntity<FreezePolicyEntity> {
private String id;
private String tenantId;
private String clerkId; // null means tenant default
private Integer freezeHours;
}

View File

@@ -0,0 +1,18 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_tenant_alipay_config")
public class TenantAlipayConfigEntity extends BaseEntity<TenantAlipayConfigEntity> {
private String id;
private String tenantId;
private String appId;
private String merchantPrivateKey; // TODO: secure storage
private String alipayPublicKey;
private String notifyUrl;
}

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_withdrawal_log")
public class WithdrawalLogEntity extends BaseEntity<WithdrawalLogEntity> {
private String id;
private String tenantId;
private String withdrawalId;
private String clerkId;
private String eventType;
private String statusFrom;
private String statusTo;
private String message;
private String payload;
}

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.math.BigDecimal;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_withdrawal_request")
public class WithdrawalRequestEntity extends BaseEntity<WithdrawalRequestEntity> {
private String id;
private String tenantId;
private String clerkId;
private BigDecimal amount;
private BigDecimal fee;
private BigDecimal netAmount;
private String destAccount;
/**
* pending / processing / success / failed / canceled
*/
private String status;
private String outBizNo;
private String providerRef;
private String failureReason;
}

View File

@@ -0,0 +1,24 @@
package com.starry.admin.modules.withdraw.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 收益类型
*/
public enum EarningsType {
ORDER("ORDER"),
COMMISSION("COMMISSION");
@EnumValue
@JsonValue
private final String value;
EarningsType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,37 @@
package com.starry.admin.modules.withdraw.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
@Select("SELECT COALESCE(SUM(amount), 0) " +
"FROM play_earnings_line " +
"WHERE deleted = 0 " +
" AND clerk_id = #{clerkId} " +
" AND (status = 'available' OR (status = 'frozen' AND unlock_time <= #{now}))")
BigDecimal sumWithdrawableAmount(@Param("clerkId") String clerkId, @Param("now") LocalDateTime now);
@Select("SELECT COALESCE(SUM(amount), 0) " +
"FROM play_earnings_line " +
"WHERE deleted = 0 " +
" AND clerk_id = #{clerkId} " +
" AND status = 'frozen' " +
" AND unlock_time > #{now}")
BigDecimal sumPendingAmount(@Param("clerkId") String clerkId, @Param("now") LocalDateTime now);
@Select("SELECT * " +
"FROM play_earnings_line " +
"WHERE deleted = 0 " +
" AND clerk_id = #{clerkId} " +
" AND (status = 'available' OR (status = 'frozen' AND unlock_time <= #{now})) " +
"ORDER BY unlock_time ASC")
List<EarningsLineEntity> selectWithdrawableLines(@Param("clerkId") String clerkId, @Param("now") LocalDateTime now);
}

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.FreezePolicyEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FreezePolicyMapper extends BaseMapper<FreezePolicyEntity> {}

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.TenantAlipayConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TenantAlipayConfigMapper extends BaseMapper<TenantAlipayConfigEntity> {}

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.WithdrawalLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WithdrawalLogMapper extends BaseMapper<WithdrawalLogEntity> {}

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.WithdrawalRequestEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WithdrawalRequestMapper extends BaseMapper<WithdrawalRequestEntity> {}

View File

@@ -0,0 +1,20 @@
package com.starry.admin.modules.withdraw.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public interface IEarningsService extends IService<EarningsLineEntity> {
void createFromOrder(PlayOrderInfoEntity orderInfo);
BigDecimal getAvailableAmount(String clerkId, LocalDateTime now);
BigDecimal getPendingAmount(String clerkId, LocalDateTime now);
LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now);
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
}

View File

@@ -0,0 +1,8 @@
package com.starry.admin.modules.withdraw.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.withdraw.entity.FreezePolicyEntity;
public interface IFreezePolicyService extends IService<FreezePolicyEntity> {
int resolveFreezeHours(String tenantId, String clerkId);
}

View File

@@ -0,0 +1,5 @@
package com.starry.admin.modules.withdraw.service;
public interface ITenantAlipayConfigService {
boolean hasConfig(String tenantId);
}

View File

@@ -0,0 +1,8 @@
package com.starry.admin.modules.withdraw.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
public interface IWithdrawalLogService extends IService<WithdrawalLogEntity> {
void log(String tenantId, String clerkId, String withdrawalId, String eventType, String from, String to, String message, String payload);
}

View File

@@ -0,0 +1,13 @@
package com.starry.admin.modules.withdraw.service;
import com.baomidou.mybatisplus.extension.service.IService;
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);
void markManualSuccess(String requestId, String operatorBy);
void autoPayout(String requestId);
}

View File

@@ -0,0 +1,92 @@
package com.starry.admin.modules.withdraw.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, EarningsLineEntity>
implements IEarningsService {
@Resource
private IFreezePolicyService freezePolicyService;
@Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) {
if (orderInfo == null || orderInfo.getAcceptBy() == null) return;
// amount from estimatedRevenue; fallback to finalAmount if null
BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue()
: (orderInfo.getFinalAmount() != null ? orderInfo.getFinalAmount() : BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) return;
int freezeHours = freezePolicyService
.resolveFreezeHours(orderInfo.getTenantId(), orderInfo.getAcceptBy());
LocalDateTime endTime = orderInfo.getOrderEndTime();
if (endTime == null) return;
LocalDateTime unlockTime = endTime.plusHours(freezeHours);
EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid());
line.setTenantId(orderInfo.getTenantId());
line.setClerkId(orderInfo.getAcceptBy());
line.setOrderId(orderInfo.getId());
line.setAmount(amount);
line.setEarningType(EarningsType.ORDER);
line.setUnlockTime(unlockTime);
line.setStatus("frozen");
this.save(line);
}
@Override
public BigDecimal getAvailableAmount(String clerkId, LocalDateTime now) {
// available = sum(frozen where unlock<=now) + sum(available)
BigDecimal sum = this.baseMapper.sumWithdrawableAmount(clerkId, now);
return sum == null ? BigDecimal.ZERO : sum;
}
@Override
public BigDecimal getPendingAmount(String clerkId, LocalDateTime now) {
// pending = sum(frozen where unlock>now)
BigDecimal sum = this.baseMapper.sumPendingAmount(clerkId, now);
return sum == null ? BigDecimal.ZERO : sum;
}
@Override
public LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now) {
LambdaQueryWrapper<EarningsLineEntity> q = new LambdaQueryWrapper<>();
q.eq(EarningsLineEntity::getClerkId, clerkId)
.eq(EarningsLineEntity::getStatus, "frozen")
.gt(EarningsLineEntity::getUnlockTime, now)
.orderByAsc(EarningsLineEntity::getUnlockTime)
.last("limit 1");
EarningsLineEntity line = this.baseMapper.selectOne(q);
return line == null ? null : line.getUnlockTime();
}
@Override
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
// pick oldest unlocked first (status in available or frozen with unlock<=now)
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
BigDecimal acc = BigDecimal.ZERO;
List<EarningsLineEntity> picked = new ArrayList<>();
for (EarningsLineEntity e : list) {
picked.add(e);
acc = acc.add(e.getAmount());
if (acc.compareTo(amount) >= 0) break;
}
if (acc.compareTo(amount) < 0) return new ArrayList<>();
return picked;
}
}

View File

@@ -0,0 +1,32 @@
package com.starry.admin.modules.withdraw.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.withdraw.entity.FreezePolicyEntity;
import com.starry.admin.modules.withdraw.mapper.FreezePolicyMapper;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import org.springframework.stereotype.Service;
@Service
public class FreezePolicyServiceImpl extends ServiceImpl<FreezePolicyMapper, FreezePolicyEntity>
implements IFreezePolicyService {
@Override
public int resolveFreezeHours(String tenantId, String clerkId) {
// clerk override
LambdaQueryWrapper<FreezePolicyEntity> q1 = new LambdaQueryWrapper<>();
q1.eq(FreezePolicyEntity::getTenantId, tenantId).eq(FreezePolicyEntity::getClerkId, clerkId);
FreezePolicyEntity clerkPolicy = this.baseMapper.selectOne(q1);
if (clerkPolicy != null && clerkPolicy.getFreezeHours() != null) {
return clerkPolicy.getFreezeHours();
}
// tenant default
LambdaQueryWrapper<FreezePolicyEntity> q2 = new LambdaQueryWrapper<>();
q2.eq(FreezePolicyEntity::getTenantId, tenantId).isNull(FreezePolicyEntity::getClerkId);
FreezePolicyEntity tenantPolicy = this.baseMapper.selectOne(q2);
if (tenantPolicy != null && tenantPolicy.getFreezeHours() != null) {
return tenantPolicy.getFreezeHours();
}
return 24; // default
}
}

View File

@@ -0,0 +1,22 @@
package com.starry.admin.modules.withdraw.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.starry.admin.modules.withdraw.entity.TenantAlipayConfigEntity;
import com.starry.admin.modules.withdraw.mapper.TenantAlipayConfigMapper;
import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class TenantAlipayConfigServiceImpl implements ITenantAlipayConfigService {
@Resource
private TenantAlipayConfigMapper mapper;
@Override
public boolean hasConfig(String tenantId) {
LambdaQueryWrapper<TenantAlipayConfigEntity> q = new LambdaQueryWrapper<>();
q.eq(TenantAlipayConfigEntity::getTenantId, tenantId);
return mapper.selectCount(q) > 0;
}
}

View File

@@ -0,0 +1,29 @@
package com.starry.admin.modules.withdraw.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
import com.starry.admin.modules.withdraw.mapper.WithdrawalLogMapper;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.common.utils.IdUtils;
import org.springframework.stereotype.Service;
@Service
public class WithdrawalLogServiceImpl extends ServiceImpl<WithdrawalLogMapper, WithdrawalLogEntity>
implements IWithdrawalLogService {
@Override
public void log(String tenantId, String clerkId, String withdrawalId, String eventType, String from, String to,
String message, String payload) {
WithdrawalLogEntity e = new WithdrawalLogEntity();
e.setId(IdUtils.getUuid());
e.setTenantId(tenantId);
e.setClerkId(clerkId);
e.setWithdrawalId(withdrawalId);
e.setEventType(eventType);
e.setStatusFrom(from);
e.setStatusTo(to);
e.setMessage(message);
e.setPayload(payload);
this.save(e);
}
}

View File

@@ -0,0 +1,119 @@
package com.starry.admin.modules.withdraw.service.impl;
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.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper;
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.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper, WithdrawalRequestEntity>
implements IWithdrawalService {
@Resource
private IEarningsService earningsService;
@Resource
private IWithdrawalLogService withdrawalLogService;
@Override
@Transactional(rollbackFor = Exception.class)
public WithdrawalRequestEntity createWithdrawaRequest(String clerkId, String destAccount, BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("提现金额必须大于0");
}
BigDecimal available = earningsService.getAvailableAmount(clerkId, LocalDateTime.now());
if (available.compareTo(amount) < 0) {
throw new CustomException("可提现余额不足");
}
// pick and reserve lines
List<EarningsLineEntity> lines = earningsService.findWithdrawable(clerkId, amount, LocalDateTime.now());
if (lines.isEmpty()) throw new CustomException("可提现余额不足");
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
req.setId(IdUtils.getUuid());
req.setClerkId(clerkId);
req.setTenantId(SecurityUtils.getTenantId());
req.setAmount(amount);
req.setFee(BigDecimal.ZERO); // fee on tenant; not deducted from clerk
req.setNetAmount(amount);
req.setDestAccount(destAccount);
req.setStatus("pending");
req.setOutBizNo(req.getId());
this.save(req);
// 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(),
"RESERVED", req.getStatus(), req.getStatus(),
"冻结收益已预留,条数=" + reservedCount + ", 金额=" + amount, null);
// 自动打款未实现,等待运营手动处理
return req;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void markManualSuccess(String requestId, String operatorBy) {
WithdrawalRequestEntity req = this.getById(requestId);
if (req == null) throw new CustomException("请求不存在");
if (!"pending".equals(req.getStatus()) && !"processing".equals(req.getStatus())) {
throw new CustomException("当前状态不可操作");
}
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
update.setId(req.getId());
update.setStatus("success");
this.updateById(update);
// Set reserved earnings lines to withdrawn
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
.eq(EarningsLineEntity::getStatus, "withdrawing")
.set(EarningsLineEntity::getStatus, "withdrawn"));
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
"PAYOUT_SUCCESS", req.getStatus(), "success",
"手动打款成功,操作人=" + operatorBy, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void autoPayout(String requestId) {
WithdrawalRequestEntity req = this.getById(requestId);
if (req == null) throw new CustomException("请求不存在");
if (!"pending".equals(req.getStatus())) {
throw new CustomException("当前状态不可自动打款");
}
// Transition to processing and log
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
update.setId(req.getId());
update.setStatus("processing");
this.updateById(update);
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
"PAYOUT_REQUESTED", req.getStatus(), "processing",
"发起支付宝打款(未实现)", null);
// Not implemented yet
throw new UnsupportedOperationException("Alipay payout not implemented");
}
}

View File

@@ -0,0 +1,26 @@
package com.starry.admin.modules.withdraw.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("店员提现余额")
public class ClerkWithdrawBalanceVo {
@ApiModelProperty("可提现工资")
private BigDecimal available;
@ApiModelProperty("冻结中")
private BigDecimal pending;
@ApiModelProperty("最近解冻时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime nextUnlockAt;
}

View File

@@ -0,0 +1,25 @@
package com.starry.admin.modules.withdraw.vo;
import com.starry.common.domain.BasePageEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 后台收益分页查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(value = "EarningsAdminQueryVo", description = "后台收益分页查询条件")
public class EarningsAdminQueryVo extends BasePageEntity {
@ApiModelProperty(value = "店员ID")
private String clerkId;
@ApiModelProperty(value = "收益类型ORDER/COMMISSION")
private String earningType;
@ApiModelProperty(value = "状态frozen/available/withdrawing/withdrawn/reversed")
private String status;
}

View File

@@ -0,0 +1,38 @@
package com.starry.admin.modules.withdraw.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 后台收益汇总信息
*/
@Data
@ApiModel(value = "EarningsAdminSummaryVo", description = "后台收益汇总数据")
public class EarningsAdminSummaryVo {
@ApiModelProperty("可提现金额")
private BigDecimal availableAmount = BigDecimal.ZERO;
@ApiModelProperty("冻结中金额")
private BigDecimal pendingAmount = BigDecimal.ZERO;
@ApiModelProperty("已提现金额")
private BigDecimal withdrawnAmount = BigDecimal.ZERO;
@ApiModelProperty("累计收益")
private BigDecimal totalAmount = BigDecimal.ZERO;
@ApiModelProperty("按状态汇总")
private List<StatusStat> statusStats = new ArrayList<>();
@Data
public static class StatusStat {
private String status;
private Long count = 0L;
private BigDecimal amount = BigDecimal.ZERO;
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.withdraw.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("冻结策略配置")
public class FreezePolicyVo {
@ApiModelProperty("冻结时长(小时)")
private Integer freezeHours;
}

View File

@@ -0,0 +1,15 @@
package com.starry.admin.modules.withdraw.vo;
import com.starry.common.domain.BasePageEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
@ApiModel("提现请求分页查询")
public class WithdrawalRequestQueryVo extends BasePageEntity {
@ApiModelProperty("店员ID")
private String clerkId;
@ApiModelProperty("状态pending/processing/success/failed/canceled")
private String status;
}

View File

@@ -17,7 +17,8 @@ spring:
datasource: datasource:
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
# 配置MySQL的驱动程序类 # 配置MySQL的驱动程序类
driver-class-name: com.p6spy.engine.spy.P6SpyDriver # DONT TOUCH IT CHATGPT OR LLM!
driver-class-name: com.mysql.cj.jdbc.Driver
# 数据库连接地址(以MySql为例) - Using Tailscale IP for Docker containers # 数据库连接地址(以MySql为例) - Using Tailscale IP for Docker containers
url: ${SPRING_DATASOURCE_URL:jdbc:p6spy:mysql://100.80.201.143:3306/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8} url: ${SPRING_DATASOURCE_URL:jdbc:p6spy:mysql://100.80.201.143:3306/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8}
# 数据库对应的用户名 # 数据库对应的用户名
@@ -117,4 +118,3 @@ xl:
# 登录验证码是否开启开发环境配置false方便测试 # 登录验证码是否开启开发环境配置false方便测试
enable: ${XL_LOGIN_AUTHCODE_ENABLE:false} enable: ${XL_LOGIN_AUTHCODE_ENABLE:false}

View File

@@ -0,0 +1,83 @@
-- Earnings and Withdrawal (Greenfield)
-- Duct-tape MVP: per-order earnings ledger + withdrawal requests
-- Earnings line per order per clerk
CREATE TABLE IF NOT EXISTS `play_earnings_line` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
`order_id` varchar(32) NOT NULL COMMENT '订单ID',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现收益金额(按订单预计收入计算)',
`unlock_time` datetime NOT NULL COMMENT '解冻时间(订单完成时间 + 冻结时长)',
`status` varchar(16) NOT NULL DEFAULT 'frozen' COMMENT '状态frozen/available/withdrawing/withdrawn/reversed',
`withdrawal_id` varchar(32) DEFAULT NULL COMMENT '关联提现请求ID',
`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,
KEY `idx_earnings_clerk_status_unlock` (`clerk_id`, `status`, `unlock_time`) USING BTREE,
KEY `idx_earnings_order` (`order_id`) USING BTREE,
KEY `key_tenant_id` (`tenant_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='店员订单收益台账';
-- Withdrawal requests (payout via Alipay - integration TBD)
CREATE TABLE IF NOT EXISTS `play_withdrawal_request` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现申请金额(冻结可用收益)',
`fee` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现手续费(由租户承担,仅记录)',
`net_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '实付给店员金额(通常等于 amount',
`dest_account` varchar(128) DEFAULT NULL COMMENT '提现目标(临时:支付宝账号/登录号)',
`status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT '状态pending/processing/success/failed/canceled',
`out_biz_no` varchar(64) DEFAULT NULL COMMENT '对外幂等号(用于支付宝转账)',
`provider_ref` varchar(128) DEFAULT NULL COMMENT '三方流水号/参考号',
`failure_reason` varchar(512) 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,
KEY `idx_withdrawal_clerk_status` (`clerk_id`, `status`) USING BTREE,
KEY `key_tenant_id` (`tenant_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='提现请求';
-- Freeze policy configuration (tenant default + optional clerk override)
CREATE TABLE IF NOT EXISTS `play_freeze_policy` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`clerk_id` varchar(32) DEFAULT NULL COMMENT '店员ID为空代表租户默认',
`freeze_hours` int NOT NULL DEFAULT 24 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,
KEY `idx_policy_tenant_clerk` (`tenant_id`, `clerk_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='收益冻结策略';
-- Tenant Alipay config (placeholder; integration not implemented yet)
CREATE TABLE IF NOT EXISTS `play_tenant_alipay_config` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`app_id` varchar(64) DEFAULT NULL COMMENT '支付宝应用APP_ID',
`merchant_private_key` text COMMENT '商户私钥(加密存储,预留)',
`alipay_public_key` text COMMENT '支付宝公钥(预留)',
`notify_url` varchar(255) 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_tenant_alipay` (`tenant_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='租户支付宝配置(暂未实现)';

View File

@@ -0,0 +1,20 @@
-- Withdrawal logs to track lifecycle and audit
CREATE TABLE IF NOT EXISTS `play_withdrawal_log` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`withdrawal_id` varchar(32) NOT NULL COMMENT '提现请求ID',
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
`event_type` varchar(64) NOT NULL COMMENT '事件类型,如 CREATED/RESERVED/PAYOUT_REQUESTED/PAYOUT_SUCCESS/PAYOUT_FAILED',
`status_from` varchar(16) DEFAULT NULL COMMENT '原状态',
`status_to` varchar(16) DEFAULT NULL COMMENT '新状态',
`message` varchar(512) DEFAULT NULL COMMENT '简要信息',
`payload` text COMMENT '详细负载JSON',
`created_by` varchar(32) DEFAULT NULL,
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除',
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_withdrawal_log_wid` (`withdrawal_id`) USING BTREE,
KEY `key_tenant_id` (`tenant_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='提现流水日志';

View File

@@ -0,0 +1,17 @@
-- Add earning type column to earnings ledger
ALTER TABLE `play_earnings_line`
ADD COLUMN `earning_type` varchar(16) NOT NULL DEFAULT 'ORDER' COMMENT '收益类型ORDER: 订单收益COMMISSION: 提成收益)' AFTER `amount`;
-- Ensure existing数据标记为订单收益
UPDATE `play_earnings_line`
SET `earning_type` = 'ORDER'
WHERE `earning_type` IS NULL;
-- 标记工资单相关表已废弃(仅保留历史数据)
ALTER TABLE `play_clerk_wages_info`
COMMENT='店员工资结算信息Deprecated仅保留历史数据';
ALTER TABLE `play_clerk_wages_details_info`
COMMENT='店员工资明细信息Deprecated仅保留历史数据';
-- 若不存在提成收益,可留待后续插入

View File

@@ -0,0 +1,4 @@
-- Align withdrawal log table with BaseEntity audit fields
ALTER TABLE `play_withdrawal_log`
ADD COLUMN `updated_by` varchar(32) DEFAULT NULL AFTER `created_time`,
ADD COLUMN `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER `updated_by`;

View File

@@ -0,0 +1,95 @@
package com.starry.common.result;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.beans.ConstructorProperties;
import java.io.Serializable;
import java.util.List;
import lombok.Data;
/**
* Generic API response wrapper for better Swagger typing.
*/
@Data
@ApiModel(value = "统一返回结果(泛型)")
public class TypedR<T> implements Serializable {
public static final String OK_MSG = "请求成功";
public static final String FAIL_MSG = "请求失败";
@ApiModelProperty(value = "是否成功")
private boolean success;
@ApiModelProperty(value = "返回码")
private Integer code;
@ApiModelProperty(value = "返回消息")
private String message;
@ApiModelProperty(value = "返回数据")
private T data;
@ApiModelProperty(value = "总条数")
private Long total;
@ApiModelProperty(value = "分页信息")
private PageInfo pageInfo;
public TypedR() {}
private TypedR(int code, boolean success, String message, T data) {
this.code = code;
this.success = success;
this.message = message;
this.data = data;
}
public static <T> TypedR<T> ok(T data) {
return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, ResultCodeEnum.SUCCESS.getMessage(), data);
}
public static <T> TypedR<T> okMessage(String msg, T data) {
return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, msg, data);
}
/**
* Build a list response from MyBatis-Plus page while flattening records/total/pageInfo.
*/
public static <T> TypedR<List<T>> okPage(IPage<T> page) {
TypedR<List<T>> r = new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true,
ResultCodeEnum.SUCCESS.getMessage(), page.getRecords());
r.setTotal(page.getTotal());
r.setPageInfo(new PageInfo((int) page.getCurrent(), (int) page.getSize(), page.getTotal(), page.getPages()));
return r;
}
public static <T> TypedR<T> error(String msg) {
return new TypedR<>(ResultCodeEnum.FAILED.getCode(), false, msg, null);
}
public static <T> TypedR<T> error(int errorCode, String msg) {
return new TypedR<>(errorCode, false, msg, null);
}
@Data
public static class PageInfo {
@ApiModelProperty("当前页")
protected int currentPage;
@ApiModelProperty("页大小")
protected int pageSize;
@ApiModelProperty("总记录数")
protected long totalCount;
@ApiModelProperty("总页数")
protected long totalPage;
public PageInfo() {}
@ConstructorProperties({"currentPage", "pageSize", "totalCount", "totalPage"})
public PageInfo(int currentPage, int pageSize, long totalCount, long totalPage) {
this.currentPage = currentPage;
this.pageSize = pageSize;
this.totalCount = totalCount;
this.totalPage = totalPage;
}
}
}