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
This commit is contained in:
irving
2025-10-11 02:13:26 -04:00
parent 4dbb637fdc
commit 8faa23e9c3
36 changed files with 1293 additions and 5 deletions

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