初始化天卡开放平台后端项目

包含完整功能模块:
- 设备激活(直销激活码模式 + 代销免激活码模式)
- 微信支付 + 天卡订单
- 天卡池管理 + 异步橘视厅SDK激活
- 第三方合作伙伴接口(代销模式)
  - 签名鉴权(appKey/appSecret + nonce防重放)
  - 天卡消耗(事务 + 幂等 + 并发控制)
  - 媒资接口(三级授权过滤)
  - 审计日志
- 定价策略(3层:门店 > 渠道 > 平台)
- 分账(微信分账 + 分润规则)
- Docker部署(含Redis容器)
This commit is contained in:
zhaogd
2026-07-26 12:34:13 +08:00
commit 30198c7191
349 changed files with 28837 additions and 0 deletions
@@ -0,0 +1,166 @@
package io.as.modules.daycard.constants;
import java.math.BigDecimal;
/**
* 天卡业务相关常量
*/
public final class DayCardConstants {
private DayCardConstants() {
}
// ==================== 订单状态 ====================
/** 已创建 */
public static final String ORDER_STATUS_CREATED = "PENDING";
/** 支付中 */
public static final String ORDER_STATUS_PAYING = "PAYING";
/** 已支付 */
public static final String ORDER_STATUS_PAID = "PAID";
/** 支付失败 */
public static final String ORDER_STATUS_PAY_FAILED = "PAY_FAILED";
/** 已取消 */
public static final String ORDER_STATUS_CANCELLED = "CANCELLED";
/** 退款中 */
public static final String ORDER_STATUS_REFUNDING = "REFUNDING";
// ==================== 天卡池状态 ====================
/** 未使用 */
public static final String POOL_STATUS_UNUSED = "UNUSED";
/** 已预留 */
public static final String POOL_STATUS_RESERVED = "RESERVED";
/** 已使用 */
public static final String POOL_STATUS_USED = "USED";
/** 可复用(已解绑,橘视厅权益仍有效) */
public static final String POOL_STATUS_REUSABLE = "REUSABLE";
// ==================== 导入批次状态 ====================
/** 处理中 */
public static final String BATCH_STATUS_PROCESSING = "PROCESSING";
/** 成功 */
public static final String BATCH_STATUS_SUCCEEDED = "SUCCEEDED";
/** 部分失败 */
public static final String BATCH_STATUS_PARTIAL_FAILED = "PARTIAL_FAILED";
/** 失败 */
public static final String BATCH_STATUS_FAILED = "FAILED";
// ==================== 激活状态 ====================
/** 待激活 */
public static final String ACTIVATE_STATUS_PENDING = "PENDING";
/** 激活成功 */
public static final String ACTIVATE_STATUS_SUCCESS = "SUCCESS";
/** 激活失败 */
public static final String ACTIVATE_STATUS_FAILED = "FAILED";
// ==================== 卡类型 ====================
/** 天卡 */
public static final String CARD_TYPE_DAY = "DAY";
// ==================== Redis Key 前缀 ====================
/** 设备防重锁前缀 */
public static final String REDIS_LOCK_PREFIX = "daycard:lock:device:";
/** 订单前缀 */
public static final String REDIS_ORDER_PREFIX = "daycard:order:";
/** 设备订单前缀 */
public static final String REDIS_DEVICE_ORDER_PREFIX = "daycard:device:";
/** 代销天卡消耗防重锁前缀(按设备标识加锁) */
public static final String REDIS_PARTNER_CONSUME_LOCK_PREFIX = "daycard:partner:consume:";
/** 第三方激活Token缓存前缀(按thirdCardNo缓存) */
public static final String REDIS_THIRD_PARTY_TOKEN_PREFIX = "api:thirdparty:activate:";
/** 第三方激活任务锁前缀(按poolId加锁,防止重复激活) */
public static final String REDIS_THIRD_PARTY_ACTIVATE_LOCK_PREFIX = "api:thirdparty:activate:lock:";
// ==================== 删除标记 ====================
/** 未删除 */
public static final String DELETE_FLAG_UNDEL = "UNDEL";
/** 已删除 */
public static final String DELETE_FLAG_DEL = "DEL";
// ==================== 分润规则范围 ====================
/** 全局 */
public static final String SCOPE_GLOBAL = "GLOBAL";
/** 渠道 */
public static final String SCOPE_CHANNEL = "CHANNEL";
/** 代理商 */
public static final String SCOPE_AGENT = "AGENT";
/** 门店 */
public static final String SCOPE_STORE = "STORE";
// ==================== 分润规则状态 ====================
/** 启用 */
public static final String PROFIT_RULE_STATUS_ACTIVE = "ACTIVE";
/** 禁用 */
public static final String PROFIT_RULE_STATUS_DISABLED = "DISABLED";
// ==================== 分润类型 ====================
/** 按比例分润 */
public static final String SHARE_TYPE_RATIO = "RATIO";
/** 固定金额分润 */
public static final String SHARE_TYPE_FIXED = "FIXED";
// ==================== 分账单状态 ====================
/** 待处理 */
public static final String SETTLE_STATUS_PENDING = "PENDING";
/** 处理中 */
public static final String SETTLE_STATUS_PROCESSING = "PROCESSING";
/** 成功 */
public static final String SETTLE_STATUS_SUCCESS = "SUCCESS";
/** 失败 */
public static final String SETTLE_STATUS_FAILED = "FAILED";
/** 部分成功 */
public static final String SETTLE_STATUS_PARTIAL = "PARTIAL";
// ==================== 接收方绑定状态 ====================
/** 待绑定 */
public static final String BIND_STATUS_PENDING = "PENDING";
/** 已绑定 */
public static final String BIND_STATUS_ACTIVE = "ACTIVE";
/** 绑定失败 */
public static final String BIND_STATUS_FAILED = "FAILED";
// ==================== 分账接收方类型 ====================
/** 商户号 */
public static final String RECEIVER_TYPE_MERCHANT_ID = "MERCHANT_ID";
/** 个人OpenID */
public static final String RECEIVER_TYPE_PERSONAL_OPENID = "PERSONAL_OPENID";
// ==================== 组织角色 ====================
/** 平台 */
public static final String ORG_ROLE_PLATFORM = "PLATFORM";
/** 渠道 */
public static final String ORG_ROLE_CHANNEL = "CHANNEL";
/** 代理 */
public static final String ORG_ROLE_AGENT = "AGENT";
/** 门店 */
public static final String ORG_ROLE_STORE = "STORE";
// ==================== 微信绑定状态 ====================
/** 已绑定 */
public static final String WX_BIND_STATUS_BINDED = "BINDED";
/** 未绑定 */
public static final String WX_BIND_STATUS_UNBOUND = "UNBOUND";
// ==================== 分润比例上限 ====================
/** 分润比例上限:30% */
public static final BigDecimal SHARE_RATIO_LIMIT = new BigDecimal("0.3000");
/** 非平台方分润比例之和上限:30% */
public static final double PROFIT_SHARE_LIMIT = 30.0;
// ==================== 退款状态 ====================
/** 退款待处理 */
public static final String REFUND_STATUS_PENDING = "PENDING";
/** 退款处理中 */
public static final String REFUND_STATUS_PROCESSING = "PROCESSING";
/** 退款成功 */
public static final String REFUND_STATUS_SUCCESS = "SUCCESS";
/** 退款失败 */
public static final String REFUND_STATUS_FAILED = "FAILED";
/** 退款关闭 */
public static final String REFUND_STATUS_CLOSED = "CLOSED";
// ==================== 支付渠道 ====================
/** Mock支付 */
public static final String PAY_CHANNEL_MOCK = "MOCK";
/** 微信支付 */
public static final String PAY_CHANNEL_WECHAT = "WECHAT";
}
@@ -0,0 +1,165 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import io.as.modules.daycard.service.DayCardActivationCodeService;
import io.as.modules.sys.entity.SysOrgEntity;
import io.as.modules.sys.service.SysOrgService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡设备激活码管理(管理后台用)
* <p>管理后台按门店生成激活码,App 端 init 时输入激活码完成设备注册绑定</p>
*/
@Api(tags = "天卡设备激活码")
@RestController
@RequestMapping("/manage/daycard/activation-code")
public class DayCardActivationCodeController {
@Autowired
private DayCardActivationCodeService dayCardActivationCodeService;
@Autowired
private SysOrgService sysOrgService;
/**
* 按门店查询激活码列表
*/
@ApiOperation("按门店查询激活码列表")
@GetMapping("/list/{storeOrgId}")
public R listByStore(@PathVariable("storeOrgId") Long storeOrgId) {
List<DayCardActivationCodeEntity> list = dayCardActivationCodeService.listByStoreOrgId(storeOrgId);
List<Map<String, Object>> result = new ArrayList<>();
for (DayCardActivationCodeEntity entity : list) {
result.add(toMap(entity));
}
Map<String, Object> data = new HashMap<>();
data.put("list", result);
return R.dataSuccess(data);
}
/**
* 为门店生成激活码(每门店一个,已存在则返回已有的)
*/
@ApiOperation("为门店生成激活码")
@AuditAction(value = "ACTCODE_GENERATE", description = "生成门店激活码")
@PostMapping("/generate")
public R generate(@RequestBody GenerateRequest request) {
if (request.getStoreOrgId() == null) {
throw new ASException("门店组织ID不能为空");
}
// 校验门店存在
SysOrgEntity storeOrg = sysOrgService.getById(request.getStoreOrgId());
if (storeOrg == null) {
throw new ASException("门店不存在");
}
// 从门店 mpath 解析渠道
Long channelOrgId = resolveChannelOrgId(storeOrg);
DayCardActivationCodeEntity entity = dayCardActivationCodeService.generateForStore(
request.getStoreOrgId(), channelOrgId, request.getRemark());
Map<String, Object> data = new HashMap<>();
data.put("data", toMap(entity));
return R.dataSuccess(data);
}
/**
* 重新生成激活码 code(旧 code 失效)
*/
@ApiOperation("重新生成激活码")
@AuditAction(value = "ACTCODE_REGENERATE", description = "重新生成激活码")
@PostMapping("/{id}/regenerate")
public R regenerate(@PathVariable("id") Long id) {
String newCode = dayCardActivationCodeService.regenerateCode(id);
Map<String, Object> item = new HashMap<>();
item.put("code", newCode);
Map<String, Object> data = new HashMap<>();
data.put("data", item);
return R.dataSuccess(data);
}
/**
* 启用/禁用激活码
*/
@ApiOperation("启用/禁用激活码")
@AuditAction(value = "ACTCODE_UPDATE_STATUS", description = "修改激活码状态")
@PutMapping("/{id}/status")
public R updateStatus(@PathVariable("id") Long id, @RequestBody UpdateStatusRequest request) {
dayCardActivationCodeService.updateStatus(id, request.getStatus());
return R.ok();
}
/**
* 删除激活码(软删除)
*/
@ApiOperation("删除激活码")
@AuditAction(value = "ACTCODE_DELETE", description = "删除激活码")
@DeleteMapping("/{id}")
public R delete(@PathVariable("id") Long id) {
dayCardActivationCodeService.delete(id);
return R.ok();
}
// ==================== 内部方法 ====================
/**
* 从门店 mpath 解析渠道组织ID(mpath 第2段)
*/
private Long resolveChannelOrgId(SysOrgEntity storeOrg) {
if (storeOrg == null || StringUtils.isBlank(storeOrg.getMpath())) {
return null;
}
String[] segments = storeOrg.getMpath().split("\\.");
if (segments.length >= 2) {
try {
return Long.parseLong(segments[1]);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private Map<String, Object> toMap(DayCardActivationCodeEntity entity) {
Map<String, Object> m = new HashMap<>();
m.put("id", entity.getId());
m.put("code", entity.getCode());
m.put("channelOrgId", entity.getChannelOrgId());
m.put("storeOrgId", entity.getStoreOrgId());
m.put("status", entity.getStatus());
m.put("maxDeviceCount", entity.getMaxDeviceCount());
m.put("activatedCount", entity.getActivatedCount());
m.put("remark", entity.getRemark());
m.put("createdAt", entity.getCreatedAt());
return m;
}
/**
* 生成激活码请求
*/
@lombok.Data
public static class GenerateRequest {
private Long storeOrgId;
private String remark;
}
/**
* 更新状态请求
*/
@lombok.Data
public static class UpdateStatusRequest {
private String status;
}
}
@@ -0,0 +1,192 @@
package io.as.modules.daycard.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.as.common.annotation.AuditAction;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.daycard.service.DayCardPoolService;
import io.as.modules.daycard.vo.DayCardPoolStatsVo;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡管理后台接口(JWT 鉴权)
* <p>供 Web 管理后台调用,与 /openapi/** 合作伙伴接口隔离</p>
*/
@RestController
@RequestMapping("/manage/daycard")
@RequiresAuthentication
public class DayCardManageController {
@Autowired
private DayCardPoolService dayCardPoolService;
@Autowired
private DayCardOrderService dayCardOrderService;
// ===================== 天卡池 =====================
/**
* 天卡池列表
*/
@GetMapping("/pool/list")
public R poolList(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardPoolService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 天卡池统计
*/
@GetMapping("/pool/stats")
public R poolStats(@RequestParam(required = false) Long partnerId) {
DayCardPoolStatsVo vo = buildStats(partnerId);
Map<String, Object> data = new HashMap<>();
data.put("stats", vo);
return R.dataSuccess(data);
}
/**
* 导入天卡卡密
*/
@AuditAction(value = "DAYCARD_POOL_IMPORT", description = "导入天卡卡密")
@PostMapping("/pool/import")
public R poolImport(@RequestBody Map<String, Object> body) {
Long partnerId = null;
Object pid = body.get("partnerId");
if (pid != null) {
partnerId = Long.parseLong(String.valueOf(pid));
}
Object codesObj = body.get("cardCodes");
if (codesObj == null) {
return R.error("cardCodes 不能为空");
}
@SuppressWarnings("unchecked")
List<String> cardCodes = (List<String>) codesObj;
if (cardCodes.isEmpty()) {
return R.error("cardCodes 不能为空");
}
if (partnerId == null) {
return R.error("partnerId 不能为空");
}
int success = dayCardPoolService.importCards(cardCodes, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("successCount", success);
data.put("totalCount", cardCodes.size());
return R.dataSuccess(data);
}
// ===================== 天卡订单 =====================
/**
* 天卡订单列表
*/
@GetMapping("/order/list")
public R orderList(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardOrderService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
// ===================== 设备权益 =====================
/**
* 查询设备天卡权益
*/
@GetMapping("/benefits/{deviceId}")
public R deviceBenefits(@PathVariable Long deviceId) {
DayCardPoolEntity pool = dayCardOrderService.getDeviceBenefits(deviceId);
Map<String, Object> item = new HashMap<>();
if (pool != null) {
item.put("hasBenefits", true);
item.put("thirdCardNo", pool.getThirdCardNo());
item.put("cardCode", pool.getCardCode());
item.put("expireAt", pool.getExpireAt());
} else {
item.put("hasBenefits", false);
}
Map<String, Object> data = new HashMap<>();
data.put("data", item);
return R.dataSuccess(data);
}
/**
* 解绑设备天卡权益(Web端手动操作)
* <p>将设备绑定的 USED 卡转为 REUSABLE,释放设备。橘视厅权益保留,后续新设备下单可复用此卡。</p>
*/
@AuditAction(value = "DAYCARD_DEVICE_UNBIND", description = "解绑设备天卡权益")
@PostMapping("/device/unbind")
public R unbindDevice(@RequestParam Long deviceId) {
dayCardOrderService.unbindDevice(deviceId);
return R.ok("设备解绑成功");
}
/**
* 设备组织解绑(更换安装场所时调用)
* <p>释放设备当前天卡权益并清除门店组织关联(storeOrgId),设备可在新场所用新激活码重新激活绑定。</p>
*/
@AuditAction(value = "DAYCARD_DEVICE_UNBIND_ORG", description = "设备组织解绑")
@PostMapping("/device/unbind-org")
public R unbindDeviceOrg(@RequestParam Long deviceId) {
dayCardOrderService.unbindDeviceOrg(deviceId);
return R.ok("设备组织解绑成功");
}
// ===================== 内部方法 =====================
private Long parsePartnerId(Map<String, Object> params) {
Object val = params.get("partnerId");
if (val == null) {
val = params.get("partner_id");
}
if (val == null || "".equals(val)) {
return null;
}
try {
return Long.parseLong(String.valueOf(val));
} catch (NumberFormatException e) {
return null;
}
}
private DayCardPoolStatsVo buildStats(Long partnerId) {
DayCardPoolStatsVo vo = new DayCardPoolStatsVo();
int available = dayCardPoolService.getAvailableCount(partnerId);
vo.setAvailable(available);
QueryWrapper<DayCardPoolEntity> reservedWrapper = new QueryWrapper<>();
reservedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("status", DayCardConstants.POOL_STATUS_RESERVED);
if (partnerId != null) {
reservedWrapper.eq("partner_id", partnerId);
}
int reserved = dayCardPoolService.count(reservedWrapper);
vo.setReserved(reserved);
QueryWrapper<DayCardPoolEntity> usedWrapper = new QueryWrapper<>();
usedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("status", DayCardConstants.POOL_STATUS_USED);
if (partnerId != null) {
usedWrapper.eq("partner_id", partnerId);
}
int used = dayCardPoolService.count(usedWrapper);
vo.setUsed(used);
vo.setTotal(available + reserved + used);
return vo;
}
}
@@ -0,0 +1,73 @@
package io.as.modules.daycard.controller;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.service.DayCardOrderService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡订单 Controller(管理后台用)
* <p>App 端接口已迁移至 /api/jst/** (ApiJstController),本 Controller 仅保留管理后台用的分页查询和详情接口</p>
*/
@Api(tags = "天卡订单")
@RestController
@RequestMapping("/manage/daycard")
public class DayCardOrderController {
@Autowired
private DayCardOrderService dayCardOrderService;
/**
* 分页查询订单
*/
@ApiOperation("分页查询天卡订单")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardOrderService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 查询订单详情
*/
@ApiOperation("查询订单详情")
@GetMapping("/info/{orderNo}")
public R info(@PathVariable("orderNo") String orderNo) {
DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo);
if (order == null) {
throw new ASException("订单不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("order", order);
return R.dataSuccess(data);
}
/**
* 从请求参数中解析 partnerId(兼容存量数据查询,新订单 partnerId 为 null)
*/
private Long parsePartnerId(Map<String, Object> params) {
Object val = params.get("partnerId");
if (val == null) {
val = params.get("partner_id");
}
if (val == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(val));
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,129 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import io.as.modules.daycard.service.DayCardPriceConfigService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡定价策略管理 Controller
*/
@Api(tags = "天卡定价策略管理")
@RestController
@RequestMapping("/manage/price")
public class DayCardPriceConfigController {
@Autowired
private DayCardPriceConfigService dayCardPriceConfigService;
/**
* 分页查询定价策略(支持 partnerId/status 筛选)
*/
@ApiOperation("分页查询定价策略")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardPriceConfigService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 查询定价策略详情
*/
@ApiOperation("定价策略详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
DayCardPriceConfigEntity config = dayCardPriceConfigService.getById(id);
if (config == null) {
throw new ASException("定价策略不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("config", config);
return R.dataSuccess(data);
}
/**
* 查询生效中的定价策略(3 层维度)
* <p>优先级:门店配置价 > 渠道配置价 > 平台默认价兜底</p>
*
* @param channelOrgId 渠道组织ID(可空)
* @param storeOrgId 门店组织ID(可空)
*/
@ApiOperation("查询生效中的定价策略")
@GetMapping("/effective")
public R effective(@RequestParam(value = "channelOrgId", required = false) Long channelOrgId,
@RequestParam(value = "storeOrgId", required = false) Long storeOrgId) {
DayCardPriceConfigEntity config = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId);
Map<String, Object> data = new HashMap<>();
data.put("config", config);
return R.dataSuccess(data);
}
/**
* 新增定价策略
*/
@ApiOperation("新增定价策略")
@AuditAction(value = "PRICE_SAVE", description = "新增定价策略")
@PostMapping("/save")
public R save(@RequestBody DayCardPriceConfigEntity config) {
Date now = new Date();
config.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
config.setCreatedAt(now);
config.setUpdatedAt(now);
if (config.getStatus() == null) {
config.setStatus("ACTIVE");
}
dayCardPriceConfigService.save(config);
return R.ok();
}
/**
* 修改定价策略
*/
@ApiOperation("修改定价策略")
@AuditAction(value = "PRICE_UPDATE", description = "修改定价策略")
@PostMapping("/update")
public R update(@RequestBody DayCardPriceConfigEntity config) {
if (config.getId() == null) {
throw new ASException("id不能为空");
}
config.setUpdatedAt(new Date());
dayCardPriceConfigService.updateById(config);
return R.ok();
}
/**
* 软删除定价策略
*/
@ApiOperation("删除定价策略")
@AuditAction(value = "PRICE_DELETE", description = "删除定价策略")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
if (ids == null || ids.length == 0) {
throw new ASException("ids不能为空");
}
List<DayCardPriceConfigEntity> list = dayCardPriceConfigService.listByIds(
Arrays.asList(ids));
Date now = new Date();
for (DayCardPriceConfigEntity config : list) {
config.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
config.setUpdatedAt(now);
}
dayCardPriceConfigService.updateBatchById(list);
return R.ok();
}
}
@@ -0,0 +1,75 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import io.as.modules.daycard.service.DayCardRefundService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡退款管理 Controller
*/
@Api(tags = "天卡退款管理")
@RestController
@RequestMapping("/manage/refund")
public class DayCardRefundController {
@Autowired
private DayCardRefundService dayCardRefundService;
/**
* 分页查询退款记录
*/
@ApiOperation("分页查询退款记录")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardRefundService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 退款详情
*/
@ApiOperation("退款详情")
@GetMapping("/info/{refundNo}")
public R info(@PathVariable("refundNo") String refundNo) {
DayCardRefundEntity refund = dayCardRefundService.getByRefundNo(refundNo);
if (refund == null) {
throw new ASException("退款记录不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("refund", refund);
return R.dataSuccess(data);
}
/**
* 发起退款
*
* @param params 包含 orderNo(订单号)和 reason(退款原因)
*/
@ApiOperation("发起退款")
@AuditAction(value = "REFUND_APPLY", description = "发起退款")
@PostMapping("/apply")
public R apply(@RequestBody Map<String, String> params) {
String orderNo = params.get("orderNo");
if (StringUtils.isBlank(orderNo)) {
throw new ASException("orderNo不能为空");
}
String reason = params.get("reason");
DayCardRefundEntity refund = dayCardRefundService.refund(orderNo, reason);
Map<String, Object> data = new HashMap<>();
data.put("refund", refund);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,192 @@
package io.as.modules.daycard.controller;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import io.as.modules.daycard.service.DayCardSettleOrderService;
import io.as.modules.daycard.service.DayCardSettleRecordService;
import io.as.modules.daycard.service.DayCardSettleRuleService;
import io.as.modules.daycard.service.ProfitCalcService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡分账管理 Controller
*/
@Api(tags = "天卡分账管理")
@RestController
@RequestMapping("/manage/settle")
public class DayCardSettleController {
@Autowired
private DayCardSettleRuleService dayCardSettleRuleService;
@Autowired
private DayCardSettleRecordService dayCardSettleRecordService;
@Autowired
private DayCardSettleOrderService dayCardSettleOrderService;
@Autowired
private ProfitCalcService profitCalcService;
// ==================== 分账规则 ====================
/**
* 分页查询分账规则
*/
@ApiOperation("分页查询分账规则")
@GetMapping("/rule/list")
public R ruleList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleRuleService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账规则详情
*/
@ApiOperation("分账规则详情")
@GetMapping("/rule/info/{id}")
public R ruleInfo(@PathVariable("id") Long id) {
DayCardSettleRuleEntity rule = dayCardSettleRuleService.getById(id);
if (rule == null) {
throw new ASException("分账规则不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 新增分账规则
*/
@ApiOperation("新增分账规则")
@PostMapping("/rule/save")
public R ruleSave(@RequestBody DayCardSettleRuleEntity rule) {
if (rule.getName() == null || rule.getName().trim().isEmpty()) {
throw new ASException("规则名称不能为空");
}
if (rule.getShareRatio() == null) {
throw new ASException("分账比例不能为空");
}
dayCardSettleRuleService.saveRule(rule);
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 修改分账规则
*/
@ApiOperation("修改分账规则")
@PostMapping("/rule/update")
public R ruleUpdate(@RequestBody DayCardSettleRuleEntity rule) {
if (rule.getId() == null) {
throw new ASException("id不能为空");
}
dayCardSettleRuleService.update(rule);
return R.ok();
}
/**
* 删除分账规则(软删除)
*/
@ApiOperation("删除分账规则")
@PostMapping("/rule/delete")
public R ruleDelete(@RequestBody Long[] ids) {
dayCardSettleRuleService.delete(ids);
return R.ok();
}
// ==================== 分账记录 ====================
/**
* 分页查询分账记录
*/
@ApiOperation("分页查询分账记录")
@GetMapping("/record/list")
public R recordList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleRecordService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账记录详情
*/
@ApiOperation("分账记录详情")
@GetMapping("/record/info/{id}")
public R recordInfo(@PathVariable("id") Long id) {
DayCardSettleRecordEntity record = dayCardSettleRecordService.getById(id);
if (record == null) {
throw new ASException("分账记录不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("record", record);
return R.dataSuccess(data);
}
// ==================== 分账单 ====================
/**
* 分页查询分账单
*/
@ApiOperation("分页查询分账单")
@GetMapping("/order/list")
public R orderList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleOrderService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账单详情
*/
@ApiOperation("分账单详情")
@GetMapping("/order/info/{id}")
public R orderInfo(@PathVariable("id") Long id) {
DayCardSettleOrderEntity settleOrder = dayCardSettleOrderService.getById(id);
if (settleOrder == null) {
throw new ASException("分账单不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("settleOrder", settleOrder);
return R.dataSuccess(data);
}
// ==================== 试算 ====================
/**
* 分润试算
*/
@ApiOperation("分润试算")
@GetMapping("/trial")
public R trial(@RequestParam("partnerId") Long partnerId,
@RequestParam(value = "channelOrgId", required = false) Long channelOrgId,
@RequestParam(value = "storeOrgId", required = false) Long storeOrgId,
@RequestParam("totalAmount") BigDecimal totalAmount) {
if (partnerId == null) {
throw new ASException("partnerId不能为空");
}
if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ASException("totalAmount必须大于0");
}
Map<String, Object> result = profitCalcService.calculate(partnerId, channelOrgId, storeOrgId, totalAmount);
Map<String, Object> data = new HashMap<>();
data.put("data", result);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,98 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import io.as.modules.daycard.service.OrgSettlementAccountService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 组织分账接收方 Controller
*/
@Api(tags = "组织分账接收方管理")
@RestController
@RequestMapping("/manage/profit-receiver")
public class OrgSettlementAccountController {
@Autowired
private OrgSettlementAccountService orgSettlementAccountService;
/**
* 分页查询分账接收方
*/
@ApiOperation("分页查询分账接收方")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = orgSettlementAccountService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账接收方详情
*/
@ApiOperation("分账接收方详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
OrgSettlementAccountEntity account = orgSettlementAccountService.getById(id);
if (account == null) {
throw new ASException("分账接收方不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("account", account);
return R.dataSuccess(data);
}
/**
* 新增分账接收方
*/
@ApiOperation("新增分账接收方")
@AuditAction(value = "PROFIT_RECEIVER_SAVE", description = "新增分账接收方")
@PostMapping("/save")
public R save(@RequestBody OrgSettlementAccountEntity account) {
if (account.getOrgId() == null) {
throw new ASException("组织ID不能为空");
}
if (account.getReceiverAccount() == null || account.getReceiverAccount().trim().isEmpty()) {
throw new ASException("接收方账号不能为空");
}
if (account.getReceiverType() == null || account.getReceiverType().trim().isEmpty()) {
throw new ASException("接收方类型不能为空");
}
orgSettlementAccountService.saveAccount(account);
Map<String, Object> data = new HashMap<>();
data.put("account", account);
return R.dataSuccess(data);
}
/**
* 删除分账接收方(软删除)
*/
@ApiOperation("删除分账接收方")
@AuditAction(value = "PROFIT_RECEIVER_DELETE", description = "删除分账接收方")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
orgSettlementAccountService.delete(ids);
return R.ok();
}
/**
* 同步接收方到微信
*/
@ApiOperation("同步接收方到微信")
@AuditAction(value = "PROFIT_RECEIVER_SYNC", description = "同步分账接收方到微信")
@PostMapping("/sync/{id}")
public R sync(@PathVariable("id") Long id) {
orgSettlementAccountService.syncToWechat(id);
return R.ok();
}
}
@@ -0,0 +1,187 @@
package io.as.modules.daycard.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.ProfitSharingDetailDao;
import io.as.modules.daycard.dao.ProfitSharingOrderDao;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import io.as.modules.sys.dao.SysOrgDao;
import io.as.modules.sys.entity.SysOrgEntity;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
/**
* 分账记录查询 Controller
*/
@Api(tags = "分账记录查询")
@RestController
@RequestMapping("/manage/profit-sharing")
public class ProfitSharingQueryController {
@Autowired
private ProfitSharingOrderDao profitSharingOrderDao;
@Autowired
private ProfitSharingDetailDao profitSharingDetailDao;
@Autowired
private SysOrgDao sysOrgDao;
/**
* 分页查询分账单(支持 orderNo/status 筛选),返回详情列表
*/
@ApiOperation("分页查询分账单")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
IPage<ProfitSharingOrderEntity> page = new Query<ProfitSharingOrderEntity>().getPage(params, "created_at", false);
QueryWrapper<ProfitSharingOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("pay_order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
String outOrderNo = (String) params.get("outOrderNo");
if (StringUtils.isNotBlank(outOrderNo)) {
wrapper.eq("out_order_no", outOrderNo);
}
IPage<ProfitSharingOrderEntity> result = profitSharingOrderDao.selectPage(page, wrapper);
// 填充明细列表
for (ProfitSharingOrderEntity order : result.getRecords()) {
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", order.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
order.setDetails(details);
}
Map<String, Object> data = new HashMap<>();
data.put("page", new PageUtils(result));
return R.dataSuccess(data);
}
/**
* 分账单详情 + details 子表
*/
@ApiOperation("分账单详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
ProfitSharingOrderEntity order = profitSharingOrderDao.selectById(id);
if (order == null) {
throw new ASException("分账单不存在");
}
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", id);
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
order.setDetails(details);
Map<String, Object> data = new HashMap<>();
data.put("order", order);
return R.dataSuccess(data);
}
/**
* 统计(参数 startDate, endDate, groupBy)
* 返回: { summary: {totalCount, totalAmount, successCount, successAmount}, groups: [{orgId, orgName, orgType, count, amount}] }
*/
@ApiOperation("分账统计")
@GetMapping("/stats")
public R stats(@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate,
@RequestParam(value = "groupBy", required = false) String groupBy) {
QueryWrapper<ProfitSharingOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
if (StringUtils.isNotBlank(startDate)) {
wrapper.ge("created_at", startDate + " 00:00:00");
}
if (StringUtils.isNotBlank(endDate)) {
wrapper.le("created_at", endDate + " 23:59:59");
}
List<ProfitSharingOrderEntity> orders = profitSharingOrderDao.selectList(wrapper);
// 汇总
int totalCount = orders.size();
int totalAmount = 0;
int successCount = 0;
int successAmount = 0;
for (ProfitSharingOrderEntity order : orders) {
if (order.getTotalAmount() != null) {
totalAmount += order.getTotalAmount();
}
if (DayCardConstants.SETTLE_STATUS_SUCCESS.equals(order.getStatus())) {
successCount++;
if (order.getTotalAmount() != null) {
successAmount += order.getTotalAmount();
}
}
}
Map<String, Object> summary = new HashMap<>();
summary.put("totalCount", totalCount);
summary.put("totalAmount", totalAmount);
summary.put("successCount", successCount);
summary.put("successAmount", successAmount);
// 分组统计
List<Map<String, Object>> groups = new ArrayList<>();
if (StringUtils.isNotBlank(groupBy) && "org".equals(groupBy)) {
// 按组织分组:查询所有明细,按 orgId 聚合
Map<Long, Map<String, Object>> orgMap = new HashMap<>();
for (ProfitSharingOrderEntity order : orders) {
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", order.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
for (ProfitSharingDetailEntity detail : details) {
Long orgId = detail.getOrgId();
if (orgId == null) {
continue;
}
Map<String, Object> group = orgMap.get(orgId);
if (group == null) {
group = new HashMap<>();
group.put("orgId", orgId);
group.put("count", 0);
group.put("amount", 0);
group.put("orgRole", detail.getOrgRole());
// 查询组织名称
SysOrgEntity org = sysOrgDao.selectById(orgId);
if (org != null) {
group.put("orgName", org.getName());
group.put("orgType", org.getType());
}
orgMap.put(orgId, group);
}
group.put("count", (Integer) group.get("count") + 1);
if (detail.getAmount() != null) {
group.put("amount", (Integer) group.get("amount") + detail.getAmount());
}
}
}
groups.addAll(orgMap.values());
}
Map<String, Object> result = new HashMap<>();
result.put("summary", summary);
result.put("groups", groups);
return R.dataSuccess(result);
}
}
@@ -0,0 +1,252 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import io.as.modules.daycard.service.ProfitSharingRuleService;
import io.as.modules.sys.entity.SysOrgEntity;
import io.as.modules.sys.service.SysOrgService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 分润规则 Controller
*/
@Api(tags = "分润规则管理")
@RestController
@RequestMapping("/manage/profit-rule")
public class ProfitSharingRuleController {
@Autowired
private ProfitSharingRuleService profitSharingRuleService;
@Autowired
private SysOrgService sysOrgService;
/**
* 分页查询分润规则
*/
@ApiOperation("分页查询分润规则")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = profitSharingRuleService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分润规则详情(含 items 子表)
*/
@ApiOperation("分润规则详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
ProfitSharingRuleEntity rule = profitSharingRuleService.getInfo(id);
if (rule == null) {
throw new ASException("分润规则不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 新增分润规则
*/
@ApiOperation("新增分润规则")
@AuditAction(value = "PROFIT_RULE_SAVE", description = "新增分润规则")
@PostMapping("/save")
public R save(@RequestBody ProfitSharingRuleEntity rule) {
if (StringUtils.isBlank(rule.getRuleName())) {
throw new ASException("规则名称不能为空");
}
if (StringUtils.isBlank(rule.getScopeType())) {
throw new ASException("范围类型不能为空");
}
profitSharingRuleService.saveRule(rule);
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 修改分润规则
*/
@ApiOperation("修改分润规则")
@AuditAction(value = "PROFIT_RULE_UPDATE", description = "修改分润规则")
@PostMapping("/update")
public R update(@RequestBody ProfitSharingRuleEntity rule) {
if (rule.getId() == null) {
throw new ASException("id不能为空");
}
profitSharingRuleService.updateRule(rule);
return R.ok();
}
/**
* 删除分润规则(逻辑删除)
*/
@ApiOperation("删除分润规则")
@AuditAction(value = "PROFIT_RULE_DELETE", description = "删除分润规则")
@PostMapping("/delete")
public R delete(@RequestBody Map<String, Object> body) {
Long id = Long.parseLong(body.get("id").toString());
profitSharingRuleService.delete(id);
return R.ok();
}
/**
* 试算:根据门店和金额预览分润结果
*/
@ApiOperation("分润试算")
@AuditAction(value = "PROFIT_RULE_PREVIEW", description = "分润试算")
@PostMapping("/preview")
public R preview(@RequestBody Map<String, Object> body) {
Long storeOrgId = Long.parseLong(body.get("storeOrgId").toString());
Long amount = Long.parseLong(body.get("amount").toString());
// 1. 从 storeOrgId 查 sys_org 获取 mpath,解析出 channelOrgId 和 agentOrgId
SysOrgEntity storeOrg = sysOrgService.getById(storeOrgId);
if (storeOrg == null) {
throw new ASException("门店组织不存在");
}
String mpath = storeOrg.getMpath();
if (StringUtils.isBlank(mpath)) {
throw new ASException("门店组织 mpath 为空");
}
String[] segments = mpath.split("\\.");
// segments[0] = PLATFORM, segments[1] = CHANNEL, segments[n-1] = STORE
Long platformOrgId = null;
Long channelOrgId = null;
Long agentOrgId = null;
if (segments.length >= 1) {
platformOrgId = Long.parseLong(segments[0]);
}
if (segments.length >= 2) {
channelOrgId = Long.parseLong(segments[1]);
}
// segments.length >= 4 表示路径中有代理商(PLATFORM.CHANNEL.AGENT.STORE)
if (segments.length >= 4) {
agentOrgId = Long.parseLong(segments[segments.length - 2]);
}
// 2. 调用 matchRule 匹配规则
ProfitSharingRuleEntity rule = profitSharingRuleService.matchRule(storeOrgId, agentOrgId, channelOrgId);
if (rule == null) {
throw new ASException("未匹配到分润规则");
}
// 3. 按 items 计算各方分账金额
List<ProfitSharingRuleItemEntity> items = rule.getItems();
if (items == null || items.isEmpty()) {
throw new ASException("分润规则未配置明细");
}
// 按 sortOrder 排序
items.sort((a, b) -> {
int sa = a.getSortOrder() == null ? 0 : a.getSortOrder();
int sb = b.getSortOrder() == null ? 0 : b.getSortOrder();
return Integer.compare(sa, sb);
});
// orgRole -> orgId 映射
Map<String, Long> orgIdMap = new HashMap<>();
orgIdMap.put(DayCardConstants.ORG_ROLE_PLATFORM, platformOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_CHANNEL, channelOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_AGENT, agentOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_STORE, storeOrgId);
// 找到最后一个 PLATFORM 项(尾差归平台)
int lastPlatformIndex = -1;
for (int i = 0; i < items.size(); i++) {
if (DayCardConstants.ORG_ROLE_PLATFORM.equals(items.get(i).getOrgRole())) {
lastPlatformIndex = i;
}
}
// 计算非平台方 RATIO 比例之和
BigDecimal nonPlatformTotalRatio = BigDecimal.ZERO;
// 计算各方分账金额
List<Map<String, Object>> details = new ArrayList<>();
long allocatedTotal = 0;
for (int i = 0; i < items.size(); i++) {
ProfitSharingRuleItemEntity item = items.get(i);
Map<String, Object> detail = new HashMap<>();
detail.put("orgRole", item.getOrgRole());
detail.put("shareType", item.getShareType());
detail.put("shareValue", item.getShareValue());
Long orgId = orgIdMap.get(item.getOrgRole());
detail.put("orgId", orgId);
// 查询组织名称
String orgName = null;
if (orgId != null) {
SysOrgEntity org = sysOrgService.getById(orgId);
if (org != null) {
orgName = org.getName();
}
}
detail.put("orgName", orgName);
long itemAmount;
if (i == lastPlatformIndex) {
// 最后一方 PLATFORM 用减法(尾差归平台)
itemAmount = amount - allocatedTotal;
} else if (DayCardConstants.SHARE_TYPE_FIXED.equals(item.getShareType())) {
// FIXED: shareValue 直接作为分
itemAmount = item.getShareValue().longValue();
allocatedTotal += itemAmount;
} else {
// RATIO: amount * shareValue / 100
BigDecimal ratioAmount = BigDecimal.valueOf(amount)
.multiply(item.getShareValue())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN);
itemAmount = ratioAmount.longValue();
allocatedTotal += itemAmount;
}
// 统计非平台方 RATIO 比例之和
if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole())
&& DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType())
&& item.getShareValue() != null) {
nonPlatformTotalRatio = nonPlatformTotalRatio.add(item.getShareValue());
}
detail.put("amount", itemAmount);
details.add(detail);
}
// 5. warning 非 null 时表示超30%
String warning = null;
if (nonPlatformTotalRatio.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) {
warning = "非平台方分润比例之和" + nonPlatformTotalRatio + "%超过上限" + DayCardConstants.PROFIT_SHARE_LIMIT + "%";
}
// 4. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("ruleId", rule.getId());
result.put("ruleName", rule.getRuleName());
result.put("details", details);
result.put("nonPlatformTotalRatio", nonPlatformTotalRatio);
result.put("warning", warning);
Map<String, Object> data = new HashMap<>();
data.put("data", result);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡设备激活码 DAO
*/
@Mapper
public interface DayCardActivationCodeDao extends BaseMapper<DayCardActivationCodeEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardImportBatchEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡导入批次
*/
@Mapper
public interface DayCardImportBatchDao extends BaseMapper<DayCardImportBatchEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡订单
*/
@Mapper
public interface DayCardOrderDao extends BaseMapper<DayCardOrderEntity> {
}
@@ -0,0 +1,22 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 天卡池
*/
@Mapper
public interface DayCardPoolDao extends BaseMapper<DayCardPoolEntity> {
/**
* 使用 SELECT ... FOR UPDATE SKIP LOCKED 抢占一张未使用的天卡,
* 避免多线程/多实例并发下单时分配到同一张卡。
* <p>天卡池为通用池,不再按 partner 过滤</p>
*
* @return 被当前事务锁定的可用天卡行,无可用时返回 null
*/
DayCardPoolEntity selectOneForUpdateSkipLocked();
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡定价策略 Dao
*/
@Mapper
public interface DayCardPriceConfigDao extends BaseMapper<DayCardPriceConfigEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardProfitReceiverEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账接收方 Dao
*/
@Mapper
public interface DayCardProfitReceiverDao extends BaseMapper<DayCardProfitReceiverEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardProfitRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分润规则 Dao
*/
@Mapper
public interface DayCardProfitRuleDao extends BaseMapper<DayCardProfitRuleEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡退款单 Dao
*/
@Mapper
public interface DayCardRefundDao extends BaseMapper<DayCardRefundEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账单 Dao
*/
@Mapper
public interface DayCardSettleOrderDao extends BaseMapper<DayCardSettleOrderEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账记录 Dao
*/
@Mapper
public interface DayCardSettleRecordDao extends BaseMapper<DayCardSettleRecordEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账规则 Dao
*/
@Mapper
public interface DayCardSettleRuleDao extends BaseMapper<DayCardSettleRuleEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 组织分账接收方 Dao
*/
@Mapper
public interface OrgSettlementAccountDao extends BaseMapper<OrgSettlementAccountEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分账明细 Dao
*/
@Mapper
public interface ProfitSharingDetailDao extends BaseMapper<ProfitSharingDetailEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分账单 Dao
*/
@Mapper
public interface ProfitSharingOrderDao extends BaseMapper<ProfitSharingOrderEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分润规则 Dao
*/
@Mapper
public interface ProfitSharingRuleDao extends BaseMapper<ProfitSharingRuleEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分润规则明细 Dao
*/
@Mapper
public interface ProfitSharingRuleItemDao extends BaseMapper<ProfitSharingRuleItemEntity> {
}
@@ -0,0 +1,52 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡设备激活码
* <p>管理后台按门店生成,App 端 init 时输入激活码完成设备注册绑定</p>
*/
@Data
@TableName("day_card_activation_code")
public class DayCardActivationCodeEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 激活码(全局唯一) */
private String code;
/** 渠道组织ID */
private Long channelOrgId;
/** 门店组织ID */
private Long storeOrgId;
/** 状态:ACTIVE/DISABLED */
private String status;
/** 最大可激活设备数(0=不限) */
private Integer maxDeviceCount;
/** 已激活设备数 */
private Integer activatedCount;
/** 备注 */
private String remark;
/** 删除标记:UNDEL/DEL */
private String deleteFlag;
/** 创建时间 */
private Date createdAt;
/** 更新时间 */
private Date updatedAt;
}
@@ -0,0 +1,46 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡导入批次
*/
@Data
@TableName("day_card_import_batch")
public class DayCardImportBatchEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 批次号 */
private String batchNo;
/** 合作商ID */
private Long partnerId;
/** 状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED */
private String status;
/** 总数 */
private Integer totalCount;
/** 成功数 */
private Integer successCount;
/** 失败数 */
private Integer failCount;
/** 结果报告URL */
private String resultReportUrl;
/** 备注 */
private String remark;
/** 创建人 */
private Long createdBy;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,82 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡订单
*/
@Data
@TableName("day_card_order")
public class DayCardOrderEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 订单号 */
private String orderNo;
/** 合作商ID */
private Long partnerId;
/** 设备ID */
private Long deviceId;
/** 门店组织ID */
private Long storeOrgId;
/** 渠道组织ID */
private Long channelOrgId;
/** 支付后写入的卡号 */
private String cardNo;
/** 订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED */
private String status;
/** 订单金额 */
private BigDecimal amount;
/** 支付单号 */
private String payNo;
/** 支付金额 */
private BigDecimal payAmount;
/** 支付时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date payTime;
/** 订单过期时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date expireTime;
/** 取消时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date cancelTime;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
/** 支付渠道 MOCK/WECHAT */
private String payChannel;
/** 微信预支付ID */
private String prepayId;
/** 微信扫码支付链接 */
private String codeUrl;
/** 设备SN(冗余,便于排查) */
private String deviceSn;
/** 设备MAC(冗余) */
private String mac;
/** 代理商组织ID */
private Long agentOrgId;
/** 组织链路快照(如 1.5.12.) */
private String orgPathSnapshot;
/** 微信支付订单号 */
private String channelOrderNo;
/** 合作商名称(非表字段) */
@TableField(exist = false)
private String partnerName;
/** 设备MAC地址(非表字段) */
@TableField(exist = false)
private String deviceMac;
}
@@ -0,0 +1,59 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡池
*/
@Data
@TableName("day_card_pool")
public class DayCardPoolEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 卡密 */
private String cardCode;
/** 第三方引用标识 */
private String thirdPartyRef;
/** 第三方卡号 */
private String thirdCardNo;
/** 卡类型,默认DAY */
private String cardType;
/** 合作商ID */
private Long partnerId;
/** 状态:UNUSED/RESERVED/USED */
private String status;
/** 激活状态:PENDING/SUCCESS/FAILED */
private String activateStatus;
/** 激活失败原因 */
private String activateError;
/** 激活成功时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date activatedAt;
/** 关联订单号 */
private String orderNo;
/** 设备ID */
private Long deviceId;
/** 导入批次ID */
private Long importBatchId;
/** 卡过期时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date expireAt;
/** 使用时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date usedAt;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,41 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡定价策略
*/
@Data
@TableName("day_card_price_config")
public class DayCardPriceConfigEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 合作伙伴ID */
private Long partnerId;
/** 渠道组织ID(可空=全渠道默认) */
private Long channelOrgId;
/** 门店组织ID(空=渠道级或平台级) */
private Long storeOrgId;
/** 天卡价格(元) */
private BigDecimal price;
/** 策略名称 */
private String name;
/** 状态 ACTIVE/DISABLED */
private String status;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,42 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡分账接收方
*/
@Data
@TableName("day_card_profit_receiver")
public class DayCardProfitReceiverEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 名称 */
private String name;
/** 合作伙伴ID */
private Long partnerId;
/** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */
private String receiverType;
/** 分账接收方账户 */
private String receiverAccount;
/** 接收方名称 */
private String receiverName;
/** 微信绑定状态 BINDED/UNBOUND */
private String wxBindStatus;
/** 备注 */
private String remark;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,53 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡分润规则
*/
@Data
@TableName("day_card_profit_rule")
public class DayCardProfitRuleEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 规则名称 */
private String name;
/** 合作伙伴ID */
private Long partnerId;
/** 渠道组织ID */
private Long channelOrgId;
/** 门店组织ID(空=渠道级或合作商级) */
private Long storeOrgId;
/** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */
private String receiverType;
/** 分账接收方账户 */
private String receiverAccount;
/** 接收方名称 */
private String receiverName;
/** 分润比例(如0.3000=30%)或固定金额 */
private BigDecimal shareRatio;
/** 分润类型 RATIO/FIXED */
private String shareType;
/** 优先级(数字越大优先级越高) */
private Integer priority;
/** 状态 ACTIVE/DISABLED */
private String status;
/** 备注 */
private String remark;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,46 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡退款单
*/
@Data
@TableName("day_card_refund")
public class DayCardRefundEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 退款单号 */
private String refundNo;
/** 关联订单号 */
private String orderNo;
/** 微信退款单号 */
private String wxRefundId;
/** 退款金额 */
private BigDecimal refundAmount;
/** 退款原因 */
private String reason;
/** 状态 PENDING/PROCESSING/SUCCESS/FAILED/CLOSED */
private String status;
/** 错误信息 */
private String errorMessage;
/** 退款完成时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date settledAt;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,54 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡分账单
*/
@Data
@TableName("day_card_settle_order")
public class DayCardSettleOrderEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 分账单号 */
private String settleNo;
/** 订单号 */
private String orderNo;
/** 合作伙伴ID */
private Long partnerId;
/** 分润规则ID */
private Long ruleId;
/** 接收方账户 */
private String receiverAccount;
/** 接收方名称 */
private String receiverName;
/** 分润比例/金额 */
private BigDecimal shareRatio;
/** 分润类型 RATIO/FIXED */
private String shareType;
/** 分账金额 */
private BigDecimal settleAmount;
/** 订单总金额 */
private BigDecimal totalAmount;
/** 状态 PENDING/PROCESSING/SUCCESS/FAILED */
private String status;
/** 错误信息 */
private String errorMessage;
/** 分账完成时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date settledAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,46 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡分账记录
*/
@Data
@TableName("day_card_settle_record")
public class DayCardSettleRecordEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 订单号 */
private String orderNo;
/** 分账规则ID */
private Long settleRuleId;
/** 订单总金额 */
private BigDecimal totalAmount;
/** 分账比例 */
private BigDecimal shareRatio;
/** 分账金额 */
private BigDecimal settleAmount;
/** 接收方账户 */
private String receiverAccount;
/** 状态 PENDING/SUCCESS/FAILED */
private String status;
/** 错误信息 */
private String errorMessage;
/** 分账完成时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date settledAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,47 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡分账规则
*/
@Data
@TableName("day_card_settle_rule")
public class DayCardSettleRuleEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 规则名称 */
private String name;
/** 合作伙伴ID */
private Long partnerId;
/** 渠道组织ID */
private Long channelOrgId;
/** 门店组织ID(空=渠道级) */
private Long storeOrgId;
/** 分账接收方账户 */
private String receiverAccount;
/** 接收方名称 */
private String receiverName;
/** 分账比例(如0.3000=30%) */
private BigDecimal shareRatio;
/** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */
private String receiverType;
/** 状态 ACTIVE/DISABLED */
private String status;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,47 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 组织分账接收方
*/
@Data
@TableName("org_settlement_account")
public class OrgSettlementAccountEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 关联组织 */
private Long orgId;
/** 支付渠道 */
private String payChannel;
/** 接收方类型 MERCHANT_ID / PERSONAL_OPENID */
private String receiverType;
/** 接收方账号(商户号或OpenID) */
private String receiverAccount;
/** 接收方名称 */
private String receiverName;
/** 绑定状态 PENDING/ACTIVE/FAILED */
private String bindStatus;
/** 微信侧绑定ID */
private String externalBindId;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
/** 组织名称(非表字段) */
@TableField(exist = false)
private String orgName;
}
@@ -0,0 +1,49 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 分账明细
*/
@Data
@TableName("profit_sharing_detail")
public class ProfitSharingDetailEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 关联分账单 */
private Long sharingOrderId;
/** 接收方组织ID */
private Long orgId;
/** 组织角色 PLATFORM/CHANNEL/AGENT/STORE */
private String orgRole;
/** 接收方账号 */
private String receiverAccount;
/** 接收方类型 */
private String receiverType;
/** 分账金额(分) */
private Integer amount;
/** 分账描述 */
private String description;
/** 状态 PENDING/SUCCESS/FAILED */
private String status;
/** 微信侧分账单号 */
private String externalOrderId;
/** 成功时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date successTime;
/** 失败原因 */
private String failReason;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,52 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 分账单
*/
@Data
@TableName("profit_sharing_order")
public class ProfitSharingOrderEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 关联支付订单号 */
private String payOrderNo;
/** 微信支付订单号 */
private String transactionId;
/** 分账方内部单号 */
private String outOrderNo;
/** 状态 PENDING/SUCCESS/FAILED/PARTIAL */
private String status;
/** 分账总金额(分) */
private Integer totalAmount;
/** 命中的规则ID */
private Long ruleId;
/** 规则快照(JSON) */
private String ruleSnapshot;
/** 组织链路快照 */
private String orgPathSnapshot;
/** 分账渠道 */
private String sharingChannel;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
/** 分账明细列表(非表字段) */
@TableField(exist = false)
private List<ProfitSharingDetailEntity> details;
}
@@ -0,0 +1,66 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 分润规则(主表)
*/
@Data
@TableName("profit_sharing_rule")
public class ProfitSharingRuleEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 规则名称 */
private String ruleName;
/** 范围类型 GLOBAL/CHANNEL/AGENT/STORE */
private String scopeType;
/** 关联组织ID(GLOBAL时为空) */
private Long scopeOrgId;
/** 优先级 */
private Integer priority;
/** 状态 ACTIVE/DISABLED */
private String status;
/** 生效开始时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date effectiveFrom;
/** 生效结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date effectiveTo;
/** 删除标记 UNDEL/DEL */
private String deleteFlag;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
/** 子表:分润规则明细(非表字段) */
@TableField(exist = false)
private List<ProfitSharingRuleItemEntity> items;
/** 关联组织名称(非表字段) */
@TableField(exist = false)
private String scopeOrgName;
}
@@ -0,0 +1,46 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 分润规则明细(子表)
*/
@Data
@TableName("profit_sharing_rule_item")
public class ProfitSharingRuleItemEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 关联规则ID */
private Long ruleId;
/** 组织角色 PLATFORM/CHANNEL/AGENT/STORE */
private String orgRole;
/** 分润类型 RATIO/FIXED */
private String shareType;
/** 分润值(比例:百分比;固定:分) */
private BigDecimal shareValue;
/** 排序 */
private Integer sortOrder;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,22 @@
package io.as.modules.daycard.form;
import lombok.Data;
import java.io.Serializable;
/**
* 天卡订单创建请求
*/
@Data
public class DayCardOrderCreateRequest implements Serializable {
private static final long serialVersionUID = 1L;
/** 设备MAC(优先) */
private String mac;
/** 设备SN(次优) */
private String deviceSn;
/** 设备UID(兜底) */
private String deviceUid;
/** 媒资ID */
private String mediaId;
}
@@ -0,0 +1,68 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import java.util.List;
/**
* 天卡设备激活码服务
*/
public interface DayCardActivationCodeService extends IService<DayCardActivationCodeEntity> {
/**
* 根据激活码查询有效记录(未删除、状态为 ACTIVE)
*
* @param code 激活码字符串
* @return 激活码实体,不存在或无效返回 null
*/
DayCardActivationCodeEntity getByCode(String code);
/**
* 按门店查询激活码列表
*
* @param storeOrgId 门店组织ID
* @return 激活码列表
*/
List<DayCardActivationCodeEntity> listByStoreOrgId(Long storeOrgId);
/**
* 为门店生成激活码(每门店一个,已存在则返回已有的)
*
* @param storeOrgId 门店组织ID
* @param channelOrgId 渠道组织ID
* @param remark 备注(可选)
* @return 激活码实体
*/
DayCardActivationCodeEntity generateForStore(Long storeOrgId, Long channelOrgId, String remark);
/**
* 重新生成激活码 code(旧 code 失效)
*
* @param id 激活码记录ID
* @return 新激活码字符串
*/
String regenerateCode(Long id);
/**
* 启用/禁用激活码
*
* @param id 激活码记录ID
* @param status 目标状态:ACTIVE/DISABLED
*/
void updateStatus(Long id, String status);
/**
* 删除激活码(软删除)
*
* @param id 激活码记录ID
*/
void delete(Long id);
/**
* 增加已激活设备计数
*
* @param id 激活码记录ID
*/
void incrementActivatedCount(Long id);
}
@@ -0,0 +1,119 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import java.math.BigDecimal;
import java.util.Map;
/**
* 天卡订单 Service
*/
public interface DayCardOrderService extends IService<DayCardOrderEntity> {
/**
* 分页查询天卡订单
*
* @param params 查询参数
* @param partnerId 合作商ID
*/
PageUtils queryPage(Map<String, Object> params, Long partnerId);
/**
* 创建天卡订单(从 sign 鉴权上下文获取 deviceId)
* <p>流程:Redis防重 -> 解析设备组织链路 -> 按渠道定价 -> 生成订单号 -> 插入订单 -> SKIP LOCKED抢卡 -> 乐观锁更新卡状态 -> 返回订单</p>
*
* @param deviceId 设备ID(从 sign 解析)
*/
DayCardOrderEntity createOrder(Long deviceId);
/**
* 创建天卡订单(通过设备标识,扫码后调用)
* <p>流程:Redis防重(mac/deviceSn/deviceUid)-> 解析设备组织链路 -> 服务端定价 -> 生成订单号 -> 插入订单 -> SKIP LOCKED抢卡 -> 乐观锁更新卡状态 -> 返回订单</p>
*
* @param mac 设备MAC(优先)
* @param deviceSn 设备SN(次优)
* @param deviceUid 设备UID(兜底)
* @param partnerId 合作商ID
*/
DayCardOrderEntity createOrder(String mac, String deviceSn, String deviceUid, Long partnerId);
/**
* 支付回调
* <p>流程:幂等检查 -> 乐观锁更新订单PAID -> 更新卡状态USED -> 异步激活 -> 清理Redis</p>
*
* @param orderNo 订单号
* @param payNo 支付单号
* @param payAmount 支付金额
*/
void payCallback(String orderNo, String payNo, BigDecimal payAmount);
/**
* 取消超时未支付的订单
* <p>查询超时订单(状态CREATED, expire_time &lt; now, LIMIT 200)→ 逐个取消(释放卡+更新订单CANCELLED)</p>
*
* @return 取消的订单数量
*/
int cancelTimeoutOrders();
/**
* 根据订单号查询订单
*
* @param orderNo 订单号
*/
DayCardOrderEntity getByOrderNo(String orderNo);
/**
* 查询设备的天卡权益(激活后 App 获取播放凭证)
* <p>从 day_card_pool 表查询 status=USED 且 expire_at > NOW() 的卡,返回 thirdCardNo 和 expireAt</p>
*
* @param deviceId 设备ID
* @return 天卡池实体,无有效权益时返回 null
*/
DayCardPoolEntity getDeviceBenefits(Long deviceId);
/**
* 查询设备生效的天卡定价(供前端二维码展示)
* <p>按 3 层维度匹配:门店 > 渠道 > 平台默认</p>
*
* @param deviceId 设备ID
* @return 定价策略,无匹配时返回 null
*/
DayCardPriceConfigEntity getDevicePrice(Long deviceId);
/**
* 解绑设备天卡权益(Web端手动操作)
* <p>将 USED 卡转为 REUSABLE,释放设备绑定,保留橘视厅权益供后续设备复用</p>
*
* @param deviceId 设备ID
*/
void unbindDevice(Long deviceId);
/**
* 设备组织解绑(更换安装场所时使用)
* <p>释放设备当前天卡权益并清除门店组织关联(storeOrgId),
* 设备可在新场所用新激活码重新激活绑定。</p>
*
* @param deviceId 设备ID
*/
void unbindDeviceOrg(Long deviceId);
/**
* 异步激活天卡:调用橘视厅接口
* <p>通过 Spring AOP 代理调用以确保 @Async 生效</p>
*
* @param order 天卡订单
*/
void asyncActivateCard(DayCardOrderEntity order);
/**
* 重试激活失败的天卡
* <p>补偿定时任务调用:查询 activate_status=FAILED 的卡,重新激活</p>
*
* @return 重试的卡数量
*/
int retryFailedActivations();
}
@@ -0,0 +1,49 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import java.util.List;
import java.util.Map;
/**
* 天卡池 Service
*/
public interface DayCardPoolService extends IService<DayCardPoolEntity> {
/**
* 分页查询天卡池
*
* @param params 查询参数
* @param partnerId 合作商ID
*/
PageUtils queryPage(Map<String, Object> params, Long partnerId);
/**
* 获取可用天卡数量
*
* @param partnerId 合作商ID
*/
int getAvailableCount(Long partnerId);
/**
* 批量导入天卡卡密
* <p>只需提供 cardCode(橘视厅激活码),thirdPartyRef 和 thirdCardNo 由服务端自动生成</p>
*
* @param cardCodes 激活码列表(从橘视厅/供应商获取的真实卡密)
* @param partnerId 合作商ID
* @return 成功导入数量
*/
int importCards(java.util.List<String> cardCodes, Long partnerId);
/**
* 生成全局唯一的第三方引用号(uid):yyyyMMdd-HHmmss-xxxx
*/
String generateUniqueThirdPartyRef();
/**
* 生成 24 位随机第三方卡号(0-9 A-Z a-z)
*/
String generateThirdCardNo();
}
@@ -0,0 +1,40 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import java.math.BigDecimal;
import java.util.Map;
/**
* 天卡定价策略 Service
*/
public interface DayCardPriceConfigService extends IService<DayCardPriceConfigEntity> {
/**
* 分页查询定价策略
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 获取有效定价策略(3 层维度,优先级从高到低)
* <p>1. 门店配置价(storeOrgId 命中)</p>
* <p>2. 渠道配置价(storeOrgId 为空,channelOrgId 命中)</p>
* <p>3. 平台默认价兜底(storeOrgId、channelOrgId 均为空)</p>
*
* @param channelOrgId 渠道组织ID(可空)
* @param storeOrgId 门店组织ID(可空)
* @return 定价策略,无匹配时返回 null
*/
DayCardPriceConfigEntity getEffectivePrice(Long channelOrgId, Long storeOrgId);
/**
* 根据组织链路获取价格金额
*
* @param channelOrgId 渠道组织ID(可空)
* @param storeOrgId 门店组织ID(可空)
* @return 价格金额,无匹配时返回 null
*/
BigDecimal getPrice(Long channelOrgId, Long storeOrgId);
}
@@ -0,0 +1,45 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardProfitReceiverEntity;
import java.util.Map;
/**
* 天卡分账接收方 Service
*/
public interface DayCardProfitReceiverService extends IService<DayCardProfitReceiverEntity> {
/**
* 分页查询分账接收方
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据ID获取分账接收方
*/
DayCardProfitReceiverEntity getById(Long id);
/**
* 新增分账接收方
*/
void saveReceiver(DayCardProfitReceiverEntity receiver);
/**
* 修改分账接收方
*/
void updateReceiver(DayCardProfitReceiverEntity receiver);
/**
* 批量删除分账接收方(软删除)
*/
void delete(Long[] ids);
/**
* 同步接收方到微信(占位方法,实际微信API待对接)
*
* @param id 接收方ID
*/
void syncToWechat(Long id);
}
@@ -0,0 +1,50 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardProfitRuleEntity;
import java.util.List;
import java.util.Map;
/**
* 天卡分润规则 Service
*/
public interface DayCardProfitRuleService extends IService<DayCardProfitRuleEntity> {
/**
* 分页查询分润规则
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据ID获取分润规则
*/
DayCardProfitRuleEntity getById(Long id);
/**
* 新增分润规则(30%校验)
*/
void saveRule(DayCardProfitRuleEntity rule);
/**
* 修改分润规则(30%校验)
*/
void updateRule(DayCardProfitRuleEntity rule);
/**
* 批量删除分润规则(软删除)
*/
void delete(Long[] ids);
/**
* 逐级匹配分润规则
* <p>优先门店级 -> 渠道级 -> 合作商级</p>
*
* @param partnerId 合作伙伴ID
* @param channelOrgId 渠道组织ID
* @param storeOrgId 门店组织ID(可空)
* @return 匹配的分润规则列表,按 priority DESC 排序
*/
List<DayCardProfitRuleEntity> matchRules(Long partnerId, Long channelOrgId, Long storeOrgId);
}
@@ -0,0 +1,45 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import java.util.Map;
/**
* 天卡退款 Service
*/
public interface DayCardRefundService extends IService<DayCardRefundEntity> {
/**
* 分页查询退款记录
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据退款单号查询退款记录
*
* @param refundNo 退款单号
* @return 退款记录
*/
DayCardRefundEntity getByRefundNo(String refundNo);
/**
* 发起退款
* <p>流程:校验订单PAID -> 生成退款单号 -> 创建退款记录 -> 释放卡 -> 回退分账 -> 微信退款 -> 更新退款成功</p>
*
* @param orderNo 订单号
* @param reason 退款原因
* @return 退款记录
*/
DayCardRefundEntity refund(String orderNo, String reason);
/**
* 更新退款状态
*
* @param id 退款记录ID
* @param status 退款状态
* @param errorMessage 错误信息(可为空)
*/
void updateStatus(Long id, String status, String errorMessage);
}
@@ -0,0 +1,50 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import java.util.List;
import java.util.Map;
/**
* 天卡分账单 Service
*/
public interface DayCardSettleOrderService extends IService<DayCardSettleOrderEntity> {
/**
* 分页查询分账单(支持 orderNo/status 筛选)
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据分账单号获取分账单
*/
DayCardSettleOrderEntity getBySettleNo(String settleNo);
/**
* 保存分账单
*/
void saveSettleOrder(DayCardSettleOrderEntity entity);
/**
* 更新分账单状态
*
* @param id 分账单ID
* @param status 状态
* @param errorMessage 错误信息(可空)
*/
void updateStatus(Long id, String status, String errorMessage);
/**
* 根据订单号获取分账单列表
*/
List<DayCardSettleOrderEntity> getByOrderNo(String orderNo);
/**
* 重试失败的分账单
*
* @return 重试的分账单数量
*/
int retryFailed();
}
@@ -0,0 +1,28 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import java.util.Map;
/**
* 天卡分账记录 Service
*/
public interface DayCardSettleRecordService extends IService<DayCardSettleRecordEntity> {
/**
* 分页查询分账记录
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据ID获取分账记录
*/
DayCardSettleRecordEntity getById(Long id);
/**
* 新增分账记录
*/
void saveRecord(DayCardSettleRecordEntity record);
}
@@ -0,0 +1,50 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import java.util.List;
import java.util.Map;
/**
* 天卡分账规则 Service
*/
public interface DayCardSettleRuleService extends IService<DayCardSettleRuleEntity> {
/**
* 分页查询分账规则
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据ID获取分账规则
*/
DayCardSettleRuleEntity getById(Long id);
/**
* 新增分账规则
*/
void saveRule(DayCardSettleRuleEntity rule);
/**
* 修改分账规则
*/
void update(DayCardSettleRuleEntity rule);
/**
* 批量删除分账规则(软删除)
*/
void delete(Long[] ids);
/**
* 根据订单信息获取适用的分账规则
* 优先匹配门店级规则,其次渠道级规则
*
* @param partnerId 合作伙伴ID
* @param channelOrgId 渠道组织ID
* @param storeOrgId 门店组织ID(可空)
* @return 适用的分账规则列表
*/
List<DayCardSettleRuleEntity> getRulesByOrder(Long partnerId, Long channelOrgId, Long storeOrgId);
}
@@ -0,0 +1,43 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import java.util.Map;
/**
* 组织分账接收方 Service
*/
public interface OrgSettlementAccountService extends IService<OrgSettlementAccountEntity> {
/**
* 分页查询(支持 orgId 筛选)
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据 ID 获取
*/
OrgSettlementAccountEntity getById(Long id);
/**
* 保存接收方(设置 deleteFlag=UNDEL)
*/
void saveAccount(OrgSettlementAccountEntity account);
/**
* 批量删除(软删除)
*/
void delete(Long[] ids);
/**
* 同步接收方到微信(占位:设置 bind_status=ACTIVE)
*/
void syncToWechat(Long id);
/**
* 根据组织 ID 获取已绑定的接收方
*/
OrgSettlementAccountEntity getByOrgId(Long orgId);
}
@@ -0,0 +1,34 @@
package io.as.modules.daycard.service;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 分润计算 Service
*/
public interface ProfitCalcService {
/**
* 试算分账结果
*
* @param partnerId 合作伙伴ID
* @param channelOrgId 渠道组织ID
* @param storeOrgId 门店组织ID
* @param totalAmount 总金额
* @return Map: { totalAmount, rules: [{ruleId, receiverAccount, receiverName, shareRatio, settleAmount}], totalSettleAmount }
*/
Map<String, Object> calculate(Long partnerId, Long channelOrgId, Long storeOrgId, BigDecimal totalAmount);
/**
* 匹配规则并计算分账金额,构建分账单列表(不保存,由调用方保存)
*
* @param orderNo 订单号
* @param order 天卡订单
* @return 分账单列表
*/
List<DayCardSettleOrderEntity> buildSettleOrders(String orderNo, DayCardOrderEntity order);
}
@@ -0,0 +1,19 @@
package io.as.modules.daycard.service;
/**
* 分账执行 Service
*/
public interface ProfitSharingExecuteService {
/**
* 执行分账(支付成功后调用)
*
* @param orderNo 订单号
*/
void executeSharing(String orderNo);
/**
* 重试失败的分账单
*/
void retryFailed();
}
@@ -0,0 +1,48 @@
package io.as.modules.daycard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import java.util.Map;
/**
* 分账规则 Service
*/
public interface ProfitSharingRuleService extends IService<ProfitSharingRuleEntity> {
/**
* 分页查询分润规则
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 获取规则详情(含 items 子表)
*/
ProfitSharingRuleEntity getInfo(Long id);
/**
* 新增分润规则
*/
void saveRule(ProfitSharingRuleEntity rule);
/**
* 修改分润规则
*/
void updateRule(ProfitSharingRuleEntity rule);
/**
* 删除分润规则(逻辑删除)
*/
void delete(Long id);
/**
* 匹配分账规则
*
* @param storeOrgId 门店组织ID
* @param agentOrgId 代理组织ID
* @param channelOrgId 渠道组织ID
* @return 匹配的分账规则(含规则项),无匹配返回 null
*/
ProfitSharingRuleEntity matchRule(Long storeOrgId, Long agentOrgId, Long channelOrgId);
}
@@ -0,0 +1,31 @@
package io.as.modules.daycard.service;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
/**
* 分账执行 Service
*/
public interface SettleExecuteService {
/**
* 执行分账(支付成功后调用)
* <p>流程:查询订单 -> 构建分账单 -> 保存 -> 逐条执行</p>
*
* @param orderNo 订单号
*/
void executeSettle(String orderNo);
/**
* 执行单条分账单
*
* @param settleOrder 分账单
*/
void executeSingle(DayCardSettleOrderEntity settleOrder);
/**
* 重试失败的分账单
*
* @return 重试的分账单数量
*/
int retryFailed();
}
@@ -0,0 +1,174 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardActivationCodeDao;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import io.as.modules.daycard.service.DayCardActivationCodeService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Random;
/**
* 天卡设备激活码服务实现
* <p>激活码生成算法复用生产环境 as-partner-backend:5位随机字符(字符集 ABCDEFGHJKMNPQRSTUVWXY23456789,去除易混字符 0/I/L/Z/O/1)</p>
*/
@Service("dayCardActivationCodeService")
public class DayCardActivationCodeServiceImpl
extends ServiceImpl<DayCardActivationCodeDao, DayCardActivationCodeEntity>
implements DayCardActivationCodeService {
/** 激活码随机字符集:去除 0/I/L/Z/O/1 等易混字符 */
private static final String ALLOWED_CHARS = "ABCDEFGHJKMNPQRSTUVWXY23456789";
private static final int CODE_LENGTH = 5;
private static final int MAX_RETRY = 64;
@Override
public DayCardActivationCodeEntity getByCode(String code) {
if (StringUtils.isBlank(code)) {
return null;
}
return this.lambdaQuery()
.eq(DayCardActivationCodeEntity::getCode, code.trim())
.eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.eq(DayCardActivationCodeEntity::getStatus, "ACTIVE")
.last("LIMIT 1")
.one();
}
@Override
public List<DayCardActivationCodeEntity> listByStoreOrgId(Long storeOrgId) {
if (storeOrgId == null) {
return new ArrayList<>();
}
return this.lambdaQuery()
.eq(DayCardActivationCodeEntity::getStoreOrgId, storeOrgId)
.eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.orderByDesc(DayCardActivationCodeEntity::getId)
.list();
}
@Override
@Transactional(rollbackFor = Exception.class)
public DayCardActivationCodeEntity generateForStore(Long storeOrgId, Long channelOrgId, String remark) {
if (storeOrgId == null) {
throw new ASException("门店组织ID不能为空");
}
// 检查门店是否已有激活码,已有则返回已有的
DayCardActivationCodeEntity existing = this.lambdaQuery()
.eq(DayCardActivationCodeEntity::getStoreOrgId, storeOrgId)
.eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT 1")
.one();
if (existing != null) {
return existing;
}
Date now = new Date();
DayCardActivationCodeEntity entity = new DayCardActivationCodeEntity();
entity.setCode(generateUniqueCode(null));
entity.setChannelOrgId(channelOrgId);
entity.setStoreOrgId(storeOrgId);
entity.setStatus("ACTIVE");
entity.setMaxDeviceCount(0);
entity.setActivatedCount(0);
entity.setRemark(remark);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
this.save(entity);
return entity;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String regenerateCode(Long id) {
if (id == null) {
throw new ASException("激活码记录ID不能为空");
}
DayCardActivationCodeEntity entity = getById(id);
if (entity == null) {
throw new ASException("激活码记录不存在");
}
String newCode = generateUniqueCode(id);
entity.setCode(newCode);
entity.setUpdatedAt(new Date());
updateById(entity);
return newCode;
}
@Override
public void updateStatus(Long id, String status) {
if (id == null) {
throw new ASException("激活码记录ID不能为空");
}
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
throw new ASException("无效的状态值");
}
DayCardActivationCodeEntity entity = getById(id);
if (entity == null) {
throw new ASException("激活码记录不存在");
}
entity.setStatus(status);
entity.setUpdatedAt(new Date());
updateById(entity);
}
@Override
public void delete(Long id) {
if (id == null) {
throw new ASException("激活码记录ID不能为空");
}
DayCardActivationCodeEntity entity = getById(id);
if (entity == null) {
throw new ASException("激活码记录不存在");
}
entity.setDeleteFlag("DEL");
entity.setUpdatedAt(new Date());
updateById(entity);
}
@Override
public void incrementActivatedCount(Long id) {
if (id == null) {
return;
}
DayCardActivationCodeEntity entity = getById(id);
if (entity == null) {
return;
}
int current = entity.getActivatedCount() != null ? entity.getActivatedCount() : 0;
entity.setActivatedCount(current + 1);
entity.setUpdatedAt(new Date());
updateById(entity);
}
/**
* 生成5位随机字符激活码,校验全局唯一(排除自身ID)
*/
private String generateUniqueCode(Long excludeId) {
Random random = new Random();
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(ALLOWED_CHARS.charAt(random.nextInt(ALLOWED_CHARS.length())));
}
String candidate = sb.toString();
long dup = this.lambdaQuery()
.eq(DayCardActivationCodeEntity::getCode, candidate)
.eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.ne(excludeId != null, DayCardActivationCodeEntity::getId, excludeId)
.count();
if (dup == 0) {
return candidate;
}
}
throw new ASException("生成唯一激活码失败,请重试");
}
}
@@ -0,0 +1,802 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.common.utils.RedisUtils;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardOrderDao;
import io.as.modules.daycard.dao.DayCardPoolDao;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.daycard.service.DayCardPriceConfigService;
import io.as.modules.daycard.service.ProfitSharingExecuteService;
import io.as.modules.daycard.service.SettleExecuteService;
import io.as.modules.device.dao.CardDeviceBindingDao;
import io.as.modules.device.dao.DeviceDao;
import io.as.modules.device.dto.DeviceOrgContext;
import io.as.modules.device.entity.DeviceEntity;
import io.as.modules.device.service.DeviceResolveService;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.jst.service.JushitingApiService;
import io.as.modules.jst.service.JstActivateCardResult;
import io.as.modules.payment.service.WechatPayService;
import io.as.modules.sys.entity.SysOrgEntity;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 天卡订单 Service 实现
*/
@Service("dayCardOrderService")
public class DayCardOrderServiceImpl extends ServiceImpl<DayCardOrderDao, DayCardOrderEntity> implements DayCardOrderService {
private static final Logger logger = LoggerFactory.getLogger(DayCardOrderServiceImpl.class);
@Autowired
private DayCardPoolDao dayCardPoolDao;
@Autowired
private RedisUtils redisUtils;
@Autowired
private JushitingApiService jushitingApiService;
@Autowired
private DeviceDao deviceDao;
@Autowired
private CardDeviceBindingDao cardDeviceBindingDao;
@Autowired
private DayCardPriceConfigService dayCardPriceConfigService;
@Autowired
private DeviceResolveService deviceResolveService;
@Autowired
@Lazy
private SettleExecuteService settleExecuteService;
@Autowired
@Lazy
private ProfitSharingExecuteService profitSharingExecuteService;
@Autowired
@Lazy
private WechatPayService wechatPayService;
/** 自注入以确保 @Async 通过 Spring 代理生效(避免同类自调用绕过 AOP 代理) */
@Autowired
@Lazy
private DayCardOrderService selfProxy;
@Autowired
private ApiDeviceSignService apiDeviceSignService;
@Value("${renren.daycard.order-timeout-minutes:15}")
private int orderTimeoutMinutes;
@Value("${renren.daycard.cancel-batch-size:200}")
private int cancelBatchSize;
@Override
public PageUtils queryPage(Map<String, Object> params, Long partnerId) {
IPage<DayCardOrderEntity> page = new Query<DayCardOrderEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
if (partnerId != null) {
wrapper.eq("partner_id", partnerId);
}
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardOrderEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
@Transactional(rollbackFor = Exception.class)
public DayCardOrderEntity createOrder(Long deviceId) {
if (deviceId == null) {
throw new ASException("设备ID不能为空");
}
// 0. 查重:已有有效权益则拒绝创建订单,避免卡密浪费
DayCardPoolEntity existingBenefits = getDeviceBenefits(deviceId);
if (existingBenefits != null) {
throw new ASException("该设备已有有效天卡权益,无需重复购买");
}
// 1. Redis防重:用 deviceId 作为锁键
String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + deviceId;
long ttlSeconds = orderTimeoutMinutes * 60L;
boolean locked = redisUtils.setIfAbsent(lockKey, "1", ttlSeconds);
if (!locked) {
throw new ASException("该设备已有进行中的订单,请稍后再试");
}
try {
// 2. 解析设备组织链路(设备 → 门店 → 渠道 → 代理商)
DeviceOrgContext orgContext = deviceResolveService.resolveDevice(deviceId);
DeviceEntity device = orgContext.getDevice();
Long storeOrgId = device.getStoreOrgId();
Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null;
Long agentOrgId = orgContext.getAgentOrg() != null ? orgContext.getAgentOrg().getId() : null;
// 3. 组织链路快照
SysOrgEntity storeOrg = orgContext.getStoreOrg();
String orgPathSnapshot = storeOrg != null ? storeOrg.getMpath() : null;
// 4. 3 层定价:门店 > 渠道 > 平台默认
DayCardPriceConfigEntity priceConfig = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId);
if (priceConfig == null) {
throw new ASException("未配置定价策略,请先在管理后台配置天卡价格");
}
BigDecimal amount = priceConfig.getPrice();
// 5. 生成订单号
String orderNo = generateOrderNo();
// 6. 插入订单(partnerId 置 null)
DayCardOrderEntity order = new DayCardOrderEntity();
order.setOrderNo(orderNo);
order.setPartnerId(null);
order.setDeviceId(device.getId());
order.setStoreOrgId(storeOrgId);
order.setChannelOrgId(channelOrgId);
order.setAmount(amount);
order.setStatus(DayCardConstants.ORDER_STATUS_CREATED);
order.setPayChannel("MOCK");
order.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
Date now = new Date();
order.setCreatedAt(now);
order.setUpdatedAt(now);
Date expireTime = new Date(now.getTime() + ttlSeconds * 1000);
order.setExpireTime(expireTime);
order.setDeviceSn(device.getDeviceSn());
order.setMac(device.getMac());
order.setAgentOrgId(agentOrgId);
order.setOrgPathSnapshot(orgPathSnapshot);
baseMapper.insert(order);
// 7. 微信 Native 支付下单
if (wechatPayService.isConfigured()) {
try {
String codeUrl = wechatPayService.createNativePay(orderNo, "天卡订单", amount);
order.setCodeUrl(codeUrl);
order.setPayChannel("WECHAT");
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", codeUrl)
.set("pay_channel", "WECHAT");
baseMapper.update(null, payUpdate);
} catch (Exception e) {
logger.warn("微信下单失败,回退到MOCK模式: orderNo={}", orderNo, e);
order.setPayChannel("MOCK");
order.setCodeUrl("mock://pay?orderNo=" + orderNo);
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", order.getCodeUrl())
.set("pay_channel", "MOCK");
baseMapper.update(null, payUpdate);
}
} else {
order.setPayChannel("MOCK");
order.setCodeUrl("mock://pay?orderNo=" + orderNo);
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", order.getCodeUrl())
.set("pay_channel", "MOCK");
baseMapper.update(null, payUpdate);
}
// 8. SKIP LOCKED 抢卡(通用池,不按 partner 过滤)
DayCardPoolEntity pool = dayCardPoolDao.selectOneForUpdateSkipLocked();
if (pool == null) {
logger.warn("创建天卡订单失败:天卡池库存不足, orderNo={}", orderNo);
throw new ASException("天卡库存不足,请补充库存后再试");
}
// 9. 乐观锁更新卡状态 UNUSED -> RESERVED
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("id", pool.getId())
.in("status", DayCardConstants.POOL_STATUS_UNUSED, DayCardConstants.POOL_STATUS_REUSABLE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.POOL_STATUS_RESERVED)
.set("order_no", orderNo)
.set("device_id", device.getId())
.set("updated_at", now);
int updated = dayCardPoolDao.update(null, poolUpdate);
if (updated == 0) {
logger.warn("抢卡竞争失败: poolId={}, orderNo={}", pool.getId(), orderNo);
throw new ASException("天卡库存不足,请稍后再试");
}
order.setCardNo(pool.getCardCode());
return order;
} catch (ASException e) {
redisUtils.delete(lockKey);
throw e;
} catch (Exception e) {
redisUtils.delete(lockKey);
throw new ASException("创建天卡订单失败: " + e.getMessage(), e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public DayCardOrderEntity createOrder(String mac, String deviceSn, String deviceUid, Long partnerId) {
// 1. Redis防重:用 mac 或 deviceSn 或 deviceUid 作为锁键
String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX;
if (StringUtils.isNotBlank(mac)) {
lockKey = lockKey + mac;
} else if (StringUtils.isNotBlank(deviceSn)) {
lockKey = lockKey + deviceSn;
} else if (StringUtils.isNotBlank(deviceUid)) {
lockKey = lockKey + deviceUid;
} else {
throw new ASException("设备标识不能全为空");
}
long ttlSeconds = orderTimeoutMinutes * 60L;
boolean locked = redisUtils.setIfAbsent(lockKey, "1", ttlSeconds);
if (!locked) {
throw new ASException("该设备已有进行中的订单,请稍后再试");
}
try {
// 2. 通过 DeviceResolveService 解析设备组织链路(设备 → 门店 → 渠道 → 代理商)
DeviceOrgContext orgContext = deviceResolveService.resolveDevice(mac, deviceSn, deviceUid);
DeviceEntity device = orgContext.getDevice();
Long storeOrgId = device.getStoreOrgId();
// 查重:已有有效权益则拒绝创建订单,避免卡密浪费
DayCardPoolEntity existingBenefits = getDeviceBenefits(device.getId());
if (existingBenefits != null) {
throw new ASException("该设备已有有效天卡权益,无需重复购买");
}
Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null;
Long agentOrgId = orgContext.getAgentOrg() != null ? orgContext.getAgentOrg().getId() : null;
// 3. 构建组织链路快照(使用门店的 mpath)
SysOrgEntity storeOrg = orgContext.getStoreOrg();
String orgPathSnapshot = storeOrg != null ? storeOrg.getMpath() : null;
// 4. 3 层定价:门店 > 渠道 > 平台默认
DayCardPriceConfigEntity priceConfig = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId);
if (priceConfig == null) {
throw new ASException("未配置定价策略,请先在管理后台配置天卡价格");
}
BigDecimal amount = priceConfig.getPrice();
// 5. 生成订单号(yyyyMMddHHmmss + 6位随机数)
String orderNo = generateOrderNo();
// 6. 插入订单
DayCardOrderEntity order = new DayCardOrderEntity();
order.setOrderNo(orderNo);
order.setPartnerId(partnerId);
order.setDeviceId(device.getId());
order.setStoreOrgId(storeOrgId);
order.setChannelOrgId(channelOrgId);
order.setAmount(amount);
order.setStatus(DayCardConstants.ORDER_STATUS_CREATED);
order.setPayChannel("MOCK");
order.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
Date now = new Date();
order.setCreatedAt(now);
order.setUpdatedAt(now);
// 订单过期时间
Date expireTime = new Date(now.getTime() + ttlSeconds * 1000);
order.setExpireTime(expireTime);
// 额外设置设备冗余信息
order.setDeviceSn(device.getDeviceSn());
order.setMac(device.getMac());
order.setAgentOrgId(agentOrgId);
order.setOrgPathSnapshot(orgPathSnapshot);
baseMapper.insert(order);
// 6.5 微信 Native 支付下单
if (wechatPayService.isConfigured()) {
try {
String codeUrl = wechatPayService.createNativePay(orderNo, "天卡订单", amount);
order.setCodeUrl(codeUrl);
order.setPayChannel("WECHAT");
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", codeUrl)
.set("pay_channel", "WECHAT");
baseMapper.update(null, payUpdate);
} catch (Exception e) {
logger.warn("微信下单失败,回退到MOCK模式: orderNo={}", orderNo, e);
order.setPayChannel("MOCK");
order.setCodeUrl("mock://pay?orderNo=" + orderNo);
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", order.getCodeUrl())
.set("pay_channel", "MOCK");
baseMapper.update(null, payUpdate);
}
} else {
// 开发模式:使用 Mock 支付
order.setPayChannel("MOCK");
order.setCodeUrl("mock://pay?orderNo=" + orderNo);
UpdateWrapper<DayCardOrderEntity> payUpdate = new UpdateWrapper<>();
payUpdate.eq("id", order.getId())
.set("code_url", order.getCodeUrl())
.set("pay_channel", "MOCK");
baseMapper.update(null, payUpdate);
}
// 7. SKIP LOCKED 抢卡
DayCardPoolEntity pool = dayCardPoolDao.selectOneForUpdateSkipLocked();
if (pool == null) {
logger.warn("创建天卡订单失败:合作商[{}]天卡池库存不足, orderNo={}", partnerId, orderNo);
throw new ASException("天卡库存不足,请补充库存后再试");
}
// 8. 乐观锁更新卡状态 UNUSED -> RESERVED
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("id", pool.getId())
.in("status", DayCardConstants.POOL_STATUS_UNUSED, DayCardConstants.POOL_STATUS_REUSABLE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.POOL_STATUS_RESERVED)
.set("order_no", orderNo)
.set("device_id", device.getId())
.set("updated_at", now);
int updated = dayCardPoolDao.update(null, poolUpdate);
if (updated == 0) {
// 卡已被其他线程抢占
logger.warn("抢卡竞争失败: poolId={}, orderNo={}", pool.getId(), orderNo);
throw new ASException("天卡库存不足,请稍后再试");
}
// 更新订单上的卡号信息
order.setCardNo(pool.getCardCode());
return order;
} catch (ASException e) {
// 业务异常时清理Redis锁,允许用户重试
redisUtils.delete(lockKey);
throw e;
} catch (Exception e) {
redisUtils.delete(lockKey);
throw new ASException("创建天卡订单失败: " + e.getMessage(), e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void payCallback(String orderNo, String payNo, BigDecimal payAmount) {
// 1. 幂等检查:查询订单
DayCardOrderEntity order = getByOrderNo(orderNo);
if (order == null) {
throw new ASException("订单不存在: " + orderNo);
}
// 已支付则幂等返回
if (DayCardConstants.ORDER_STATUS_PAID.equals(order.getStatus())) {
logger.info("支付回调幂等返回: orderNo={}, 已为PAID状态", orderNo);
return;
}
// 非CREATED/PAYING状态不允许回调
if (!DayCardConstants.ORDER_STATUS_CREATED.equals(order.getStatus())
&& !DayCardConstants.ORDER_STATUS_PAYING.equals(order.getStatus())) {
throw new ASException("订单状态不允许支付回调: " + order.getStatus());
}
// 2. 乐观锁更新订单为PAID
UpdateWrapper<DayCardOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("order_no", orderNo)
.in("status", DayCardConstants.ORDER_STATUS_CREATED, DayCardConstants.ORDER_STATUS_PAYING)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.ORDER_STATUS_PAID)
.set("pay_no", payNo)
.set("pay_amount", payAmount)
.set("pay_time", new Date())
.set("updated_at", new Date());
int updated = baseMapper.update(null, orderUpdate);
if (updated == 0) {
throw new ASException("订单状态已变更,支付回调失败: " + orderNo);
}
// 3. 更新卡状态 RESERVED -> USED,同时设置初始 expire_at(24小时兜底,异步激活成功后覆盖)
Date now = new Date();
Date defaultExpireAt = new Date(now.getTime() + 24 * 60 * 60 * 1000L);
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("order_no", orderNo)
.eq("status", DayCardConstants.POOL_STATUS_RESERVED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.POOL_STATUS_USED)
.set("activate_status", DayCardConstants.ACTIVATE_STATUS_PENDING)
.set("used_at", now)
.set("expire_at", defaultExpireAt)
.set("updated_at", now);
dayCardPoolDao.update(null, poolUpdate);
// 4. 异步激活(调用jst服务)——通过 selfProxy 调用确保 @Async 生效
selfProxy.asyncActivateCard(order);
// 5. 清理Redis防重锁
String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceId();
redisUtils.delete(lockKey);
// 兼容新下单接口(锁键使用mac/deviceSn/deviceUid)
if (StringUtils.isNotBlank(order.getMac())) {
redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getMac());
}
if (StringUtils.isNotBlank(order.getDeviceSn())) {
redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceSn());
}
// 6. 触发分账(异步)
try {
profitSharingExecuteService.executeSharing(orderNo);
} catch (Exception e) {
logger.error("触发分账失败: orderNo={}", orderNo, e);
}
}
/**
* 异步激活天卡:调用橘视厅接口
* <p>通过 {@link #selfProxy} 调用以确保 Spring AOP 代理生效</p>
* <p>激活状态追踪:PENDING → SUCCESS/FAILED</p>
*/
@Async("thirdPartyActivateExecutor")
@Override
public void asyncActivateCard(DayCardOrderEntity order) {
try {
// 查询绑定的天卡
QueryWrapper<DayCardPoolEntity> poolWrapper = new QueryWrapper<>();
poolWrapper.eq("order_no", order.getOrderNo())
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
DayCardPoolEntity pool = dayCardPoolDao.selectOne(poolWrapper);
if (pool == null) {
logger.warn("异步激活天卡失败:未找到订单关联的卡, orderNo={}", order.getOrderNo());
return;
}
// 查询设备
DeviceEntity device = deviceDao.selectById(order.getDeviceId());
if (device == null) {
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "设备不存在: " + order.getDeviceId(), null);
logger.warn("异步激活天卡失败:设备不存在, deviceId={}", order.getDeviceId());
return;
}
// 1. 调用橘视厅免密登录获取Token
String thirdCardRef = pool.getThirdCardNo();
if (StringUtils.isBlank(thirdCardRef)) {
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "卡的第三方引用为空", null);
logger.warn("异步激活天卡失败:卡的第三方引用为空, poolId={}", pool.getId());
return;
}
Map<String, String> tokenResult = jushitingApiService.getToken(device, thirdCardRef);
if (tokenResult == null || StringUtils.isBlank(tokenResult.get("token"))) {
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "获取Token失败", null);
logger.warn("异步激活天卡失败:获取Token失败, orderNo={}", order.getOrderNo());
return;
}
// 2. 调用卡密激活
String memberCode = tokenResult.get("memberCode");
String token = tokenResult.get("token");
JstActivateCardResult activateResult = jushitingApiService.activateCard(
order.getDeviceId(), pool.getCardCode(), memberCode, token);
if (!activateResult.isSuccess()) {
String errorMsg = activateResult.getErrorMessage() != null ? activateResult.getErrorMessage() : "卡密激活返回失败";
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, errorMsg, null);
logger.error("异步激活天卡失败:卡密激活返回失败, orderNo={}, cardCode={}", order.getOrderNo(), pool.getCardCode());
return;
}
// 3. 确定卡有效期
Date expireAt = null;
// 优先使用激活接口返回的 expireTime
if (StringUtils.isNotBlank(activateResult.getExpireTime())) {
expireAt = parseExpireTime(activateResult.getExpireTime());
}
// 激活接口未返回到期时间,主动查询 VIP 到期时间
if (expireAt == null) {
String vipExpireTime = jushitingApiService.queryVipExpireTime(order.getDeviceId(), thirdCardRef);
if (StringUtils.isNotBlank(vipExpireTime)) {
expireAt = parseExpireTime(vipExpireTime);
}
}
// 兜底:默认24小时有效期
if (expireAt == null) {
expireAt = new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000L);
logger.warn("橘视厅未返回到期时间,使用默认24小时: orderNo={}", order.getOrderNo());
}
// 4. 更新 day_card_pool:写入 expire_at + 激活成功状态
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("id", pool.getId())
.set("expire_at", expireAt)
.set("activate_status", DayCardConstants.ACTIVATE_STATUS_SUCCESS)
.set("activated_at", new Date())
.set("activate_error", null)
.set("updated_at", new Date());
dayCardPoolDao.update(null, poolUpdate);
logger.info("异步激活天卡完成: orderNo={}, cardCode={}, expireAt={}", order.getOrderNo(), pool.getCardCode(), expireAt);
} catch (Exception e) {
logger.error("异步激活天卡异常: orderNo={}", order.getOrderNo(), e);
// 尝试更新激活失败状态
try {
QueryWrapper<DayCardPoolEntity> poolWrapper = new QueryWrapper<>();
poolWrapper.eq("order_no", order.getOrderNo())
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
DayCardPoolEntity pool = dayCardPoolDao.selectOne(poolWrapper);
if (pool != null) {
String errorMsg = e.getMessage();
if (errorMsg != null && errorMsg.length() > 900) {
errorMsg = errorMsg.substring(0, 900);
}
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, errorMsg, null);
}
} catch (Exception ex) {
logger.error("更新激活失败状态异常: orderNo={}", order.getOrderNo(), ex);
}
}
}
/**
* 更新天卡激活状态
*/
private void updateActivateStatus(Long poolId, String status, String error, Date activatedAt) {
UpdateWrapper<DayCardPoolEntity> update = new UpdateWrapper<>();
update.eq("id", poolId)
.set("activate_status", status)
.set("activate_error", error)
.set("updated_at", new Date());
if (activatedAt != null) {
update.set("activated_at", activatedAt);
}
dayCardPoolDao.update(null, update);
}
/**
* 解析橘视厅返回的到期时间字符串(格式如 "2027-04-24 16:48:58")
*/
private Date parseExpireTime(String timeStr) {
if (timeStr == null || timeStr.trim().isEmpty()) {
return null;
}
try {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
return sdf.parse(timeStr.trim());
} catch (Exception e) {
logger.warn("解析到期时间失败: {}", timeStr);
return null;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public int cancelTimeoutOrders() {
// 查询超时订单(状态CREATED, expire_time < now, LIMIT batch_size)
QueryWrapper<DayCardOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("status", DayCardConstants.ORDER_STATUS_CREATED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.lt("expire_time", new Date())
.last("LIMIT " + cancelBatchSize);
List<DayCardOrderEntity> timeoutOrders = baseMapper.selectList(wrapper);
int cancelled = 0;
for (DayCardOrderEntity order : timeoutOrders) {
try {
cancelSingleOrder(order);
cancelled++;
} catch (Exception e) {
logger.error("取消超时订单失败: orderNo={}", order.getOrderNo(), e);
}
}
if (cancelled > 0) {
logger.info("本次取消超时天卡订单 {} 笔", cancelled);
}
return cancelled;
}
/**
* 取消单个超时订单:释放卡 + 更新订单CANCELLED
*/
private void cancelSingleOrder(DayCardOrderEntity order) {
Date now = new Date();
// 微信支付订单需调用微信关单API
if ("WECHAT".equals(order.getPayChannel())) {
try {
wechatPayService.closeOrder(order.getOrderNo());
} catch (Exception e) {
logger.warn("微信关单失败,继续本地取消: orderNo={}", order.getOrderNo(), e);
}
}
// 释放卡:RESERVED -> UNUSED
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("order_no", order.getOrderNo())
.eq("status", DayCardConstants.POOL_STATUS_RESERVED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.POOL_STATUS_UNUSED)
.set("order_no", null)
.set("device_id", null)
.set("updated_at", now);
dayCardPoolDao.update(null, poolUpdate);
// 更新订单为CANCELLED
UpdateWrapper<DayCardOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("id", order.getId())
.eq("status", DayCardConstants.ORDER_STATUS_CREATED)
.set("status", DayCardConstants.ORDER_STATUS_CANCELLED)
.set("cancel_time", now)
.set("updated_at", now);
baseMapper.update(null, orderUpdate);
// 清理Redis防重锁
String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceId();
redisUtils.delete(lockKey);
// 兼容新下单接口(锁键使用mac/deviceSn/deviceUid)
if (StringUtils.isNotBlank(order.getMac())) {
redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getMac());
}
if (StringUtils.isNotBlank(order.getDeviceSn())) {
redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceSn());
}
}
@Override
public DayCardOrderEntity getByOrderNo(String orderNo) {
QueryWrapper<DayCardOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("order_no", orderNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
return baseMapper.selectOne(wrapper);
}
@Override
public DayCardPoolEntity getDeviceBenefits(Long deviceId) {
QueryWrapper<DayCardPoolEntity> wrapper = new QueryWrapper<>();
wrapper.eq("device_id", deviceId)
.eq("status", DayCardConstants.POOL_STATUS_USED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.isNotNull("expire_at")
.gt("expire_at", new Date())
.orderByDesc("used_at")
.last("LIMIT 1");
return dayCardPoolDao.selectOne(wrapper);
}
@Override
public DayCardPriceConfigEntity getDevicePrice(Long deviceId) {
DeviceOrgContext orgContext = deviceResolveService.resolveDevice(deviceId);
DeviceEntity device = orgContext.getDevice();
Long storeOrgId = device.getStoreOrgId();
Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null;
return dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId);
}
@Override
public void unbindDevice(Long deviceId) {
if (deviceId == null) {
throw new ASException("设备ID不能为空");
}
DayCardPoolEntity pool = getDeviceBenefits(deviceId);
if (pool == null) {
throw new ASException("设备没有有效的天卡权益,无需解绑");
}
UpdateWrapper<DayCardPoolEntity> update = new UpdateWrapper<>();
update.eq("id", pool.getId())
.eq("status", DayCardConstants.POOL_STATUS_USED)
.set("status", DayCardConstants.POOL_STATUS_REUSABLE)
.set("device_id", null)
.set("updated_at", new Date());
int updated = dayCardPoolDao.update(null, update);
if (updated == 0) {
throw new ASException("解绑失败,卡状态可能已变更");
}
logger.info("设备解绑成功: deviceId={}, poolId={}, cardCode={}, expireAt={}",
deviceId, pool.getId(), pool.getCardCode(), pool.getExpireAt());
}
@Override
public void unbindDeviceOrg(Long deviceId) {
if (deviceId == null) {
throw new ASException("设备ID不能为空");
}
Date now = new Date();
// 1. 释放设备当前天卡权益(若有):USED -> REUSABLE,清卡上的 device_id
DayCardPoolEntity pool = getDeviceBenefits(deviceId);
if (pool != null) {
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("id", pool.getId())
.eq("status", DayCardConstants.POOL_STATUS_USED)
.set("status", DayCardConstants.POOL_STATUS_REUSABLE)
.set("device_id", null)
.set("updated_at", now);
dayCardPoolDao.update(null, poolUpdate);
logger.info("设备组织解绑-释放天卡权益: deviceId={}, poolId={}, cardCode={}",
deviceId, pool.getId(), pool.getCardCode());
}
// 2. 清除设备的门店组织关联(storeOrgId),设备可在新场所用新激活码重新绑定
UpdateWrapper<DeviceEntity> deviceUpdate = new UpdateWrapper<>();
deviceUpdate.eq("id", deviceId)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("store_org_id", null)
.set("updated_at", now);
int updated = deviceDao.update(null, deviceUpdate);
if (updated == 0) {
throw new ASException("设备不存在或解绑失败: " + deviceId);
}
// 3. 作废当前 sign,App 端旧 sign 立即失效,被迫回到激活码输入页(逆向 init)
apiDeviceSignService.invalidate(deviceId);
logger.info("设备组织解绑成功: deviceId={}", deviceId);
}
@Override
public int retryFailedActivations() {
// 查询激活失败的卡(status=USED, activate_status=FAILED)
QueryWrapper<DayCardPoolEntity> wrapper = new QueryWrapper<>();
wrapper.eq("status", DayCardConstants.POOL_STATUS_USED)
.eq("activate_status", DayCardConstants.ACTIVATE_STATUS_FAILED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT " + cancelBatchSize);
List<DayCardPoolEntity> failedPools = dayCardPoolDao.selectList(wrapper);
int retried = 0;
for (DayCardPoolEntity pool : failedPools) {
try {
// 查询关联订单
if (StringUtils.isBlank(pool.getOrderNo())) {
continue;
}
DayCardOrderEntity order = getByOrderNo(pool.getOrderNo());
if (order == null) {
continue;
}
// 重置激活状态为 PENDING
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_PENDING, null, null);
// 通过 selfProxy 调用确保 @Async 生效
selfProxy.asyncActivateCard(order);
retried++;
} catch (Exception e) {
logger.error("重试激活天卡失败: poolId={}, orderNo={}", pool.getId(), pool.getOrderNo(), e);
}
}
if (retried > 0) {
logger.info("本次重试激活失败天卡 {} 张", retried);
}
return retried;
}
/**
* 生成订单号:yyyyMMddHHmmss + 6位随机数
*/
private String generateOrderNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timestamp = sdf.format(new Date());
int random = ThreadLocalRandom.current().nextInt(100000, 1000000);
return timestamp + random;
}
}
@@ -0,0 +1,207 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardImportBatchDao;
import io.as.modules.daycard.dao.DayCardPoolDao;
import io.as.modules.daycard.entity.DayCardImportBatchEntity;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.service.DayCardPoolService;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
/**
* 天卡池 Service 实现
*/
@Service("dayCardPoolService")
public class DayCardPoolServiceImpl extends ServiceImpl<DayCardPoolDao, DayCardPoolEntity> implements DayCardPoolService {
private static final Logger logger = LoggerFactory.getLogger(DayCardPoolServiceImpl.class);
private static final DateTimeFormatter CARD_NO_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter THIRD_PARTY_REF_HHMMSS = DateTimeFormatter.ofPattern("HHmmss");
private static final String THIRD_CARD_NO_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final int THIRD_CARD_NO_LENGTH = 24;
private static final String THIRD_CARD_NO_PREFIX = "D";
private static final SecureRandom THIRD_CARD_NO_RANDOM = new SecureRandom();
private static final int MAX_RETRY = 10;
@Autowired
private DayCardImportBatchDao dayCardImportBatchDao;
@Override
public PageUtils queryPage(Map<String, Object> params, Long partnerId) {
IPage<DayCardPoolEntity> page = new Query<DayCardPoolEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardPoolEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
if (partnerId != null) {
wrapper.eq("partner_id", partnerId);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
String cardCode = (String) params.get("cardCode");
if (StringUtils.isNotBlank(cardCode)) {
wrapper.eq("card_code", cardCode);
}
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("order_no", orderNo);
}
IPage<DayCardPoolEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public int getAvailableCount(Long partnerId) {
QueryWrapper<DayCardPoolEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("status", DayCardConstants.POOL_STATUS_UNUSED);
if (partnerId != null) {
wrapper.eq("partner_id", partnerId);
}
return baseMapper.selectCount(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int importCards(List<String> cardCodes, Long partnerId) {
if (cardCodes == null || cardCodes.isEmpty()) {
return 0;
}
Date now = new Date();
String batchNo = "DC" + now.getTime();
int successCount = 0;
// 创建导入批次
DayCardImportBatchEntity batch = new DayCardImportBatchEntity();
batch.setBatchNo(batchNo);
batch.setPartnerId(partnerId);
batch.setStatus(DayCardConstants.BATCH_STATUS_PROCESSING);
batch.setTotalCount(cardCodes.size());
batch.setSuccessCount(0);
batch.setFailCount(0);
batch.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
batch.setCreatedAt(now);
batch.setUpdatedAt(now);
dayCardImportBatchDao.insert(batch);
Set<String> thirdCardNoReserved = new HashSet<>();
List<DayCardPoolEntity> toInsert = new ArrayList<>();
for (String code : cardCodes) {
String trimmed = code == null ? "" : code.trim();
if (trimmed.isEmpty()) {
continue;
}
// 查重:同一合作商下 cardCode 不重复
long cnt = this.lambdaQuery()
.eq(DayCardPoolEntity::getCardCode, trimmed)
.eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.count();
if (cnt > 0) {
logger.warn("导入天卡跳过重复卡密: cardCode={}", trimmed);
continue;
}
DayCardPoolEntity pool = new DayCardPoolEntity();
pool.setCardCode(trimmed);
pool.setThirdPartyRef(generateUniqueThirdPartyRef());
pool.setThirdCardNo(generateThirdCardNo(thirdCardNoReserved));
pool.setCardType(DayCardConstants.CARD_TYPE_DAY);
pool.setPartnerId(partnerId);
pool.setStatus(DayCardConstants.POOL_STATUS_UNUSED);
pool.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
pool.setCreatedAt(now);
pool.setUpdatedAt(now);
toInsert.add(pool);
successCount++;
}
if (!toInsert.isEmpty()) {
// MyBatis-Plus saveBatch
this.saveBatch(toInsert);
}
// 更新批次状态
batch.setStatus(DayCardConstants.BATCH_STATUS_SUCCEEDED);
batch.setSuccessCount(successCount);
batch.setFailCount(cardCodes.size() - successCount);
batch.setUpdatedAt(new Date());
dayCardImportBatchDao.updateById(batch);
logger.info("天卡导入完成: batchNo={}, total={}, success={}", batchNo, cardCodes.size(), successCount);
return successCount;
}
@Override
public String generateUniqueThirdPartyRef() {
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
LocalDateTime n = LocalDateTime.now();
String ref = n.format(CARD_NO_DATE) + "-" + n.format(THIRD_PARTY_REF_HHMMSS) + "-"
+ String.format("%04d", rnd.nextInt(10000));
long cnt = this.lambdaQuery()
.eq(DayCardPoolEntity::getThirdPartyRef, ref)
.eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.count();
if (cnt == 0) {
return ref;
}
}
throw new RuntimeException("生成第三方引用号失败,请稍后重试");
}
@Override
public String generateThirdCardNo() {
return generateThirdCardNo(null);
}
private String generateThirdCardNo(Set<String> inFlightReserved) {
int alphaLen = THIRD_CARD_NO_ALPHABET.length();
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
StringBuilder sb = new StringBuilder(THIRD_CARD_NO_PREFIX.length() + THIRD_CARD_NO_LENGTH);
sb.append(THIRD_CARD_NO_PREFIX);
for (int i = 0; i < THIRD_CARD_NO_LENGTH; i++) {
sb.append(THIRD_CARD_NO_ALPHABET.charAt(THIRD_CARD_NO_RANDOM.nextInt(alphaLen)));
}
String third = sb.toString();
if (inFlightReserved != null && inFlightReserved.contains(third)) {
continue;
}
long cnt = this.lambdaQuery()
.eq(DayCardPoolEntity::getThirdCardNo, third)
.eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL)
.count();
if (cnt == 0) {
if (inFlightReserved != null) {
inFlightReserved.add(third);
}
return third;
}
}
throw new RuntimeException("生成第三方卡号失败,请稍后重试");
}
}
@@ -0,0 +1,96 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardPriceConfigDao;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import io.as.modules.daycard.service.DayCardPriceConfigService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Map;
/**
* 天卡定价策略 Service 实现
*/
@Service("dayCardPriceConfigService")
public class DayCardPriceConfigServiceImpl extends ServiceImpl<DayCardPriceConfigDao, DayCardPriceConfigEntity> implements DayCardPriceConfigService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardPriceConfigEntity> page = new Query<DayCardPriceConfigEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardPriceConfigEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String partnerId = (String) params.get("partnerId");
if (StringUtils.isNotBlank(partnerId)) {
wrapper.eq("partner_id", partnerId);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
String channelOrgId = (String) params.get("channelOrgId");
if (StringUtils.isNotBlank(channelOrgId)) {
wrapper.eq("channel_org_id", channelOrgId);
}
String storeOrgId = (String) params.get("storeOrgId");
if (StringUtils.isNotBlank(storeOrgId)) {
wrapper.eq("store_org_id", storeOrgId);
}
IPage<DayCardPriceConfigEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardPriceConfigEntity getEffectivePrice(Long channelOrgId, Long storeOrgId) {
// 1. 门店配置价(最优)
if (storeOrgId != null) {
QueryWrapper<DayCardPriceConfigEntity> storeWrapper = new QueryWrapper<>();
storeWrapper.eq("store_org_id", storeOrgId)
.eq("status", "ACTIVE")
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT 1");
DayCardPriceConfigEntity storePrice = baseMapper.selectOne(storeWrapper);
if (storePrice != null) {
return storePrice;
}
}
// 2. 渠道配置价(次优)
if (channelOrgId != null) {
QueryWrapper<DayCardPriceConfigEntity> channelWrapper = new QueryWrapper<>();
channelWrapper.isNull("store_org_id")
.eq("channel_org_id", channelOrgId)
.eq("status", "ACTIVE")
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT 1");
DayCardPriceConfigEntity channelPrice = baseMapper.selectOne(channelWrapper);
if (channelPrice != null) {
return channelPrice;
}
}
// 3. 平台默认价兜底(store_org_id、channel_org_id 均为空)
QueryWrapper<DayCardPriceConfigEntity> defaultWrapper = new QueryWrapper<>();
defaultWrapper.isNull("store_org_id")
.isNull("channel_org_id")
.eq("status", "ACTIVE")
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT 1");
return baseMapper.selectOne(defaultWrapper);
}
@Override
public BigDecimal getPrice(Long channelOrgId, Long storeOrgId) {
DayCardPriceConfigEntity config = getEffectivePrice(channelOrgId, storeOrgId);
return config != null ? config.getPrice() : null;
}
}
@@ -0,0 +1,107 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardProfitReceiverDao;
import io.as.modules.daycard.entity.DayCardProfitReceiverEntity;
import io.as.modules.daycard.service.DayCardProfitReceiverService;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 天卡分账接收方 Service 实现
*/
@Service("dayCardProfitReceiverService")
public class DayCardProfitReceiverServiceImpl extends ServiceImpl<DayCardProfitReceiverDao, DayCardProfitReceiverEntity> implements DayCardProfitReceiverService {
private static final Logger logger = LoggerFactory.getLogger(DayCardProfitReceiverServiceImpl.class);
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardProfitReceiverEntity> page = new Query<DayCardProfitReceiverEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardProfitReceiverEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String partnerId = (String) params.get("partnerId");
if (StringUtils.isNotBlank(partnerId)) {
wrapper.eq("partner_id", partnerId);
}
String receiverType = (String) params.get("receiverType");
if (StringUtils.isNotBlank(receiverType)) {
wrapper.eq("receiver_type", receiverType);
}
String wxBindStatus = (String) params.get("wxBindStatus");
if (StringUtils.isNotBlank(wxBindStatus)) {
wrapper.eq("wx_bind_status", wxBindStatus);
}
IPage<DayCardProfitReceiverEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardProfitReceiverEntity getById(Long id) {
return baseMapper.selectById(id);
}
@Override
public void saveReceiver(DayCardProfitReceiverEntity receiver) {
Date now = new Date();
receiver.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
if (receiver.getWxBindStatus() == null) {
receiver.setWxBindStatus(DayCardConstants.WX_BIND_STATUS_UNBOUND);
}
receiver.setCreatedAt(now);
receiver.setUpdatedAt(now);
baseMapper.insert(receiver);
}
@Override
public void updateReceiver(DayCardProfitReceiverEntity receiver) {
receiver.setUpdatedAt(new Date());
baseMapper.updateById(receiver);
}
@Override
public void delete(Long[] ids) {
if (ids == null || ids.length == 0) {
return;
}
List<DayCardProfitReceiverEntity> list = new ArrayList<>();
Date now = new Date();
for (Long id : ids) {
DayCardProfitReceiverEntity entity = new DayCardProfitReceiverEntity();
entity.setId(id);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
entity.setUpdatedAt(now);
list.add(entity);
}
this.updateBatchById(list);
}
@Override
public void syncToWechat(Long id) {
DayCardProfitReceiverEntity receiver = baseMapper.selectById(id);
if (receiver == null) {
return;
}
// 占位方法:记录日志,设置绑定状态
logger.info("同步分账接收方到微信: id={}, receiverAccount={}", id, receiver.getReceiverAccount());
// TODO: 实际微信API待对接
receiver.setWxBindStatus(DayCardConstants.WX_BIND_STATUS_BINDED);
receiver.setUpdatedAt(new Date());
baseMapper.updateById(receiver);
}
}
@@ -0,0 +1,156 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardProfitRuleDao;
import io.as.modules.daycard.entity.DayCardProfitRuleEntity;
import io.as.modules.daycard.service.DayCardProfitRuleService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 天卡分润规则 Service 实现
*/
@Service("dayCardProfitRuleService")
public class DayCardProfitRuleServiceImpl extends ServiceImpl<DayCardProfitRuleDao, DayCardProfitRuleEntity> implements DayCardProfitRuleService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardProfitRuleEntity> page = new Query<DayCardProfitRuleEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardProfitRuleEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String partnerId = (String) params.get("partnerId");
if (StringUtils.isNotBlank(partnerId)) {
wrapper.eq("partner_id", partnerId);
}
String channelOrgId = (String) params.get("channelOrgId");
if (StringUtils.isNotBlank(channelOrgId)) {
wrapper.eq("channel_org_id", channelOrgId);
}
String storeOrgId = (String) params.get("storeOrgId");
if (StringUtils.isNotBlank(storeOrgId)) {
wrapper.eq("store_org_id", storeOrgId);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardProfitRuleEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardProfitRuleEntity getById(Long id) {
return baseMapper.selectById(id);
}
@Override
public void saveRule(DayCardProfitRuleEntity rule) {
validateShareRatio(rule);
Date now = new Date();
rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
rule.setCreatedAt(now);
rule.setUpdatedAt(now);
baseMapper.insert(rule);
}
@Override
public void updateRule(DayCardProfitRuleEntity rule) {
validateShareRatio(rule);
rule.setUpdatedAt(new Date());
baseMapper.updateById(rule);
}
@Override
public void delete(Long[] ids) {
if (ids == null || ids.length == 0) {
return;
}
List<DayCardProfitRuleEntity> list = new ArrayList<>();
Date now = new Date();
for (Long id : ids) {
DayCardProfitRuleEntity entity = new DayCardProfitRuleEntity();
entity.setId(id);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
entity.setUpdatedAt(now);
list.add(entity);
}
this.updateBatchById(list);
}
@Override
public List<DayCardProfitRuleEntity> matchRules(Long partnerId, Long channelOrgId, Long storeOrgId) {
// 1. 优先查门店级匹配的规则
if (storeOrgId != null && channelOrgId != null) {
QueryWrapper<DayCardProfitRuleEntity> storeWrapper = new QueryWrapper<>();
storeWrapper.eq("partner_id", partnerId)
.eq("channel_org_id", channelOrgId)
.eq("store_org_id", storeOrgId)
.eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.orderByDesc("priority");
List<DayCardProfitRuleEntity> storeRules = baseMapper.selectList(storeWrapper);
if (!storeRules.isEmpty()) {
return storeRules;
}
}
// 2. 无则查渠道级匹配的规则(store_org_id 为 null)
if (channelOrgId != null) {
QueryWrapper<DayCardProfitRuleEntity> channelWrapper = new QueryWrapper<>();
channelWrapper.eq("partner_id", partnerId)
.eq("channel_org_id", channelOrgId)
.isNull("store_org_id")
.eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.orderByDesc("priority");
List<DayCardProfitRuleEntity> channelRules = baseMapper.selectList(channelWrapper);
if (!channelRules.isEmpty()) {
return channelRules;
}
}
// 3. 无则查合作商级匹配的规则(channel_org_id 和 store_org_id 都为 null)
QueryWrapper<DayCardProfitRuleEntity> partnerWrapper = new QueryWrapper<>();
partnerWrapper.eq("partner_id", partnerId)
.isNull("channel_org_id")
.isNull("store_org_id")
.eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.orderByDesc("priority");
return baseMapper.selectList(partnerWrapper);
}
/**
* 校验分润比例不超过 30%
*/
private void validateShareRatio(DayCardProfitRuleEntity rule) {
if (rule.getShareRatio() == null) {
throw new ASException("分润比例不能为空");
}
if (DayCardConstants.SHARE_TYPE_FIXED.equals(rule.getShareType())) {
// 固定金额类型,不校验比例
return;
}
if (rule.getShareRatio().compareTo(DayCardConstants.SHARE_RATIO_LIMIT) > 0) {
throw new ASException("分润比例不能超过30%");
}
if (rule.getShareRatio().compareTo(BigDecimal.ZERO) <= 0) {
throw new ASException("分润比例必须大于0");
}
}
}
@@ -0,0 +1,301 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardOrderDao;
import io.as.modules.daycard.dao.DayCardPoolDao;
import io.as.modules.daycard.dao.DayCardRefundDao;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import io.as.modules.daycard.service.DayCardRefundService;
import io.as.modules.payment.service.WechatPayResult;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 天卡退款 Service 实现
*/
@Service("dayCardRefundService")
public class DayCardRefundServiceImpl extends ServiceImpl<DayCardRefundDao, DayCardRefundEntity> implements DayCardRefundService {
private static final Logger logger = LoggerFactory.getLogger(DayCardRefundServiceImpl.class);
@Autowired
private DayCardOrderDao dayCardOrderDao;
@Autowired
private DayCardPoolDao dayCardPoolDao;
@Autowired
private io.as.modules.daycard.dao.ProfitSharingOrderDao profitSharingOrderDao;
@Autowired
private io.as.modules.daycard.dao.ProfitSharingDetailDao profitSharingDetailDao;
@Autowired
@org.springframework.context.annotation.Lazy
private io.as.modules.payment.service.WechatPayService wechatPayService;
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardRefundEntity> page = new Query<DayCardRefundEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardRefundEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String refundNo = (String) params.get("refundNo");
if (StringUtils.isNotBlank(refundNo)) {
wrapper.eq("refund_no", refundNo);
}
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardRefundEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardRefundEntity getByRefundNo(String refundNo) {
QueryWrapper<DayCardRefundEntity> wrapper = new QueryWrapper<>();
wrapper.eq("refund_no", refundNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
return baseMapper.selectOne(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public DayCardRefundEntity refund(String orderNo, String reason) {
if (StringUtils.isBlank(orderNo)) {
throw new ASException("订单号不能为空");
}
Date now = new Date();
// 1. 查询订单,校验 status=PAID
QueryWrapper<DayCardOrderEntity> orderWrapper = new QueryWrapper<>();
orderWrapper.eq("order_no", orderNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
DayCardOrderEntity order = dayCardOrderDao.selectOne(orderWrapper);
if (order == null) {
throw new ASException("订单不存在: " + orderNo);
}
if (!DayCardConstants.ORDER_STATUS_PAID.equals(order.getStatus())) {
throw new ASException("订单状态不允许退款,当前状态: " + order.getStatus());
}
// 2. 退款金额:优先取 payAmount,为空则用 amount
BigDecimal refundAmount = order.getPayAmount() != null ? order.getPayAmount() : order.getAmount();
if (refundAmount == null || refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ASException("退款金额无效");
}
// 3. 生成退款单号(REFUND + yyyyMMddHHmmss + 6位随机)
String refundNo = generateRefundNo();
// 4. 创建退款记录 status=PENDING
DayCardRefundEntity refund = new DayCardRefundEntity();
refund.setRefundNo(refundNo);
refund.setOrderNo(orderNo);
refund.setRefundAmount(refundAmount);
refund.setReason(reason);
refund.setStatus(DayCardConstants.REFUND_STATUS_PENDING);
refund.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
refund.setCreatedAt(now);
refund.setUpdatedAt(now);
baseMapper.insert(refund);
try {
// 5. 更新订单状态为 REFUNDING
UpdateWrapper<DayCardOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("id", order.getId())
.eq("status", DayCardConstants.ORDER_STATUS_PAID)
.set("status", DayCardConstants.ORDER_STATUS_REFUNDING)
.set("updated_at", now);
int updated = dayCardOrderDao.update(null, orderUpdate);
if (updated == 0) {
throw new ASException("订单状态已变更,退款失败");
}
// 6. 释放卡:USED -> UNUSED,清除 device_id, order_no, expire_at, activate_status
UpdateWrapper<DayCardPoolEntity> poolUpdate = new UpdateWrapper<>();
poolUpdate.eq("order_no", orderNo)
.eq("status", DayCardConstants.POOL_STATUS_USED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.set("status", DayCardConstants.POOL_STATUS_UNUSED)
.set("order_no", null)
.set("device_id", null)
.set("expire_at", null)
.set("activate_status", null)
.set("updated_at", now);
dayCardPoolDao.update(null, poolUpdate);
logger.info("退款释放卡成功: orderNo={}", orderNo);
// 7. 尝试回退分账(设置关联分账单状态为 CLOSED)
rollbackSettleRecords(orderNo, now);
// 8. 调用微信退款(Mock支付则跳过)
String payNo = order.getPayNo();
if (isWechatPayment(payNo)) {
BigDecimal totalAmount = order.getAmount();
WechatPayResult refundResult = wechatPayService.refund(orderNo, refundNo, refundAmount, totalAmount, reason);
if (!refundResult.isSuccess()) {
throw new ASException("微信退款失败: " + refundResult.getErrorMsg());
}
logger.info("微信退款成功: orderNo={}, refundNo={}", orderNo, refundNo);
} else {
logger.info("Mock支付退款,无需调用微信接口: orderNo={}, refundNo={}", orderNo, refundNo);
}
// 9. 更新退款记录 status=SUCCESS
refund.setStatus(DayCardConstants.REFUND_STATUS_SUCCESS);
refund.setSettledAt(new Date());
refund.setUpdatedAt(new Date());
baseMapper.updateById(refund);
logger.info("退款成功: orderNo={}, refundNo={}, refundAmount={}", orderNo, refundNo, refundAmount);
return refund;
} catch (Exception e) {
logger.error("退款处理失败: orderNo={}, refundNo={}", orderNo, refundNo, e);
// 更新退款记录为 FAILED
String errorMsg = e.getMessage();
if (errorMsg != null && errorMsg.length() > 450) {
errorMsg = errorMsg.substring(0, 450);
}
refund.setStatus(DayCardConstants.REFUND_STATUS_FAILED);
refund.setErrorMessage(errorMsg);
refund.setUpdatedAt(new Date());
baseMapper.updateById(refund);
throw e;
}
}
@Override
public void updateStatus(Long id, String status, String errorMessage) {
UpdateWrapper<DayCardRefundEntity> update = new UpdateWrapper<>();
update.eq("id", id)
.set("status", status)
.set("error_message", errorMessage)
.set("updated_at", new Date());
if (DayCardConstants.REFUND_STATUS_SUCCESS.equals(status)
|| DayCardConstants.REFUND_STATUS_CLOSED.equals(status)) {
update.set("settled_at", new Date());
}
baseMapper.update(null, update);
}
/**
* 回退分账:查询订单关联的分账单,逐方回退已分账金额
*/
private void rollbackSettleRecords(String orderNo, Date now) {
try {
// 查询关联的分账单
QueryWrapper<ProfitSharingOrderEntity> orderWrapper = new QueryWrapper<>();
orderWrapper.eq("pay_order_no", orderNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
ProfitSharingOrderEntity sharingOrder = profitSharingOrderDao.selectOne(orderWrapper);
if (sharingOrder == null) {
logger.info("无关联分账单,跳过分账回退: orderNo={}", orderNo);
return;
}
// 查询分账明细
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", sharingOrder.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
if (details == null || details.isEmpty()) {
logger.info("无分账明细,跳过分账回退: orderNo={}", orderNo);
return;
}
// 逐方回退已成功的分账
for (ProfitSharingDetailEntity detail : details) {
if (!DayCardConstants.SETTLE_STATUS_SUCCESS.equals(detail.getStatus())) {
continue;
}
if (detail.getReceiverAccount() == null || detail.getAmount() == null) {
continue;
}
// 调用微信分账回退 API
String outReturnNo = "RT" + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(1000, 9999);
WechatPayResult returnResult = wechatPayService.profitSharingReturn(
sharingOrder.getOutOrderNo(),
detail.getReceiverAccount(),
detail.getAmount(),
outReturnNo);
// 更新明细状态
UpdateWrapper<ProfitSharingDetailEntity> detailUpdate = new UpdateWrapper<>();
detailUpdate.eq("id", detail.getId());
if (returnResult.isSuccess()) {
detailUpdate.set("status", DayCardConstants.REFUND_STATUS_CLOSED)
.set("updated_at", now);
} else {
detailUpdate.set("fail_reason", returnResult.getErrorMsg())
.set("updated_at", now);
}
profitSharingDetailDao.update(null, detailUpdate);
}
// 更新分账单状态为 CLOSED
UpdateWrapper<ProfitSharingOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.REFUND_STATUS_CLOSED)
.set("updated_at", now);
profitSharingOrderDao.update(null, orderUpdate);
logger.info("分账回退完成: orderNo={}", orderNo);
} catch (Exception e) {
logger.warn("分账回退失败,跳过: orderNo={}", orderNo, e);
}
}
/**
* 判断是否为微信支付
* <p>Mock 支付的 payNo 以 "MOCK_" 开头,其余视为微信支付</p>
*
* @param payNo 支付单号
* @return true=微信支付
*/
private boolean isWechatPayment(String payNo) {
if (StringUtils.isBlank(payNo)) {
// 没有支付单号,默认视为微信支付
return true;
}
return !payNo.startsWith(DayCardConstants.PAY_CHANNEL_MOCK + "_");
}
/**
* 生成退款单号:REFUND + yyyyMMddHHmmss + 6位随机数
*/
private String generateRefundNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timestamp = sdf.format(new Date());
int random = ThreadLocalRandom.current().nextInt(100000, 1000000);
return "REFUND" + timestamp + random;
}
}
@@ -0,0 +1,100 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardSettleOrderDao;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import io.as.modules.daycard.service.DayCardSettleOrderService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 天卡分账单 Service 实现
*/
@Service("dayCardSettleOrderService")
public class DayCardSettleOrderServiceImpl extends ServiceImpl<DayCardSettleOrderDao, DayCardSettleOrderEntity> implements DayCardSettleOrderService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardSettleOrderEntity> page = new Query<DayCardSettleOrderEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardSettleOrderEntity> wrapper = new QueryWrapper<>();
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardSettleOrderEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardSettleOrderEntity getBySettleNo(String settleNo) {
QueryWrapper<DayCardSettleOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("settle_no", settleNo);
return baseMapper.selectOne(wrapper);
}
@Override
public void saveSettleOrder(DayCardSettleOrderEntity entity) {
Date now = new Date();
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
baseMapper.insert(entity);
}
@Override
public void updateStatus(Long id, String status, String errorMessage) {
UpdateWrapper<DayCardSettleOrderEntity> update = new UpdateWrapper<>();
update.eq("id", id)
.set("status", status)
.set("error_message", errorMessage)
.set("updated_at", new Date());
if (DayCardConstants.SETTLE_STATUS_SUCCESS.equals(status)) {
update.set("settled_at", new Date());
}
baseMapper.update(null, update);
}
@Override
public List<DayCardSettleOrderEntity> getByOrderNo(String orderNo) {
QueryWrapper<DayCardSettleOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("order_no", orderNo)
.orderByDesc("created_at");
return baseMapper.selectList(wrapper);
}
@Override
public int retryFailed() {
QueryWrapper<DayCardSettleOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("status", DayCardConstants.SETTLE_STATUS_FAILED)
.last("LIMIT 200");
List<DayCardSettleOrderEntity> failedList = baseMapper.selectList(wrapper);
int retried = 0;
for (DayCardSettleOrderEntity settleOrder : failedList) {
try {
// 重置状态为 PENDING,等待重新执行
updateStatus(settleOrder.getId(), DayCardConstants.SETTLE_STATUS_PENDING, null);
retried++;
} catch (Exception e) {
// 忽略单条重置失败
}
}
return retried;
}
}
@@ -0,0 +1,54 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.dao.DayCardSettleRecordDao;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import io.as.modules.daycard.service.DayCardSettleRecordService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Map;
/**
* 天卡分账记录 Service 实现
*/
@Service("dayCardSettleRecordService")
public class DayCardSettleRecordServiceImpl extends ServiceImpl<DayCardSettleRecordDao, DayCardSettleRecordEntity> implements DayCardSettleRecordService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardSettleRecordEntity> page = new Query<DayCardSettleRecordEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardSettleRecordEntity> wrapper = new QueryWrapper<>();
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardSettleRecordEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardSettleRecordEntity getById(Long id) {
return baseMapper.selectById(id);
}
@Override
public void saveRecord(DayCardSettleRecordEntity record) {
Date now = new Date();
record.setCreatedAt(now);
record.setUpdatedAt(now);
baseMapper.insert(record);
}
}
@@ -0,0 +1,113 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardSettleRuleDao;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import io.as.modules.daycard.service.DayCardSettleRuleService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 天卡分账规则 Service 实现
*/
@Service("dayCardSettleRuleService")
public class DayCardSettleRuleServiceImpl extends ServiceImpl<DayCardSettleRuleDao, DayCardSettleRuleEntity> implements DayCardSettleRuleService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<DayCardSettleRuleEntity> page = new Query<DayCardSettleRuleEntity>().getPage(params, "created_at", false);
QueryWrapper<DayCardSettleRuleEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String partnerId = (String) params.get("partnerId");
if (StringUtils.isNotBlank(partnerId)) {
wrapper.eq("partner_id", partnerId);
}
String channelOrgId = (String) params.get("channelOrgId");
if (StringUtils.isNotBlank(channelOrgId)) {
wrapper.eq("channel_org_id", channelOrgId);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<DayCardSettleRuleEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public DayCardSettleRuleEntity getById(Long id) {
return baseMapper.selectById(id);
}
@Override
public void saveRule(DayCardSettleRuleEntity rule) {
Date now = new Date();
rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
rule.setCreatedAt(now);
rule.setUpdatedAt(now);
baseMapper.insert(rule);
}
@Override
public void update(DayCardSettleRuleEntity rule) {
rule.setUpdatedAt(new Date());
baseMapper.updateById(rule);
}
@Override
public void delete(Long[] ids) {
if (ids == null || ids.length == 0) {
return;
}
List<DayCardSettleRuleEntity> list = new ArrayList<>();
Date now = new Date();
for (Long id : ids) {
DayCardSettleRuleEntity entity = new DayCardSettleRuleEntity();
entity.setId(id);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
entity.setUpdatedAt(now);
list.add(entity);
}
this.updateBatchById(list);
}
@Override
public List<DayCardSettleRuleEntity> getRulesByOrder(Long partnerId, Long channelOrgId, Long storeOrgId) {
QueryWrapper<DayCardSettleRuleEntity> wrapper = new QueryWrapper<>();
wrapper.eq("partner_id", partnerId)
.eq("status", "ACTIVE")
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
// 门店级规则(store_org_id 不为空且匹配)
if (storeOrgId != null) {
QueryWrapper<DayCardSettleRuleEntity> storeWrapper = new QueryWrapper<>();
storeWrapper.eq("partner_id", partnerId)
.eq("channel_org_id", channelOrgId)
.eq("store_org_id", storeOrgId)
.eq("status", "ACTIVE")
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
List<DayCardSettleRuleEntity> storeRules = baseMapper.selectList(storeWrapper);
if (!storeRules.isEmpty()) {
return storeRules;
}
}
// 渠道级规则(store_org_id 为空)
wrapper.eq("channel_org_id", channelOrgId)
.isNull("store_org_id");
return baseMapper.selectList(wrapper);
}
}
@@ -0,0 +1,188 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.OrgSettlementAccountDao;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import io.as.modules.daycard.service.OrgSettlementAccountService;
import io.as.modules.payment.service.WechatPayResult;
import io.as.modules.payment.service.WechatPayService;
import io.as.modules.sys.dao.SysOrgDao;
import io.as.modules.sys.entity.SysOrgEntity;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 组织分账接收方 Service 实现
*/
@Service("orgSettlementAccountService")
public class OrgSettlementAccountServiceImpl extends ServiceImpl<OrgSettlementAccountDao, OrgSettlementAccountEntity> implements OrgSettlementAccountService {
private static final Logger logger = LoggerFactory.getLogger(OrgSettlementAccountServiceImpl.class);
@Autowired
private SysOrgDao sysOrgDao;
@Autowired
private io.as.modules.payment.service.WechatPayService wechatPayService;
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<OrgSettlementAccountEntity> page = new Query<OrgSettlementAccountEntity>().getPage(params, "created_at", false);
QueryWrapper<OrgSettlementAccountEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String orgId = (String) params.get("orgId");
if (StringUtils.isNotBlank(orgId)) {
wrapper.eq("org_id", orgId);
}
String bindStatus = (String) params.get("bindStatus");
if (StringUtils.isNotBlank(bindStatus)) {
wrapper.eq("bind_status", bindStatus);
}
String receiverType = (String) params.get("receiverType");
if (StringUtils.isNotBlank(receiverType)) {
wrapper.eq("receiver_type", receiverType);
}
IPage<OrgSettlementAccountEntity> result = baseMapper.selectPage(page, wrapper);
// 填充组织名称
for (OrgSettlementAccountEntity entity : result.getRecords()) {
if (entity.getOrgId() != null) {
SysOrgEntity org = sysOrgDao.selectById(entity.getOrgId());
if (org != null) {
entity.setOrgName(org.getName());
}
}
}
return new PageUtils(result);
}
@Override
public OrgSettlementAccountEntity getById(Long id) {
OrgSettlementAccountEntity entity = baseMapper.selectById(id);
if (entity != null && entity.getOrgId() != null) {
SysOrgEntity org = sysOrgDao.selectById(entity.getOrgId());
if (org != null) {
entity.setOrgName(org.getName());
}
}
return entity;
}
@Override
public void saveAccount(OrgSettlementAccountEntity account) {
Date now = new Date();
account.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
if (account.getBindStatus() == null) {
account.setBindStatus(DayCardConstants.BIND_STATUS_PENDING);
}
if (account.getPayChannel() == null) {
account.setPayChannel(DayCardConstants.PAY_CHANNEL_WECHAT);
}
account.setCreatedAt(now);
account.setUpdatedAt(now);
// 调用微信添加分账接收方
WechatPayResult addResult = wechatPayService.addReceiver(
account.getReceiverType(),
account.getReceiverAccount(),
account.getReceiverName(),
"STORE");
if (addResult.isSuccess()) {
account.setBindStatus(DayCardConstants.BIND_STATUS_ACTIVE);
account.setExternalBindId(addResult.getPayNo());
} else {
account.setBindStatus(DayCardConstants.BIND_STATUS_FAILED);
logger.warn("微信添加分账接收方失败: account={}, error={}", account.getReceiverAccount(), addResult.getErrorMsg());
}
baseMapper.insert(account);
}
@Override
public void delete(Long[] ids) {
if (ids == null || ids.length == 0) {
return;
}
// 查询待删除的实体列表
List<OrgSettlementAccountEntity> list = new ArrayList<>();
Date now = new Date();
for (Long id : ids) {
OrgSettlementAccountEntity entity = new OrgSettlementAccountEntity();
entity.setId(id);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
entity.setUpdatedAt(now);
list.add(entity);
}
// 调用微信删除分账接收方
for (OrgSettlementAccountEntity entity : list) {
// 先查询完整的实体信息
OrgSettlementAccountEntity full = baseMapper.selectById(entity.getId());
if (full != null && full.getReceiverAccount() != null) {
WechatPayResult delResult = wechatPayService.deleteReceiver(
full.getReceiverType(), full.getReceiverAccount());
if (!delResult.isSuccess()) {
logger.warn("微信删除分账接收方失败: account={}, error={}",
full.getReceiverAccount(), delResult.getErrorMsg());
}
}
}
this.updateBatchById(list);
}
@Override
public void syncToWechat(Long id) {
OrgSettlementAccountEntity account = baseMapper.selectById(id);
if (account == null) {
return;
}
logger.info("同步分账接收方到微信: id={}, orgId={}, receiverAccount={}", id, account.getOrgId(), account.getReceiverAccount());
WechatPayResult result = wechatPayService.addReceiver(
account.getReceiverType(),
account.getReceiverAccount(),
account.getReceiverName(),
"STORE");
UpdateWrapper<OrgSettlementAccountEntity> update = new UpdateWrapper<>();
update.eq("id", id)
.set("updated_at", new Date());
if (result.isSuccess()) {
update.set("bind_status", DayCardConstants.BIND_STATUS_ACTIVE);
update.set("external_bind_id", result.getPayNo());
} else {
update.set("bind_status", DayCardConstants.BIND_STATUS_FAILED);
}
baseMapper.update(null, update);
}
@Override
public OrgSettlementAccountEntity getByOrgId(Long orgId) {
QueryWrapper<OrgSettlementAccountEntity> wrapper = new QueryWrapper<>();
wrapper.eq("org_id", orgId)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("bind_status", DayCardConstants.BIND_STATUS_ACTIVE)
.last("LIMIT 1");
return baseMapper.selectOne(wrapper);
}
}
@@ -0,0 +1,126 @@
package io.as.modules.daycard.service.impl;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardProfitRuleEntity;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import io.as.modules.daycard.service.DayCardProfitRuleService;
import io.as.modules.daycard.service.ProfitCalcService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 分润计算 Service 实现
*/
@Service("profitCalcService")
public class ProfitCalcServiceImpl implements ProfitCalcService {
private static final Logger logger = LoggerFactory.getLogger(ProfitCalcServiceImpl.class);
@Autowired
private DayCardProfitRuleService dayCardProfitRuleService;
@Override
public Map<String, Object> calculate(Long partnerId, Long channelOrgId, Long storeOrgId, BigDecimal totalAmount) {
Map<String, Object> result = new HashMap<>();
result.put("totalAmount", totalAmount);
List<DayCardProfitRuleEntity> rules = dayCardProfitRuleService.matchRules(partnerId, channelOrgId, storeOrgId);
List<Map<String, Object>> ruleList = new ArrayList<>();
BigDecimal totalSettleAmount = BigDecimal.ZERO;
for (DayCardProfitRuleEntity rule : rules) {
BigDecimal settleAmount = calcSettleAmount(rule, totalAmount);
Map<String, Object> ruleMap = new HashMap<>();
ruleMap.put("ruleId", rule.getId());
ruleMap.put("receiverAccount", rule.getReceiverAccount());
ruleMap.put("receiverName", rule.getReceiverName());
ruleMap.put("shareRatio", rule.getShareRatio());
ruleMap.put("shareType", rule.getShareType());
ruleMap.put("settleAmount", settleAmount);
ruleList.add(ruleMap);
totalSettleAmount = totalSettleAmount.add(settleAmount);
}
result.put("rules", ruleList);
result.put("totalSettleAmount", totalSettleAmount);
return result;
}
@Override
public List<DayCardSettleOrderEntity> buildSettleOrders(String orderNo, DayCardOrderEntity order) {
List<DayCardSettleOrderEntity> settleOrders = new ArrayList<>();
List<DayCardProfitRuleEntity> rules = dayCardProfitRuleService.matchRules(
order.getPartnerId(), order.getChannelOrgId(), order.getStoreOrgId());
if (rules == null || rules.isEmpty()) {
logger.info("未匹配到分润规则,跳过分账: orderNo={}", orderNo);
return settleOrders;
}
BigDecimal totalAmount = order.getPayAmount() != null ? order.getPayAmount() : order.getAmount();
Date now = new Date();
for (DayCardProfitRuleEntity rule : rules) {
BigDecimal settleAmount = calcSettleAmount(rule, totalAmount);
DayCardSettleOrderEntity settleOrder = new DayCardSettleOrderEntity();
settleOrder.setSettleNo(generateSettleNo());
settleOrder.setOrderNo(orderNo);
settleOrder.setPartnerId(order.getPartnerId());
settleOrder.setRuleId(rule.getId());
settleOrder.setReceiverAccount(rule.getReceiverAccount());
settleOrder.setReceiverName(rule.getReceiverName());
settleOrder.setShareRatio(rule.getShareRatio());
settleOrder.setShareType(rule.getShareType());
settleOrder.setSettleAmount(settleAmount);
settleOrder.setTotalAmount(totalAmount);
settleOrder.setStatus(DayCardConstants.SETTLE_STATUS_PENDING);
settleOrder.setCreatedAt(now);
settleOrder.setUpdatedAt(now);
settleOrders.add(settleOrder);
}
return settleOrders;
}
/**
* 计算单条规则的分账金额
* <p>RATIO: totalAmount * shareRatio</p>
* <p>FIXED: shareRatio 直接作为金额</p>
*/
private BigDecimal calcSettleAmount(DayCardProfitRuleEntity rule, BigDecimal totalAmount) {
if (DayCardConstants.SHARE_TYPE_FIXED.equals(rule.getShareType())) {
return rule.getShareRatio().setScale(2, RoundingMode.HALF_UP);
}
// 默认按比例
return totalAmount.multiply(rule.getShareRatio()).setScale(2, RoundingMode.HALF_UP);
}
/**
* 生成分账单号:ST + yyyyMMddHHmmss + 6位随机数
*/
private String generateSettleNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timestamp = sdf.format(new Date());
int random = ThreadLocalRandom.current().nextInt(100000, 1000000);
return "ST" + timestamp + random;
}
}
@@ -0,0 +1,465 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardOrderDao;
import io.as.modules.daycard.dao.OrgSettlementAccountDao;
import io.as.modules.daycard.dao.ProfitSharingDetailDao;
import io.as.modules.daycard.dao.ProfitSharingOrderDao;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import io.as.modules.daycard.service.ProfitSharingExecuteService;
import io.as.modules.daycard.service.ProfitSharingRuleService;
import io.as.modules.payment.service.WechatPayResult;
import io.as.modules.payment.service.WechatPayService;
import io.as.modules.sys.dao.SysOrgDao;
import io.as.modules.sys.entity.SysOrgEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 分账执行 Service 实现
*/
@Service("profitSharingExecuteService")
public class ProfitSharingExecuteServiceImpl implements ProfitSharingExecuteService {
private static final Logger logger = LoggerFactory.getLogger(ProfitSharingExecuteServiceImpl.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private DayCardOrderDao dayCardOrderDao;
@Autowired
private ProfitSharingOrderDao profitSharingOrderDao;
@Autowired
private ProfitSharingDetailDao profitSharingDetailDao;
@Autowired(required = false)
private ProfitSharingRuleService profitSharingRuleService;
@Autowired
private OrgSettlementAccountDao orgSettlementAccountDao;
@Autowired
private SysOrgDao sysOrgDao;
@Autowired
private io.as.modules.payment.service.WechatPayService wechatPayService;
@Override
@Transactional(rollbackFor = Exception.class)
public void executeSharing(String orderNo) {
// 1. 查询订单
QueryWrapper<DayCardOrderEntity> orderWrapper = new QueryWrapper<>();
orderWrapper.eq("order_no", orderNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
DayCardOrderEntity order = dayCardOrderDao.selectOne(orderWrapper);
if (order == null) {
logger.warn("分账执行失败:订单不存在, orderNo={}", orderNo);
return;
}
// 2. 幂等检查:查 profit_sharing_order by pay_order_no,已存在则跳过
QueryWrapper<ProfitSharingOrderEntity> existWrapper = new QueryWrapper<>();
existWrapper.eq("pay_order_no", orderNo);
Integer existCount = profitSharingOrderDao.selectCount(existWrapper);
if (existCount != null && existCount > 0) {
logger.info("分账单已存在,跳过: orderNo={}", orderNo);
return;
}
// 3. 从订单获取 storeOrgId, agentOrgId, channelOrgId, orgPathSnapshot
Long storeOrgId = order.getStoreOrgId();
Long channelOrgId = order.getChannelOrgId();
Long agentOrgId = resolveAgentOrgId(storeOrgId);
Long platformOrgId = resolvePlatformOrgId(storeOrgId);
String orgPathSnapshot = buildOrgPathSnapshot(storeOrgId, agentOrgId, channelOrgId);
// 4. 调用 profitSharingRuleService.matchRule() 匹配规则
if (profitSharingRuleService == null) {
logger.warn("ProfitSharingRuleService 未注入,跳过分账: orderNo={}", orderNo);
return;
}
ProfitSharingRuleEntity rule = profitSharingRuleService.matchRule(storeOrgId, agentOrgId, channelOrgId);
if (rule == null || rule.getItems() == null || rule.getItems().isEmpty()) {
logger.info("无匹配的分账规则,跳过分账: orderNo={}", orderNo);
return;
}
// 5. 获取 rule_items,计算各方分账金额
// 金额单位:分(int),从订单 amount(元)转换
BigDecimal amountYuan = order.getAmount();
if (amountYuan == null) {
logger.warn("订单金额为空,跳过分账: orderNo={}", orderNo);
return;
}
int totalAmount = amountYuan.multiply(new BigDecimal("100")).intValue();
List<ProfitSharingRuleItemEntity> ruleItems = rule.getItems();
List<ProfitSharingDetailEntity> details = new ArrayList<>();
int nonPlatformSum = 0;
BigDecimal nonPlatformTotalRatio = BigDecimal.ZERO;
for (ProfitSharingRuleItemEntity item : ruleItems) {
ProfitSharingDetailEntity detail = new ProfitSharingDetailEntity();
detail.setOrgRole(item.getOrgRole());
detail.setOrgId(resolveOrgIdByRole(item.getOrgRole(), platformOrgId, channelOrgId, agentOrgId, storeOrgId));
detail.setStatus(DayCardConstants.SETTLE_STATUS_PENDING);
if (DayCardConstants.SHARE_TYPE_FIXED.equals(item.getShareType())) {
// FIXED: shareValue 即为金额(分)
detail.setAmount(item.getShareValue().intValue());
} else {
// RATIO: totalAmount * shareValue / 100
int amt = BigDecimal.valueOf(totalAmount)
.multiply(item.getShareValue())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN)
.intValue();
detail.setAmount(amt);
}
detail.setDescription("分账-" + (item.getOrgRole() != null ? item.getOrgRole() : "UNKNOWN"));
// 累计非平台方金额和比例
if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole())) {
nonPlatformSum += detail.getAmount();
if (DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType()) && item.getShareValue() != null) {
nonPlatformTotalRatio = nonPlatformTotalRatio.add(item.getShareValue());
}
}
details.add(detail);
}
// 尾差归平台:PLATFORM 的金额 = totalAmount - 其他各方之和
for (ProfitSharingDetailEntity detail : details) {
if (DayCardConstants.ORG_ROLE_PLATFORM.equals(detail.getOrgRole())) {
detail.setAmount(totalAmount - nonPlatformSum);
break;
}
}
// 6. 二次校验非平台方总比例 ≤ 30%
if (nonPlatformTotalRatio.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) {
logger.error("非平台方总比例超过30%限制: orderNo={}, ratioSum={}", orderNo, nonPlatformTotalRatio);
return;
}
// 7. 创建 profit_sharing_order(存 rule_snapshot JSON)
String outOrderNo = generateOutOrderNo();
String ruleSnapshot = serializeRuleSnapshot(rule);
ProfitSharingOrderEntity sharingOrder = new ProfitSharingOrderEntity();
sharingOrder.setPayOrderNo(orderNo);
sharingOrder.setTransactionId(order.getPayNo());
sharingOrder.setOutOrderNo(outOrderNo);
sharingOrder.setStatus(DayCardConstants.SETTLE_STATUS_PENDING);
sharingOrder.setTotalAmount(totalAmount);
sharingOrder.setRuleId(rule.getId());
sharingOrder.setRuleSnapshot(ruleSnapshot);
sharingOrder.setOrgPathSnapshot(orgPathSnapshot);
sharingOrder.setSharingChannel("WECHAT_NATIVE");
sharingOrder.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
Date now = new Date();
sharingOrder.setCreatedAt(now);
sharingOrder.setUpdatedAt(now);
profitSharingOrderDao.insert(sharingOrder);
// 8. 批量创建 profit_sharing_detail
for (ProfitSharingDetailEntity detail : details) {
detail.setSharingOrderId(sharingOrder.getId());
detail.setCreatedAt(now);
detail.setUpdatedAt(now);
profitSharingDetailDao.insert(detail);
}
logger.info("分账单已创建: orderNo={}, outOrderNo={}, detailCount={}", orderNo, outOrderNo, details.size());
// 9. 查询各接收方的 org_settlement_account(bind_status=ACTIVE)
for (ProfitSharingDetailEntity detail : details) {
if (detail.getOrgId() != null) {
OrgSettlementAccountEntity account = orgSettlementAccountDao.selectOne(
new QueryWrapper<OrgSettlementAccountEntity>()
.eq("org_id", detail.getOrgId())
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("bind_status", DayCardConstants.BIND_STATUS_ACTIVE)
.last("LIMIT 1"));
if (account != null) {
detail.setReceiverAccount(account.getReceiverAccount());
detail.setReceiverType(account.getReceiverType());
} else {
logger.warn("未找到活跃的结算账户: orgId={}, orgRole={}", detail.getOrgId(), detail.getOrgRole());
}
}
}
// 10. 调用微信分账 API
// 更新分账单状态为 PROCESSING
UpdateWrapper<ProfitSharingOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.SETTLE_STATUS_PROCESSING)
.set("updated_at", new Date());
profitSharingOrderDao.update(null, orderUpdate);
// 构建分账接收方列表
List<Map<String, Object>> receivers = new ArrayList<>();
for (ProfitSharingDetailEntity detail : details) {
if (detail.getReceiverAccount() == null || DayCardConstants.ORG_ROLE_PLATFORM.equals(detail.getOrgRole())) {
continue; // 平台方通过完结分账获取留成,不需要分账接收方
}
Map<String, Object> receiver = new HashMap<>();
receiver.put("type", detail.getReceiverType() != null ? detail.getReceiverType() : "MERCHANT_ID");
receiver.put("account", detail.getReceiverAccount());
receiver.put("amount", detail.getAmount());
receiver.put("description", detail.getDescription() != null ? detail.getDescription() : "分账");
receivers.add(receiver);
}
// 调用微信分账 API
String transactionId = order.getPayNo();
WechatPayResult sharingResult = wechatPayService.createProfitSharing(transactionId, outOrderNo, receivers);
if (!sharingResult.isSuccess()) {
// 分账失败,更新状态
UpdateWrapper<ProfitSharingOrderEntity> failUpdate = new UpdateWrapper<>();
failUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.SETTLE_STATUS_FAILED)
.set("updated_at", new Date());
profitSharingOrderDao.update(null, failUpdate);
logger.error("微信分账失败: orderNo={}, outOrderNo={}, error={}", orderNo, outOrderNo, sharingResult.getErrorMsg());
return;
}
logger.info("微信分账API调用成功: outOrderNo={}", outOrderNo);
// 模拟/实际更新所有 detail 状态为 SUCCESS
for (ProfitSharingDetailEntity detail : details) {
UpdateWrapper<ProfitSharingDetailEntity> detailUpdate = new UpdateWrapper<>();
detailUpdate.eq("id", detail.getId())
.set("status", DayCardConstants.SETTLE_STATUS_SUCCESS)
.set("success_time", new Date())
.set("updated_at", new Date());
profitSharingDetailDao.update(null, detailUpdate);
}
// 11. 调用完结分账 API
boolean finishSuccess = wechatPayService.finishProfitSharing(outOrderNo, transactionId);
if (finishSuccess) {
UpdateWrapper<ProfitSharingOrderEntity> finishUpdate = new UpdateWrapper<>();
finishUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.SETTLE_STATUS_SUCCESS)
.set("updated_at", new Date());
profitSharingOrderDao.update(null, finishUpdate);
logger.info("分账执行成功: orderNo={}, outOrderNo={}", orderNo, outOrderNo);
} else {
logger.warn("完结分账失败,分账单保持PROCESSING状态: outOrderNo={}", outOrderNo);
}
}
@Override
public void retryFailed() {
// 1. 查 profit_sharing_order status=PENDING 或 FAILED 且 created_at < NOW() - 5分钟
Date threshold = new Date(System.currentTimeMillis() - 5 * 60 * 1000L);
QueryWrapper<ProfitSharingOrderEntity> wrapper = new QueryWrapper<>();
wrapper.in("status", DayCardConstants.SETTLE_STATUS_PENDING, DayCardConstants.SETTLE_STATUS_FAILED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.lt("created_at", threshold)
.last("LIMIT 200");
List<ProfitSharingOrderEntity> failedOrders = profitSharingOrderDao.selectList(wrapper);
if (failedOrders.isEmpty()) {
return;
}
logger.info("开始重试失败的分账单,共 {} 笔", failedOrders.size());
int retried = 0;
for (ProfitSharingOrderEntity sharingOrder : failedOrders) {
try {
retrySingle(sharingOrder);
retried++;
} catch (Exception e) {
logger.error("重试分账单失败: outOrderNo={}", sharingOrder.getOutOrderNo(), e);
}
}
logger.info("重试分账单完成,成功 {} 笔", retried);
}
/**
* 重试单笔分账单
*/
private void retrySingle(ProfitSharingOrderEntity sharingOrder) {
// 查询明细
QueryWrapper<ProfitSharingDetailEntity> wrapper = new QueryWrapper<>();
wrapper.eq("sharing_order_id", sharingOrder.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(wrapper);
// 更新分账单状态为 PROCESSING
UpdateWrapper<ProfitSharingOrderEntity> orderUpdate = new UpdateWrapper<>();
orderUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.SETTLE_STATUS_PROCESSING)
.set("updated_at", new Date());
profitSharingOrderDao.update(null, orderUpdate);
// 调用微信查询分账状态
WechatPayResult queryResult = wechatPayService.queryProfitSharing(sharingOrder.getOutOrderNo());
if (queryResult.isSuccess()) {
logger.info("微信分账查询成功: outOrderNo={}", sharingOrder.getOutOrderNo());
} else {
logger.warn("微信分账查询失败: outOrderNo={}, error={}", sharingOrder.getOutOrderNo(), queryResult.getErrorMsg());
}
// 占位:模拟调用微信分账 API 成功
logger.info("重试分账(占位): outOrderNo={}", sharingOrder.getOutOrderNo());
// 更新所有 detail 状态为 SUCCESS
for (ProfitSharingDetailEntity detail : details) {
if (!DayCardConstants.SETTLE_STATUS_SUCCESS.equals(detail.getStatus())) {
UpdateWrapper<ProfitSharingDetailEntity> detailUpdate = new UpdateWrapper<>();
detailUpdate.eq("id", detail.getId())
.set("status", DayCardConstants.SETTLE_STATUS_SUCCESS)
.set("success_time", new Date())
.set("updated_at", new Date());
profitSharingDetailDao.update(null, detailUpdate);
}
}
// 更新分账单状态为 SUCCESS
UpdateWrapper<ProfitSharingOrderEntity> finishUpdate = new UpdateWrapper<>();
finishUpdate.eq("id", sharingOrder.getId())
.set("status", DayCardConstants.SETTLE_STATUS_SUCCESS)
.set("updated_at", new Date());
profitSharingOrderDao.update(null, finishUpdate);
logger.info("重试分账成功: outOrderNo={}", sharingOrder.getOutOrderNo());
}
/**
* 解析代理组织ID(门店的父组织)
*/
private Long resolveAgentOrgId(Long storeOrgId) {
if (storeOrgId == null) {
return null;
}
SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId);
if (storeOrg == null || storeOrg.getParentId() == null) {
return null;
}
return storeOrg.getParentId();
}
/**
* 解析平台组织ID(mpath 第一段)
*/
private Long resolvePlatformOrgId(Long storeOrgId) {
if (storeOrgId == null) {
return null;
}
SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId);
if (storeOrg == null || storeOrg.getMpath() == null) {
return null;
}
String[] segments = storeOrg.getMpath().split("\\.");
if (segments.length >= 1) {
return Long.parseLong(segments[0]);
}
return null;
}
/**
* 根据组织角色解析对应的组织ID
*/
private Long resolveOrgIdByRole(String orgRole, Long platformOrgId, Long channelOrgId, Long agentOrgId, Long storeOrgId) {
if (orgRole == null) {
return null;
}
switch (orgRole) {
case DayCardConstants.ORG_ROLE_PLATFORM:
return platformOrgId;
case DayCardConstants.ORG_ROLE_CHANNEL:
return channelOrgId;
case DayCardConstants.ORG_ROLE_AGENT:
return agentOrgId;
case DayCardConstants.ORG_ROLE_STORE:
return storeOrgId;
default:
return null;
}
}
/**
* 构建组织链路快照
*/
private String buildOrgPathSnapshot(Long storeOrgId, Long agentOrgId, Long channelOrgId) {
StringBuilder sb = new StringBuilder();
if (channelOrgId != null) {
SysOrgEntity channelOrg = sysOrgDao.selectById(channelOrgId);
if (channelOrg != null) {
sb.append(channelOrg.getName());
}
}
if (agentOrgId != null) {
SysOrgEntity agentOrg = sysOrgDao.selectById(agentOrgId);
if (agentOrg != null) {
if (sb.length() > 0) {
sb.append(" > ");
}
sb.append(agentOrg.getName());
}
}
if (storeOrgId != null) {
SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId);
if (storeOrg != null) {
if (sb.length() > 0) {
sb.append(" > ");
}
sb.append(storeOrg.getName());
}
}
return sb.toString();
}
/**
* 序列化规则快照为 JSON
*/
private String serializeRuleSnapshot(ProfitSharingRuleEntity rule) {
try {
return objectMapper.writeValueAsString(rule);
} catch (Exception e) {
logger.warn("序列化规则快照失败: ruleId={}", rule.getId(), e);
return "{}";
}
}
/**
* 生成分账内部单号:PS + yyyyMMddHHmmss + 6位随机数
*/
private String generateOutOrderNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timestamp = sdf.format(new Date());
int random = ThreadLocalRandom.current().nextInt(100000, 1000000);
return "PS" + timestamp + random;
}
}
@@ -0,0 +1,217 @@
package io.as.modules.daycard.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.ProfitSharingRuleDao;
import io.as.modules.daycard.dao.ProfitSharingRuleItemDao;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import io.as.modules.daycard.service.ProfitSharingRuleService;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 分润规则 Service 实现
*/
@Service("profitSharingRuleService")
public class ProfitSharingRuleServiceImpl extends ServiceImpl<ProfitSharingRuleDao, ProfitSharingRuleEntity> implements ProfitSharingRuleService {
@Autowired
private ProfitSharingRuleItemDao profitSharingRuleItemDao;
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<ProfitSharingRuleEntity> page = new Query<ProfitSharingRuleEntity>().getPage(params, "created_at", false);
QueryWrapper<ProfitSharingRuleEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String scopeType = (String) params.get("scopeType");
if (StringUtils.isNotBlank(scopeType)) {
wrapper.eq("scope_type", scopeType);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
IPage<ProfitSharingRuleEntity> result = baseMapper.selectPage(page, wrapper);
return new PageUtils(result);
}
@Override
public ProfitSharingRuleEntity getInfo(Long id) {
ProfitSharingRuleEntity rule = baseMapper.selectById(id);
if (rule == null) {
return null;
}
// 查询子表 items
QueryWrapper<ProfitSharingRuleItemEntity> itemWrapper = new QueryWrapper<>();
itemWrapper.eq("rule_id", id).orderByAsc("sort_order");
List<ProfitSharingRuleItemEntity> items = profitSharingRuleItemDao.selectList(itemWrapper);
rule.setItems(items);
return rule;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveRule(ProfitSharingRuleEntity rule) {
Date now = new Date();
rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
rule.setCreatedAt(now);
rule.setUpdatedAt(now);
// 1. 插入主表获取 id
baseMapper.insert(rule);
// 2. 批量插入 items
List<ProfitSharingRuleItemEntity> items = rule.getItems();
if (items != null && !items.isEmpty()) {
for (ProfitSharingRuleItemEntity item : items) {
item.setRuleId(rule.getId());
item.setCreatedAt(now);
item.setUpdatedAt(now);
profitSharingRuleItemDao.insert(item);
}
}
// 3. 30%校验
validateShareRatio(items);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateRule(ProfitSharingRuleEntity rule) {
Date now = new Date();
rule.setUpdatedAt(now);
// 1. 更新主表
baseMapper.updateById(rule);
// 2. 删除旧 items
profitSharingRuleItemDao.delete(new QueryWrapper<ProfitSharingRuleItemEntity>().eq("rule_id", rule.getId()));
// 3. 批量插入新 items
List<ProfitSharingRuleItemEntity> items = rule.getItems();
if (items != null && !items.isEmpty()) {
for (ProfitSharingRuleItemEntity item : items) {
item.setRuleId(rule.getId());
item.setCreatedAt(now);
item.setUpdatedAt(now);
profitSharingRuleItemDao.insert(item);
}
}
// 4. 30%校验
validateShareRatio(items);
}
@Override
public void delete(Long id) {
ProfitSharingRuleEntity entity = new ProfitSharingRuleEntity();
entity.setId(id);
entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
entity.setUpdatedAt(new Date());
baseMapper.updateById(entity);
}
@Override
public ProfitSharingRuleEntity matchRule(Long storeOrgId, Long agentOrgId, Long channelOrgId) {
// 1. 查 STORE 级规则
if (storeOrgId != null) {
ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_STORE, storeOrgId);
if (rule != null) {
fillItems(rule);
return rule;
}
}
// 2. 未命中 -> 查 AGENT 级
if (agentOrgId != null) {
ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_AGENT, agentOrgId);
if (rule != null) {
fillItems(rule);
return rule;
}
}
// 3. 未命中 -> 查 CHANNEL 级
if (channelOrgId != null) {
ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_CHANNEL, channelOrgId);
if (rule != null) {
fillItems(rule);
return rule;
}
}
// 4. 未命中 -> 查 GLOBAL 级
ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_GLOBAL, null);
if (rule != null) {
fillItems(rule);
return rule;
}
return null;
}
/**
* 查询指定范围内生效的规则(单条,按 priority DESC 取最高)
*/
private ProfitSharingRuleEntity queryActiveRule(String scopeType, Long scopeOrgId) {
QueryWrapper<ProfitSharingRuleEntity> wrapper = new QueryWrapper<>();
wrapper.eq("scope_type", scopeType)
.eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.and(w -> w.isNull("effective_from").or().le("effective_from", new Date()))
.and(w -> w.isNull("effective_to").or().ge("effective_to", new Date()))
.orderByDesc("priority")
.last("LIMIT 1");
if (scopeOrgId != null) {
wrapper.eq("scope_org_id", scopeOrgId);
} else {
wrapper.isNull("scope_org_id");
}
return baseMapper.selectOne(wrapper);
}
/**
* 填充规则的 items 子表
*/
private void fillItems(ProfitSharingRuleEntity rule) {
QueryWrapper<ProfitSharingRuleItemEntity> itemWrapper = new QueryWrapper<>();
itemWrapper.eq("rule_id", rule.getId()).orderByAsc("sort_order");
List<ProfitSharingRuleItemEntity> items = profitSharingRuleItemDao.selectList(itemWrapper);
rule.setItems(items);
}
/**
* 30%校验:计算非平台方(orgRole != PLATFORM)的 RATIO 类型 shareValue 之和,超过30则抛异常
*/
private void validateShareRatio(List<ProfitSharingRuleItemEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
BigDecimal sum = BigDecimal.ZERO;
for (ProfitSharingRuleItemEntity item : items) {
if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole())
&& DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType())) {
if (item.getShareValue() != null) {
sum = sum.add(item.getShareValue());
}
}
}
if (sum.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) {
throw new ASException("非平台方分润比例之和不能超过" + DayCardConstants.PROFIT_SHARE_LIMIT + "%");
}
}
}
@@ -0,0 +1,133 @@
package io.as.modules.daycard.service.impl;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardOrderDao;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import io.as.modules.daycard.service.DayCardSettleOrderService;
import io.as.modules.daycard.service.ProfitCalcService;
import io.as.modules.daycard.service.SettleExecuteService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 分账执行 Service 实现
*/
@Service("settleExecuteService")
public class SettleExecuteServiceImpl implements SettleExecuteService {
private static final Logger logger = LoggerFactory.getLogger(SettleExecuteServiceImpl.class);
@Autowired
private DayCardOrderDao dayCardOrderDao;
@Autowired
private ProfitCalcService profitCalcService;
@Autowired
private DayCardSettleOrderService dayCardSettleOrderService;
@Override
public void executeSettle(String orderNo) {
// 1. 查询订单
DayCardOrderEntity order = dayCardOrderDao.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<DayCardOrderEntity>()
.eq("order_no", orderNo)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL));
if (order == null) {
logger.warn("分账执行失败:订单不存在, orderNo={}", orderNo);
return;
}
// 幂等检查:已有分账单则跳过
List<DayCardSettleOrderEntity> existingOrders = dayCardSettleOrderService.getByOrderNo(orderNo);
if (existingOrders != null && !existingOrders.isEmpty()) {
logger.info("分账单已存在,跳过: orderNo={}", orderNo);
return;
}
// 2. 调用 ProfitCalcService 构建分账单
List<DayCardSettleOrderEntity> settleOrders = profitCalcService.buildSettleOrders(orderNo, order);
if (settleOrders == null || settleOrders.isEmpty()) {
logger.info("无匹配的分润规则,跳过分账: orderNo={}", orderNo);
return;
}
// 3. 保存分账单
for (DayCardSettleOrderEntity settleOrder : settleOrders) {
dayCardSettleOrderService.saveSettleOrder(settleOrder);
}
logger.info("分账单已保存: orderNo={}, count={}", orderNo, settleOrders.size());
// 4. 逐条执行
for (DayCardSettleOrderEntity settleOrder : settleOrders) {
try {
executeSingle(settleOrder);
} catch (Exception e) {
logger.error("执行分账单失败: settleNo={}", settleOrder.getSettleNo(), e);
dayCardSettleOrderService.updateStatus(settleOrder.getId(),
DayCardConstants.SETTLE_STATUS_FAILED, e.getMessage());
}
}
}
@Override
public void executeSingle(DayCardSettleOrderEntity settleOrder) {
// 设置状态为处理中
dayCardSettleOrderService.updateStatus(settleOrder.getId(),
DayCardConstants.SETTLE_STATUS_PROCESSING, null);
try {
// TODO: 实际调用微信分账API
// 当前为占位:模拟调用成功
logger.info("执行分账(占位): settleNo={}, receiverAccount={}, settleAmount={}",
settleOrder.getSettleNo(), settleOrder.getReceiverAccount(), settleOrder.getSettleAmount());
// 模拟调用成功
dayCardSettleOrderService.updateStatus(settleOrder.getId(),
DayCardConstants.SETTLE_STATUS_SUCCESS, null);
logger.info("分账执行成功: settleNo={}", settleOrder.getSettleNo());
} catch (Exception e) {
logger.error("分账执行失败: settleNo={}", settleOrder.getSettleNo(), e);
dayCardSettleOrderService.updateStatus(settleOrder.getId(),
DayCardConstants.SETTLE_STATUS_FAILED, e.getMessage());
throw e;
}
}
@Override
public int retryFailed() {
// 先重置失败的分账单状态
int resetCount = dayCardSettleOrderService.retryFailed();
if (resetCount == 0) {
return 0;
}
logger.info("开始重试失败的分账单,共 {} 笔", resetCount);
// 查询 PENDING 状态的分账单重新执行
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<DayCardSettleOrderEntity> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
wrapper.eq("status", DayCardConstants.SETTLE_STATUS_PENDING)
.last("LIMIT 200");
List<DayCardSettleOrderEntity> pendingList = dayCardSettleOrderService.list(wrapper);
int retried = 0;
for (DayCardSettleOrderEntity settleOrder : pendingList) {
try {
executeSingle(settleOrder);
retried++;
} catch (Exception e) {
logger.error("重试分账单失败: settleNo={}", settleOrder.getSettleNo(), e);
}
}
logger.info("重试分账单完成,成功 {} 笔", retried);
return retried;
}
}
@@ -0,0 +1,32 @@
package io.as.modules.daycard.task;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.job.task.ITask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 天卡激活失败补偿定时任务
* <p>定时扫描 activate_status=FAILED 的卡,重新激活</p>
*/
@Component("dayCardActivateRetryTask")
public class DayCardActivateRetryTask implements ITask {
private static final Logger logger = LoggerFactory.getLogger(DayCardActivateRetryTask.class);
@Autowired
private DayCardOrderService dayCardOrderService;
@Override
public void run(String params) {
logger.info("天卡激活失败补偿任务开始执行, params={}", params);
try {
int retried = dayCardOrderService.retryFailedActivations();
logger.info("天卡激活失败补偿任务执行完毕, 重试激活数={}", retried);
} catch (Exception e) {
logger.error("天卡激活失败补偿任务执行异常", e);
}
}
}
@@ -0,0 +1,32 @@
package io.as.modules.daycard.task;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.job.task.ITask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 天卡订单超时取消定时任务
* <p>定时扫描状态为 CREATED 且已过期的天卡订单,逐个取消并释放预留的卡</p>
*/
@Component("dayCardOrderTimeoutTask")
public class DayCardOrderTimeoutTask implements ITask {
private static final Logger logger = LoggerFactory.getLogger(DayCardOrderTimeoutTask.class);
@Autowired
private DayCardOrderService dayCardOrderService;
@Override
public void run(String params) {
logger.info("天卡订单超时取消任务开始执行, params={}", params);
try {
int cancelled = dayCardOrderService.cancelTimeoutOrders();
logger.info("天卡订单超时取消任务执行完毕, 取消订单数={}", cancelled);
} catch (Exception e) {
logger.error("天卡订单超时取消任务执行异常", e);
}
}
}
@@ -0,0 +1,32 @@
package io.as.modules.daycard.task;
import io.as.modules.daycard.service.ProfitSharingExecuteService;
import io.as.modules.job.task.ITask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 分账兜底查询定时任务
* <p>定时扫描状态为 PENDING 或 FAILED 且超过5分钟的分账单,逐条重试</p>
*/
@Component("profitSharingQueryTask")
public class ProfitSharingQueryTask implements ITask {
private static final Logger logger = LoggerFactory.getLogger(ProfitSharingQueryTask.class);
@Autowired
private ProfitSharingExecuteService profitSharingExecuteService;
@Override
public void run(String params) {
logger.info("分账兜底查询任务开始执行, params={}", params);
try {
profitSharingExecuteService.retryFailed();
logger.info("分账兜底查询任务执行完毕");
} catch (Exception e) {
logger.error("分账兜底查询任务执行异常", e);
}
}
}
@@ -0,0 +1,27 @@
package io.as.modules.daycard.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡订单展示VO
*/
@Data
public class DayCardOrderVo implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String orderNo;
private String status;
private BigDecimal amount;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date payTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
}
@@ -0,0 +1,22 @@
package io.as.modules.daycard.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 天卡池库存统计VO
*/
@Data
public class DayCardPoolStatsVo implements Serializable {
private static final long serialVersionUID = 1L;
/** 总数 */
private Integer total;
/** 可用(未使用) */
private Integer available;
/** 已预留 */
private Integer reserved;
/** 已使用 */
private Integer used;
}