|
@@ -0,0 +1,1139 @@
|
|
|
|
|
+package com.fs.qw.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.fs.common.constant.FsConstants;
|
|
|
|
|
+import com.fs.common.core.domain.R;
|
|
|
|
|
+import com.fs.common.core.redis.RedisCache;
|
|
|
|
|
+import com.fs.common.utils.StringUtils;
|
|
|
|
|
+import com.fs.company.service.ICompanyService;
|
|
|
|
|
+import com.fs.course.config.CourseConfig;
|
|
|
|
|
+import com.fs.course.domain.BalanceRollbackError;
|
|
|
|
|
+import com.fs.course.domain.FsCourseRedPacketLog;
|
|
|
|
|
+import com.fs.course.domain.FsCourseWatchLog;
|
|
|
|
|
+import com.fs.course.domain.FsUserCourse;
|
|
|
|
|
+import com.fs.course.domain.FsUserCourseVideo;
|
|
|
|
|
+import com.fs.course.domain.FsUserCourseVideoRedPackage;
|
|
|
|
|
+import com.fs.course.mapper.BalanceRollbackErrorMapper;
|
|
|
|
|
+import com.fs.course.mapper.FsCourseRedPacketLogMapper;
|
|
|
|
|
+import com.fs.course.mapper.FsCourseWatchLogMapper;
|
|
|
|
|
+import com.fs.course.mapper.FsUserCourseMapper;
|
|
|
|
|
+import com.fs.course.mapper.FsUserCourseVideoMapper;
|
|
|
|
|
+import com.fs.course.mapper.FsUserCourseVideoRedPackageMapper;
|
|
|
|
|
+import com.fs.course.service.ICourseRandomRedPacketService;
|
|
|
|
|
+import com.fs.course.vo.TodayFinishWatchForRedPacketVO;
|
|
|
|
|
+import com.fs.his.domain.FsUser;
|
|
|
|
|
+import com.fs.his.mapper.FsUserMapper;
|
|
|
|
|
+import com.fs.his.param.WxSendRedPacketParam;
|
|
|
|
|
+import com.fs.his.service.IFsStorePaymentService;
|
|
|
|
|
+import com.fs.qw.domain.QwExternalContact;
|
|
|
|
|
+import com.fs.qw.domain.QwUser;
|
|
|
|
|
+import com.fs.qw.mapper.QwExternalContactMapper;
|
|
|
|
|
+import com.fs.qw.mapper.QwUserMapper;
|
|
|
|
|
+import com.fs.qw.param.ExternalContactSendRedPacketParam;
|
|
|
|
|
+import com.fs.qw.param.SidebarSendRedPacketParam;
|
|
|
|
|
+import com.fs.qw.service.IExternalContactRedPacketService;
|
|
|
|
|
+import com.fs.qw.vo.QwSopCourseFinishTempSetting;
|
|
|
|
|
+import com.fs.qwApi.Result.QwOpenidResult;
|
|
|
|
|
+import com.fs.qwApi.param.QwOpenidByExternalcontactParams;
|
|
|
|
|
+import com.fs.qwApi.service.QwApiService;
|
|
|
|
|
+import com.fs.sop.domain.QwSopLogs;
|
|
|
|
|
+import com.fs.sop.service.IQwSopLogsService;
|
|
|
|
|
+import com.fs.system.service.ISysConfigService;
|
|
|
|
|
+import com.fs.voice.utils.StringUtil;
|
|
|
|
|
+import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.redisson.api.RLock;
|
|
|
|
|
+import org.redisson.api.RedissonClient;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.net.URLEncoder;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.ZoneId;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 销售端一键发红包:当天完课、仅飞书看课(watch_type=3)、按主体/我的客户范围、课程去重;
|
|
|
|
|
+ * 冷却:客户管理按「销售账号+主体」,我的客户按「销售账号+企微账号」,各 1 小时。
|
|
|
|
|
+ * 不套外层事务,避免嵌套 rollback-only。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class ExternalContactRedPacketServiceImpl implements IExternalContactRedPacketService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String SCOPE_CORP = "corp";
|
|
|
|
|
+ private static final String SCOPE_MINE = "mine";
|
|
|
|
|
+ /** 冷却 Redis key 前缀,完整 key = 前缀 + companyUserId + ":" + corpId */
|
|
|
|
|
+ private static final String COOLDOWN_KEY_PREFIX = "sales:redpacket:cooldown:";
|
|
|
|
|
+ /** 防重复点击锁前缀,完整 key = 前缀 + companyUserId + ":" + corpId */
|
|
|
|
|
+ private static final String CLICK_LOCK_PREFIX = "sales:redpacket:click:";
|
|
|
|
|
+ private static final int COOLDOWN_HOURS = 1;
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private QwExternalContactMapper qwExternalContactMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private QwUserMapper qwUserMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsUserMapper fsUserMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsUserCourseVideoMapper fsUserCourseVideoMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsUserCourseMapper fsUserCourseMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsUserCourseVideoRedPackageMapper fsUserCourseVideoRedPackageMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsCourseRedPacketLogMapper redPacketLogMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private FsCourseWatchLogMapper fsCourseWatchLogMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private BalanceRollbackErrorMapper balanceRollbackErrorMapper;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ICourseRandomRedPacketService courseRandomRedPacketService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private IFsStorePaymentService paymentService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ICompanyService companyService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ISysConfigService configService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private QwApiService qwApiService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private RedisCache redisCache;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private RedissonClient redissonClient;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private IQwSopLogsService qwSopLogsService;
|
|
|
|
|
+
|
|
|
|
|
+ /** 确认收款链接待发送优先级(越大越靠前) */
|
|
|
|
|
+ private static final int CONFIRM_RECEIVE_SORT = 9999999;
|
|
|
|
|
+ /** sendType=3 完课发送,走 pad 现有校验(约 12 小时有效) */
|
|
|
|
|
+ private static final int CONFIRM_RECEIVE_SEND_TYPE = 3;
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public R sendRedPacketToExternalContacts(ExternalContactSendRedPacketParam param,
|
|
|
|
|
+ Long companyId,
|
|
|
|
|
+ Long companyUserId) {
|
|
|
|
|
+ if (companyId == null || companyUserId == null) {
|
|
|
|
|
+ return R.error("公司或账号信息缺失");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (param == null || StringUtil.strIsNullOrEmpty(param.getScope())) {
|
|
|
|
|
+ return R.error("发放范围缺失");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(param.getCorpId())) {
|
|
|
|
|
+ return R.error("请先选择企微主体");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String scope = param.getScope();
|
|
|
|
|
+ Long filterQwUserId = null;
|
|
|
|
|
+ if (SCOPE_MINE.equals(scope)) {
|
|
|
|
|
+ if (param.getQwUserId() == null) {
|
|
|
|
|
+ return R.error("请先选择企微账号");
|
|
|
|
|
+ }
|
|
|
|
|
+ QwUser qwUser = qwUserMapper.selectQwUserById(param.getQwUserId());
|
|
|
|
|
+ if (qwUser == null) {
|
|
|
|
|
+ return R.error("企微账号不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!companyId.equals(qwUser.getCompanyId())
|
|
|
|
|
+ || qwUser.getCompanyUserId() == null
|
|
|
|
|
+ || !companyUserId.equals(qwUser.getCompanyUserId())) {
|
|
|
|
|
+ return R.error("无权操作该企微账号下的客户");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isNotEmpty(qwUser.getCorpId())
|
|
|
|
|
+ && !param.getCorpId().equals(qwUser.getCorpId())) {
|
|
|
|
|
+ return R.error("企微账号与主体不匹配");
|
|
|
|
|
+ }
|
|
|
|
|
+ filterQwUserId = param.getQwUserId();
|
|
|
|
|
+ } else if (!SCOPE_CORP.equals(scope)) {
|
|
|
|
|
+ return R.error("发放范围无效");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
|
|
+ CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
|
|
|
|
|
+ if (config == null) {
|
|
|
|
|
+ return R.error("课程配置为空,请联系管理员");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 冷却维度:客户管理按主体;我的客户按企微账号(与筛选/查询一致)
|
|
|
|
|
+ String cooldownDim = SCOPE_MINE.equals(scope)
|
|
|
|
|
+ ? ("qw:" + filterQwUserId)
|
|
|
|
|
+ : ("corp:" + param.getCorpId());
|
|
|
|
|
+ RLock clickLock = redissonClient.getLock(buildClickLockKey(companyUserId, cooldownDim));
|
|
|
|
|
+ boolean lockAcquired = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!clickLock.tryLock(3, 30, TimeUnit.SECONDS)) {
|
|
|
|
|
+ return R.error("正在发放中,请勿重复点击");
|
|
|
|
|
+ }
|
|
|
|
|
+ lockAcquired = true;
|
|
|
|
|
+
|
|
|
|
|
+ String cooldownKey = buildCooldownKey(companyUserId, cooldownDim);
|
|
|
|
|
+ Long expireAt = parseExpireAt(redisCache.getCacheObject(cooldownKey));
|
|
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
|
|
+ if (expireAt != null && expireAt > now) {
|
|
|
|
|
+ long remainMin = (expireAt - now + 59999L) / 60000L;
|
|
|
|
|
+ String coolMsg = SCOPE_MINE.equals(scope)
|
|
|
|
|
+ ? ("当前企微账号发红包冷却中,请" + remainMin + "分钟后再试")
|
|
|
|
|
+ : ("当前主体发红包冷却中,请" + remainMin + "分钟后再试");
|
|
|
|
|
+ return R.error(coolMsg)
|
|
|
|
|
+ .put("cooldownRemainSeconds", (expireAt - now) / 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LocalDate today = LocalDate.now();
|
|
|
|
|
+ Date startTime = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
|
|
|
+ Date endTime = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
|
|
|
+ List<TodayFinishWatchForRedPacketVO> finishList =
|
|
|
|
|
+ fsCourseWatchLogMapper.selectTodayFinishWatchForRedPacket(
|
|
|
|
|
+ companyId, param.getCorpId(), filterQwUserId, startTime, endTime);
|
|
|
|
|
+ if (finishList == null || finishList.isEmpty()) {
|
|
|
|
|
+ return R.ok("成功0人,跳过0人,失败0人;当天没有符合条件的飞书完课客户")
|
|
|
|
|
+ .put("success", 0).put("skip", 0).put("fail", 0)
|
|
|
|
|
+ .put("cooldownRemainSeconds", 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 同一客户 + 同一课程 + 同一小节 + 同一主体只取最近一条完课记录
|
|
|
|
|
+ Map<String, TodayFinishWatchForRedPacketVO> unique = new LinkedHashMap<>();
|
|
|
|
|
+ for (TodayFinishWatchForRedPacketVO row : finishList) {
|
|
|
|
|
+ if (row.getUserId() == null || row.getCourseId() == null || row.getVideoId() == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ String key = row.getUserId() + "_" + row.getCourseId() + "_" + row.getVideoId() + "_" + param.getCorpId();
|
|
|
|
|
+ if (!unique.containsKey(key)) {
|
|
|
|
|
+ unique.put(key, row);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Set<String> sentKeys = loadSentKeys(companyId, new ArrayList<>(unique.values()), startTime, endTime);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("[销售一键发红包] 开始处理 companyId={}, companyUserId={}, corpId={}, 待处理人数={}",
|
|
|
|
|
+ companyId, companyUserId, param.getCorpId(), unique.size());
|
|
|
|
|
+
|
|
|
|
|
+ int success = 0;
|
|
|
|
|
+ int skip = 0;
|
|
|
|
|
+ int fail = 0;
|
|
|
|
|
+ List<String> failDetails = new ArrayList<>();
|
|
|
|
|
+ for (TodayFinishWatchForRedPacketVO row : unique.values()) {
|
|
|
|
|
+ Long contactId = row.getQwExternalContactId();
|
|
|
|
|
+ String displayName = StringUtils.isNotEmpty(row.getContactName())
|
|
|
|
|
+ ? row.getContactName() : ("客户#" + contactId);
|
|
|
|
|
+ // 已发放判定:用户 + 课程 + 小节(仅限今天,历史发放不拦截)
|
|
|
|
|
+ String sentKey = buildSentKey(row.getUserId(), row.getCourseId(), row.getVideoId());
|
|
|
|
|
+ if (sentKeys.contains(sentKey)) {
|
|
|
|
|
+ skip++;
|
|
|
|
|
+ logRedPacketDecision(contactId, null, displayName, row, "跳过", "今日已发放", null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(contactId);
|
|
|
|
|
+ if (contact == null) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = "客户不存在";
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, null, displayName, row, "失败", reason, null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (contact.getCompanyId() == null || !companyId.equals(contact.getCompanyId())) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = "无权操作";
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ FsUser user = fsUserMapper.selectFsUserByUserId(row.getUserId());
|
|
|
|
|
+ if (user == null) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = "会员不存在";
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(row.getVideoId());
|
|
|
|
|
+ BigDecimal amount = resolveAmount(row.getVideoId(), companyId, row.getCourseId(), video);
|
|
|
|
|
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = "红包金额为0";
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String openId = resolveOpenId(contact);
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(openId)) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String eid = contact.getExternalUserId();
|
|
|
|
|
+ String reason = (StringUtils.isNotEmpty(eid) && eid.startsWith("wo"))
|
|
|
|
|
+ ? "企微客户不支持转openid" : "openid转换失败";
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, eid, displayName, row, "失败", reason, null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
|
|
|
|
|
+ packetParam.setOpenId(openId);
|
|
|
|
|
+ packetParam.setAmount(amount);
|
|
|
|
|
+ packetParam.setCompanyId(companyId);
|
|
|
|
|
+ packetParam.setSource(2);
|
|
|
|
|
+ packetParam.setRedPacketMode(config.getRedPacketMode());
|
|
|
|
|
+ packetParam.setUser(user);
|
|
|
|
|
+ String corpId = StringUtils.isNotEmpty(contact.getCorpId())
|
|
|
|
|
+ ? contact.getCorpId() : param.getCorpId();
|
|
|
|
|
+ packetParam.setCorpId(corpId);
|
|
|
|
|
+ // 销售端发红包 APPID 传企微主体 corpId
|
|
|
|
|
+ packetParam.setAppId(corpId);
|
|
|
|
|
+
|
|
|
|
|
+ R sendResult = sendOneWithDeduction(packetParam, amount, config,
|
|
|
|
|
+ companyId, companyUserId, contact, row.getCourseId(),
|
|
|
|
|
+ row.getVideoId(), row.getPeriodId(), row.getLogId());
|
|
|
|
|
+ if (sendResult != null && Integer.valueOf(200).equals(sendResult.get("code"))) {
|
|
|
|
|
+ success++;
|
|
|
|
|
+ sentKeys.add(sentKey);
|
|
|
|
|
+ logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row,
|
|
|
|
|
+ "发放", "发送成功,金额=" + amount, sendResult);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = resolveFailMsg(sendResult);
|
|
|
|
|
+ if (reason == null) {
|
|
|
|
|
+ reason = "发放失败";
|
|
|
|
|
+ }
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row,
|
|
|
|
|
+ "失败", reason, sendResult);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ fail++;
|
|
|
|
|
+ String reason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
|
|
|
|
+ log.error("[销售一键发红包] 异常 contactId={}, externalUserId={}, userId={}, courseId={}, videoId={}, 原因={}",
|
|
|
|
|
+ contactId, null, row.getUserId(), row.getCourseId(), row.getVideoId(), reason, e);
|
|
|
|
|
+ failDetails.add(displayName + ":" + reason);
|
|
|
|
|
+ logRedPacketDecision(contactId, null, displayName, row, "失败", reason, null);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.info("[销售一键发红包] 处理完成 companyId={}, companyUserId={}, corpId={}, 成功={}, 跳过={}, 失败={}",
|
|
|
|
|
+ companyId, companyUserId, param.getCorpId(), success, skip, fail);
|
|
|
|
|
+
|
|
|
|
|
+ StringBuilder msg = new StringBuilder();
|
|
|
|
|
+ msg.append("成功").append(success).append("人,跳过(今日已发放)").append(skip)
|
|
|
|
|
+ .append("人,失败").append(fail).append("人");
|
|
|
|
|
+ if (!failDetails.isEmpty()) {
|
|
|
|
|
+ int show = Math.min(failDetails.size(), 20);
|
|
|
|
|
+ msg.append(";失败明细:").append(String.join(";", failDetails.subList(0, show)));
|
|
|
|
|
+ if (failDetails.size() > show) {
|
|
|
|
|
+ msg.append("…");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ int cooldownRemainSeconds = 0;
|
|
|
|
|
+ if (success > 0) {
|
|
|
|
|
+ long newExpireAt = now + TimeUnit.HOURS.toMillis(COOLDOWN_HOURS);
|
|
|
|
|
+ redisCache.setCacheObject(cooldownKey, newExpireAt, COOLDOWN_HOURS * 3600, TimeUnit.SECONDS);
|
|
|
|
|
+ cooldownRemainSeconds = COOLDOWN_HOURS * 3600;
|
|
|
|
|
+ }
|
|
|
|
|
+ return R.ok(msg.toString())
|
|
|
|
|
+ .put("success", success)
|
|
|
|
|
+ .put("skip", skip)
|
|
|
|
|
+ .put("fail", fail)
|
|
|
|
|
+ .put("failDetails", failDetails)
|
|
|
|
|
+ .put("cooldownRemainSeconds", cooldownRemainSeconds);
|
|
|
|
|
+ } catch (InterruptedException e) {
|
|
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
|
|
+ return R.error("系统繁忙,请稍后重试");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (lockAcquired && clickLock.isHeldByCurrentThread()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ clickLock.unlock();
|
|
|
|
|
+ } catch (IllegalMonitorStateException ignored) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Set<String> loadSentKeys(Long companyId, List<TodayFinishWatchForRedPacketVO> rows,
|
|
|
|
|
+ Date startTime, Date endTime) {
|
|
|
|
|
+ Set<String> sentKeys = new HashSet<>();
|
|
|
|
|
+ List<Long> userIds = new ArrayList<>();
|
|
|
|
|
+ for (TodayFinishWatchForRedPacketVO row : rows) {
|
|
|
|
|
+ if (row.getUserId() != null && !userIds.contains(row.getUserId())) {
|
|
|
|
|
+ userIds.add(row.getUserId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (userIds.isEmpty()) {
|
|
|
|
|
+ return sentKeys;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 只查今天已发记录,历史发放不计入跳过
|
|
|
|
|
+ List<FsCourseRedPacketLog> sentList = redPacketLogMapper.selectSentUserCourseByUserIds(
|
|
|
|
|
+ companyId, userIds, startTime, endTime);
|
|
|
|
|
+ if (sentList == null) {
|
|
|
|
|
+ return sentKeys;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (FsCourseRedPacketLog log : sentList) {
|
|
|
|
|
+ if (log.getUserId() != null && log.getCourseId() != null && log.getVideoId() != null) {
|
|
|
|
|
+ sentKeys.add(buildSentKey(log.getUserId(), log.getCourseId(), log.getVideoId()));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return sentKeys;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 已发放去重键:用户 + 课程 + 小节 */
|
|
|
|
|
+ private static String buildSentKey(Long userId, Long courseId, Long videoId) {
|
|
|
|
|
+ return userId + "_" + courseId + "_" + videoId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打印单个企微客户发红包决策日志:客户ID、发/不发、是否报错及原因
|
|
|
|
|
+ */
|
|
|
|
|
+ private void logRedPacketDecision(Long contactId, String externalUserId, String displayName,
|
|
|
|
|
+ TodayFinishWatchForRedPacketVO row, String decision, String reason, R sendResult) {
|
|
|
|
|
+ Object wxCode = sendResult != null ? sendResult.get("code") : null;
|
|
|
|
|
+ Object wxMsg = sendResult != null ? sendResult.get("msg") : null;
|
|
|
|
|
+ log.info("[销售一键发红包-明细] contactId={}, externalUserId={}, 客户名={}, userId={}, courseId={}, videoId={}, "
|
|
|
|
|
+ + "结果={}, 原因={}, wxCode={}, wxMsg={}",
|
|
|
|
|
+ contactId,
|
|
|
|
|
+ externalUserId,
|
|
|
|
|
+ displayName,
|
|
|
|
|
+ row != null ? row.getUserId() : null,
|
|
|
|
|
+ row != null ? row.getCourseId() : null,
|
|
|
|
|
+ row != null ? row.getVideoId() : null,
|
|
|
|
|
+ decision,
|
|
|
|
|
+ reason,
|
|
|
|
|
+ wxCode,
|
|
|
|
|
+ wxMsg);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Long parseExpireAt(Object cached) {
|
|
|
|
|
+ if (cached == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (cached instanceof Number) {
|
|
|
|
|
+ return ((Number) cached).longValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Long.parseLong(cached.toString());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 冷却 key:销售账号 + 维度(corp:{corpId} 或 qw:{qwUserId})
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String buildCooldownKey(Long companyUserId, String dimension) {
|
|
|
|
|
+ return COOLDOWN_KEY_PREFIX + companyUserId + ":" + dimension;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 点击锁 key:与冷却同一维度
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String buildClickLockKey(Long companyUserId, String dimension) {
|
|
|
|
|
+ return CLICK_LOCK_PREFIX + companyUserId + ":" + dimension;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 与领取红包一致:公司配置优先,否则小节金额 + 随机规则
|
|
|
|
|
+ */
|
|
|
|
|
+ private BigDecimal resolveAmount(Long videoId, Long companyId, Long courseId, FsUserCourseVideo video) {
|
|
|
|
|
+ BigDecimal amount = BigDecimal.ZERO;
|
|
|
|
|
+ FsUserCourseVideoRedPackage redPackage =
|
|
|
|
|
+ fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyIdZd(videoId, companyId);
|
|
|
|
|
+ if (redPackage != null && redPackage.getRedPacketMoney() != null) {
|
|
|
|
|
+ amount = redPackage.getRedPacketMoney();
|
|
|
|
|
+ } else if (video != null) {
|
|
|
|
|
+ amount = video.getRedPacketMoney() != null ? video.getRedPacketMoney() : BigDecimal.ZERO;
|
|
|
|
|
+ amount = courseRandomRedPacketService.resolveAmount(amount, courseId, companyId, video, true);
|
|
|
|
|
+ }
|
|
|
|
|
+ return amount;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析发红包所需 openid:
|
|
|
|
|
+ * 1)库中 qw_external_contact.open_id 已有则直接用;
|
|
|
|
|
+ * 2)否则调企微 externalContactConvertToOpenid,成功后回写 open_id。
|
|
|
|
|
+ * 说明:暂不支持企业微信外部联系人(external_userid 以 wo 开头)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String resolveOpenId(QwExternalContact contact) {
|
|
|
|
|
+ if (StringUtils.isNotEmpty(contact.getOpenId())) {
|
|
|
|
|
+ return contact.getOpenId();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(contact.getExternalUserId())
|
|
|
|
|
+ || StringUtil.strIsNullOrEmpty(contact.getCorpId())) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (contact.getExternalUserId().startsWith("wo")) {
|
|
|
|
|
+ log.warn("企微外部联系人不支持转openid, id={}, externalUserId={}",
|
|
|
|
|
+ contact.getId(), contact.getExternalUserId());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ QwOpenidByExternalcontactParams params = new QwOpenidByExternalcontactParams();
|
|
|
|
|
+ params.setExternal_userid(contact.getExternalUserId());
|
|
|
|
|
+ QwOpenidResult result = qwApiService.externalcontactToOpenid(params, contact.getCorpId());
|
|
|
|
|
+ if (result == null || result.getErrCode() != 0 || StringUtil.strIsNullOrEmpty(result.getOpenid())) {
|
|
|
|
|
+ log.error("外部联系人转openid失败, id={}, externalUserId={}, result={}",
|
|
|
|
|
+ contact.getId(), contact.getExternalUserId(), JSON.toJSONString(result));
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ contact.setOpenId(result.getOpenid());
|
|
|
|
|
+ QwExternalContact update = new QwExternalContact();
|
|
|
|
|
+ update.setId(contact.getId());
|
|
|
|
|
+ update.setOpenId(result.getOpenid());
|
|
|
|
|
+ qwExternalContactMapper.updateQwExternalContact(update);
|
|
|
|
|
+ return result.getOpenid();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("外部联系人转openid异常, id={}, externalUserId={}",
|
|
|
|
|
+ contact.getId(), contact.getExternalUserId(), e);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 对齐 sendRedPacketRewardToUser:可选 Redis 预扣 → sendRedPacket → 写流水 / 回滚
|
|
|
|
|
+ */
|
|
|
|
|
+ private R sendOneWithDeduction(WxSendRedPacketParam packetParam,
|
|
|
|
|
+ BigDecimal amount,
|
|
|
|
|
+ CourseConfig config,
|
|
|
|
|
+ Long companyId,
|
|
|
|
|
+ Long companyUserId,
|
|
|
|
|
+ QwExternalContact contact,
|
|
|
|
|
+ Long courseId,
|
|
|
|
|
+ Long videoId,
|
|
|
|
|
+ Long periodId,
|
|
|
|
|
+ Long watchLogId) {
|
|
|
|
|
+ if ("1".equals(config.getIsRedPackageBalanceDeduction())) {
|
|
|
|
|
+ BalanceRollbackError balanceRollbackError = new BalanceRollbackError();
|
|
|
|
|
+ balanceRollbackError.setCompanyId(companyId);
|
|
|
|
|
+ balanceRollbackError.setUserId(contact.getFsUserId() != null ? contact.getFsUserId() : packetParam.getUser().getUserId());
|
|
|
|
|
+ balanceRollbackError.setLogId(watchLogId);
|
|
|
|
|
+ balanceRollbackError.setVideoId(videoId);
|
|
|
|
|
+ balanceRollbackError.setStatus(0);
|
|
|
|
|
+ balanceRollbackError.setMoney(amount);
|
|
|
|
|
+
|
|
|
|
|
+ String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + companyId;
|
|
|
|
|
+ RLock lock1 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + companyId);
|
|
|
|
|
+ boolean lockAcquired = false;
|
|
|
|
|
+ BigDecimal newMoney;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (lock1.tryLock(3, 10, TimeUnit.SECONDS)) {
|
|
|
|
|
+ lockAcquired = true;
|
|
|
|
|
+ String moneyStr = redisCache.getCacheObject(companyMoneyKey);
|
|
|
|
|
+ if (StringUtils.isEmpty(moneyStr)) {
|
|
|
|
|
+ return R.error("系统异常,请稍后重试");
|
|
|
|
|
+ }
|
|
|
|
|
+ BigDecimal originalMoney = new BigDecimal(moneyStr);
|
|
|
|
|
+ if (originalMoney.compareTo(new BigDecimal("0.3")) < 0) {
|
|
|
|
|
+ return R.error("服务商余额不足,请联系群主服务器充值!");
|
|
|
|
|
+ }
|
|
|
|
|
+ newMoney = originalMoney.subtract(amount);
|
|
|
|
|
+ redisCache.setCacheObject(companyMoneyKey, newMoney.toString());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return R.error("系统繁忙,请稍后重试");
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("销售发红包预扣失败, companyId={}", companyId, e);
|
|
|
|
|
+ return R.error("系统异常,请稍后重试");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (lockAcquired && lock1.isHeldByCurrentThread()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ lock1.unlock();
|
|
|
|
|
+ } catch (IllegalMonitorStateException ignored) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ R sendRedPacket;
|
|
|
|
|
+ try {
|
|
|
|
|
+ sendRedPacket = paymentService.sendRedPacket(packetParam);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("销售发红包异常, contactId={}", contact.getId(), e);
|
|
|
|
|
+ rollbackBalance(balanceRollbackError);
|
|
|
|
|
+ return R.error("奖励发送失败:" + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (Integer.valueOf(200).equals(sendRedPacket.get("code"))) {
|
|
|
|
|
+ Long fsUserId = packetParam.getUser() != null ? packetParam.getUser().getUserId() : contact.getFsUserId();
|
|
|
|
|
+ insertRedPacketLog(sendRedPacket, companyId, companyUserId, contact, fsUserId,
|
|
|
|
|
+ courseId, videoId, periodId, amount, watchLogId, packetParam.getAppId(), config);
|
|
|
|
|
+ // 待用户确认收款时,写入 qw_sop_logs 待发送(高优先级链接卡片)
|
|
|
|
|
+ tryEnqueueConfirmReceiveSopLog(sendRedPacket, packetParam, contact, config, courseId);
|
|
|
|
|
+ BigDecimal money = amount.multiply(BigDecimal.valueOf(-1));
|
|
|
|
|
+ companyService.asyncRecordBalanceLog(companyId, money, 15, newMoney, "销售发放红包", fsUserId);
|
|
|
|
|
+ return sendRedPacket;
|
|
|
|
|
+ }
|
|
|
|
|
+ rollbackBalance(balanceRollbackError);
|
|
|
|
|
+ return R.error(resolveFailMsg(sendRedPacket));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ R sendRedPacket = paymentService.sendRedPacket(packetParam);
|
|
|
|
|
+ if (Integer.valueOf(200).equals(sendRedPacket.get("code"))) {
|
|
|
|
|
+ Long fsUserId = packetParam.getUser() != null ? packetParam.getUser().getUserId() : contact.getFsUserId();
|
|
|
|
|
+ insertRedPacketLog(sendRedPacket, companyId, companyUserId, contact, fsUserId,
|
|
|
|
|
+ courseId, videoId, periodId, amount, watchLogId, packetParam.getAppId(), config);
|
|
|
|
|
+ tryEnqueueConfirmReceiveSopLog(sendRedPacket, packetParam, contact, config, courseId);
|
|
|
|
|
+ return sendRedPacket;
|
|
|
|
|
+ }
|
|
|
|
|
+ return R.error(resolveFailMsg(sendRedPacket));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("销售发红包异常(无预扣), contactId={}", contact.getId(), e);
|
|
|
|
|
+ return R.error("奖励发送失败:" + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 写入课程领取红包流水,并回写看课记录奖励类型(对齐用户自领)
|
|
|
|
|
+ */
|
|
|
|
|
+ private void insertRedPacketLog(R sendRedPacket,
|
|
|
|
|
+ Long companyId,
|
|
|
|
|
+ Long companyUserId,
|
|
|
|
|
+ QwExternalContact contact,
|
|
|
|
|
+ Long fsUserId,
|
|
|
|
|
+ Long courseId,
|
|
|
|
|
+ Long videoId,
|
|
|
|
|
+ Long periodId,
|
|
|
|
|
+ BigDecimal amount,
|
|
|
|
|
+ Long watchLogId,
|
|
|
|
|
+ String appId,
|
|
|
|
|
+ CourseConfig config) {
|
|
|
|
|
+ FsCourseRedPacketLog redPacketLog = new FsCourseRedPacketLog();
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (sendRedPacket.get("isNew") != null && Integer.valueOf(1).equals(sendRedPacket.get("isNew"))) {
|
|
|
|
|
+ TransferBillsResult transferBillsResult = (TransferBillsResult) sendRedPacket.get("data");
|
|
|
|
|
+ redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
|
|
|
|
|
+ if (transferBillsResult != null) {
|
|
|
|
|
+ redPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (sendRedPacket.get("orderCode") != null) {
|
|
|
|
|
+ redPacketLog.setOutBatchNo(sendRedPacket.get("orderCode").toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sendRedPacket.get("mchId") != null) {
|
|
|
|
|
+ redPacketLog.setMchId(sendRedPacket.get("mchId").toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析发红包结果写入流水异常: {}", e.getMessage());
|
|
|
|
|
+ redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
|
|
|
|
|
+ }
|
|
|
|
|
+ redPacketLog.setCourseId(courseId);
|
|
|
|
|
+ redPacketLog.setCompanyId(companyId);
|
|
|
|
|
+ redPacketLog.setUserId(fsUserId);
|
|
|
|
|
+ redPacketLog.setVideoId(videoId);
|
|
|
|
|
+ redPacketLog.setStatus(0);
|
|
|
|
|
+ redPacketLog.setQwUserId(contact.getQwUserId() != null ? String.valueOf(contact.getQwUserId()) : null);
|
|
|
|
|
+ redPacketLog.setCompanyUserId(companyUserId);
|
|
|
|
|
+ redPacketLog.setCreateTime(new Date());
|
|
|
|
|
+ redPacketLog.setAmount(amount);
|
|
|
|
|
+ redPacketLog.setPeriodId(periodId);
|
|
|
|
|
+ redPacketLog.setWatchLogId(watchLogId);
|
|
|
|
|
+ redPacketLog.setAppId(appId);
|
|
|
|
|
+ redPacketLogMapper.insertFsCourseRedPacketLog(redPacketLog);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新看课记录奖励类型,便于领取红包记录关联展示与防重复领取
|
|
|
|
|
+ if (watchLogId != null && config != null && config.getRewardType() != null) {
|
|
|
|
|
+ FsCourseWatchLog watchLog = new FsCourseWatchLog();
|
|
|
|
|
+ watchLog.setLogId(watchLogId);
|
|
|
|
|
+ watchLog.setRewardType(config.getRewardType());
|
|
|
|
|
+ fsCourseWatchLogMapper.updateFsCourseWatchLog(watchLog);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 商家转账待用户确认时:拼 confirmReceive.html 链接,写入 qw_sop_logs 待发送(链接卡片)。
|
|
|
|
|
+ * 标题:课程已完成;描述:点击领取专属奖励;封面:课程 imgUrl。
|
|
|
|
|
+ * sendType=3(完课,约 12 小时有效);sort=9999999 高优先级。
|
|
|
|
|
+ * 域名与飞书 NEW 一致,取 course.config.realLinkDomainName。
|
|
|
|
|
+ * 入库失败不影响红包主流程成功。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void tryEnqueueConfirmReceiveSopLog(R sendRedPacket,
|
|
|
|
|
+ WxSendRedPacketParam packetParam,
|
|
|
|
|
+ QwExternalContact contact,
|
|
|
|
|
+ CourseConfig config,
|
|
|
|
|
+ Long courseId) {
|
|
|
|
|
+ Long contactId = contact != null ? contact.getId() : null;
|
|
|
|
|
+ String externalUserId = contact != null ? contact.getExternalUserId() : null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ String state = extractTransferState(sendRedPacket);
|
|
|
|
|
+ String packageInfo = extractPackageInfo(sendRedPacket);
|
|
|
|
|
+ String mchId = sendRedPacket.get("mchId") != null ? String.valueOf(sendRedPacket.get("mchId")) : null;
|
|
|
|
|
+ String appId = packetParam != null ? packetParam.getAppId() : null;
|
|
|
|
|
+ String outBillNo = extractOutBillNo(sendRedPacket);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("[销售发红包-确认收款] 开始处理 contactId={}, externalUserId={}, outBillNo={}, state={}, mchId={}, appId={}, courseId={}, packageEmpty={}",
|
|
|
|
|
+ contactId, externalUserId, outBillNo, state, mchId, appId, courseId, StringUtil.strIsNullOrEmpty(packageInfo));
|
|
|
|
|
+
|
|
|
|
|
+ if (!"WAIT_USER_CONFIRM".equals(state)) {
|
|
|
|
|
+ log.info("[销售发红包-确认收款] 非待确认状态,跳过写入待发送 contactId={}, state={}", contactId, state);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(packageInfo)) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] package_info 为空,无法生成链接 contactId={}, outBillNo={}", contactId, outBillNo);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(mchId) || StringUtil.strIsNullOrEmpty(appId)) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] mchId/appId 缺失,无法生成链接 contactId={}, mchId={}, appId={}", contactId, mchId, appId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (config == null || StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
|
|
|
|
|
+ log.error("[销售发红包-确认收款] 未配置通用看课域名(realLinkDomainName),无法生成链接 contactId={}", contactId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (contact == null || contact.getQwUserId() == null) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] 客户缺少企微账号 qwUserId,无法写入待发送 contactId={}", contactId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(externalUserId)) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] 客户 externalUserId 为空,无法写入待发送 contactId={}", contactId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ QwUser qwUser = qwUserMapper.selectQwUserById(contact.getQwUserId());
|
|
|
|
|
+ if (qwUser == null) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] 企微账号不存在 contactId={}, qwUserId={}", contactId, contact.getQwUserId());
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(qwUser.getQwUserId())) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] 企微账号 qwUserId 为空 contactId={}, qwUserPk={}", contactId, qwUser.getId());
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String confirmUrl = buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
|
|
|
|
|
+ String courseCover = resolveCourseCoverUrl(courseId);
|
|
|
|
|
+ log.info("[销售发红包-确认收款] 链接已生成,准备写入 qw_sop_logs 链接卡片 contactId={}, qwUserKey={}, outBillNo={}, courseId={}, coverEmpty={}, url={}",
|
|
|
|
|
+ contactId, qwUser.getId(), outBillNo, courseId, StringUtil.strIsNullOrEmpty(courseCover), confirmUrl);
|
|
|
|
|
+
|
|
|
|
|
+ // contentType=3:链接卡片(pad 走 sendLink)
|
|
|
|
|
+ QwSopCourseFinishTempSetting.Setting linkSetting = new QwSopCourseFinishTempSetting.Setting();
|
|
|
|
|
+ linkSetting.setContentType("3");
|
|
|
|
|
+ linkSetting.setLinkTitle("课程已完成");
|
|
|
|
|
+ linkSetting.setLinkDescribe("点击领取专属奖励");
|
|
|
|
|
+ linkSetting.setLinkUrl(confirmUrl);
|
|
|
|
|
+ linkSetting.setLinkImageUrl(courseCover);
|
|
|
|
|
+ List<QwSopCourseFinishTempSetting.Setting> settingList = new ArrayList<>();
|
|
|
|
|
+ settingList.add(linkSetting);
|
|
|
|
|
+
|
|
|
|
|
+ QwSopCourseFinishTempSetting settingRoot = new QwSopCourseFinishTempSetting();
|
|
|
|
|
+ settingRoot.setType(0);
|
|
|
|
|
+ settingRoot.setCourseType(0);
|
|
|
|
|
+ settingRoot.setSetting(settingList);
|
|
|
|
|
+
|
|
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
|
|
|
|
+ QwSopLogs sopLogs = new QwSopLogs();
|
|
|
|
|
+ sopLogs.setQwUserid(qwUser.getQwUserId());
|
|
|
|
|
+ sopLogs.setQwUserKey(qwUser.getId());
|
|
|
|
|
+ sopLogs.setExternalUserId(externalUserId);
|
|
|
|
|
+ sopLogs.setExternalId(contact.getId());
|
|
|
|
|
+ sopLogs.setExternalUserName(contact.getName());
|
|
|
|
|
+ sopLogs.setLogType(2);
|
|
|
|
|
+ sopLogs.setSendStatus(3L);
|
|
|
|
|
+ sopLogs.setSendType(CONFIRM_RECEIVE_SEND_TYPE);
|
|
|
|
|
+ sopLogs.setSort(CONFIRM_RECEIVE_SORT);
|
|
|
|
|
+ sopLogs.setReceivingStatus(0L);
|
|
|
|
|
+ sopLogs.setSendTime(sdf.format(new Date()));
|
|
|
|
|
+ sopLogs.setCorpId(StringUtils.isNotEmpty(contact.getCorpId()) ? contact.getCorpId() : qwUser.getCorpId());
|
|
|
|
|
+ sopLogs.setCompanyId(contact.getCompanyId() != null ? contact.getCompanyId() : qwUser.getCompanyId());
|
|
|
|
|
+ sopLogs.setFsUserId(contact.getFsUserId());
|
|
|
|
|
+ sopLogs.setRemark("销售发红包确认收款");
|
|
|
|
|
+ sopLogs.setContentJson(JSON.toJSONString(settingRoot));
|
|
|
|
|
+
|
|
|
|
|
+ int rows = qwSopLogsService.insertQwSopLogs(sopLogs);
|
|
|
|
|
+ log.info("[销售发红包-确认收款] qw_sop_logs 链接卡片已写入 contactId={}, externalUserId={}, qwUserKey={}, outBillNo={}, rows={}, sort={}, sendType={}, url={}",
|
|
|
|
|
+ contactId, externalUserId, qwUser.getId(), outBillNo, rows, CONFIRM_RECEIVE_SORT, CONFIRM_RECEIVE_SEND_TYPE, confirmUrl);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("[销售发红包-确认收款] 写入待发送异常(不影响红包主流程) contactId={}, externalUserId={}, err={}",
|
|
|
|
|
+ contactId, externalUserId, e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取课程封面:优先 imgUrl,其次 secondImg;查不到或为空则返回空串(仍发链接卡片)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String resolveCourseCoverUrl(Long courseId) {
|
|
|
|
|
+ if (courseId == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
|
|
|
|
|
+ if (course == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isNotEmpty(course.getImgUrl())) {
|
|
|
|
|
+ return course.getImgUrl();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isNotEmpty(course.getSecondImg())) {
|
|
|
|
|
+ return course.getSecondImg();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("[销售发红包-确认收款] 查询课程封面失败 courseId={}, err={}", courseId, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String buildConfirmReceiveUrl(String domainName, String mchId, String appId, String packageInfo) {
|
|
|
|
|
+ String domain = domainName.trim();
|
|
|
|
|
+ while (domain.endsWith("/")) {
|
|
|
|
|
+ domain = domain.substring(0, domain.length() - 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ return domain + "/confirmReceive.html"
|
|
|
|
|
+ + "?mchId=" + urlEncode(mchId)
|
|
|
|
|
+ + "&appId=" + urlEncode(appId)
|
|
|
|
|
+ + "&package=" + urlEncode(packageInfo);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String urlEncode(String value) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8.name());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return value == null ? "" : value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String extractPackageInfo(R sendRedPacket) {
|
|
|
|
|
+ if (sendRedPacket == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Object pkg = sendRedPacket.get("package");
|
|
|
|
|
+ if (pkg != null && StringUtils.isNotEmpty(String.valueOf(pkg))) {
|
|
|
|
|
+ return String.valueOf(pkg);
|
|
|
|
|
+ }
|
|
|
|
|
+ Object data = sendRedPacket.get("data");
|
|
|
|
|
+ if (data instanceof TransferBillsResult) {
|
|
|
|
|
+ return ((TransferBillsResult) data).getPackageInfo();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (data != null) {
|
|
|
|
|
+ com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
|
|
|
|
|
+ if (obj != null) {
|
|
|
|
|
+ String p = obj.getString("packageInfo");
|
|
|
|
|
+ if (StringUtils.isEmpty(p)) {
|
|
|
|
|
+ p = obj.getString("package_info");
|
|
|
|
|
+ }
|
|
|
|
|
+ return p;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String extractTransferState(R sendRedPacket) {
|
|
|
|
|
+ if (sendRedPacket == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Object data = sendRedPacket.get("data");
|
|
|
|
|
+ if (data instanceof TransferBillsResult) {
|
|
|
|
|
+ return ((TransferBillsResult) data).getState();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (data != null) {
|
|
|
|
|
+ com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
|
|
|
|
|
+ if (obj != null) {
|
|
|
|
|
+ return obj.getString("state");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String extractOutBillNo(R sendRedPacket) {
|
|
|
|
|
+ if (sendRedPacket == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Object data = sendRedPacket.get("data");
|
|
|
|
|
+ if (data instanceof TransferBillsResult) {
|
|
|
|
|
+ return ((TransferBillsResult) data).getOutBillNo();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (data != null) {
|
|
|
|
|
+ com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
|
|
|
|
|
+ if (obj != null) {
|
|
|
|
|
+ String no = obj.getString("outBillNo");
|
|
|
|
|
+ if (StringUtils.isEmpty(no)) {
|
|
|
|
|
+ no = obj.getString("out_bill_no");
|
|
|
|
|
+ }
|
|
|
|
|
+ return no;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Object orderCode = sendRedPacket.get("orderCode");
|
|
|
|
|
+ return orderCode != null ? String.valueOf(orderCode) : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void rollbackBalance(BalanceRollbackError balanceRollbackError) {
|
|
|
|
|
+ String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + balanceRollbackError.getCompanyId();
|
|
|
|
|
+ RLock lock2 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + balanceRollbackError.getCompanyId());
|
|
|
|
|
+ boolean lockAcquired = false;
|
|
|
|
|
+ boolean backError = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (lock2.tryLock(3, 10, TimeUnit.SECONDS)) {
|
|
|
|
|
+ lockAcquired = true;
|
|
|
|
|
+ String currentMoneyStr = redisCache.getCacheObject(companyMoneyKey);
|
|
|
|
|
+ if (StringUtils.isEmpty(currentMoneyStr)) {
|
|
|
|
|
+ throw new RuntimeException("回滚余额异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ BigDecimal rollbackMoney = new BigDecimal(currentMoneyStr).add(balanceRollbackError.getMoney());
|
|
|
|
|
+ redisCache.setCacheObject(companyMoneyKey, rollbackMoney.toString());
|
|
|
|
|
+ backError = false;
|
|
|
|
|
+ } else if (backError) {
|
|
|
|
|
+ balanceRollbackErrorMapper.insert(balanceRollbackError);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("销售发红包回滚余额异常, companyId={}", balanceRollbackError.getCompanyId(), e);
|
|
|
|
|
+ if (backError) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ balanceRollbackErrorMapper.insert(balanceRollbackError);
|
|
|
|
|
+ } catch (Exception ignore) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (lockAcquired && lock2.isHeldByCurrentThread()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ lock2.unlock();
|
|
|
|
|
+ } catch (IllegalMonitorStateException ignored) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 侧边栏发红包:按外部联系人+员工企微号找人;
|
|
|
|
|
+ * 完课判定=今天 + 该销售(company_user_id) + 该客户(不限制 watch_type);
|
|
|
|
|
+ * 已领取提示;已发未领则复用原 confirmReceive 链接;可发则走与一键发红包相同转账并返回链接。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public R prepareSidebarRedPacketLink(SidebarSendRedPacketParam param) {
|
|
|
|
|
+ if (param == null || StringUtil.strIsNullOrEmpty(param.getCorpId())
|
|
|
|
|
+ || StringUtil.strIsNullOrEmpty(param.getQwUserId())
|
|
|
|
|
+ || StringUtil.strIsNullOrEmpty(param.getExternalUserId())) {
|
|
|
|
|
+ return R.error("参数不完整,请刷新侧边栏后重试");
|
|
|
|
|
+ }
|
|
|
|
|
+ String corpId = param.getCorpId().trim();
|
|
|
|
|
+ String qwUserIdStr = param.getQwUserId().trim();
|
|
|
|
|
+ String externalUserId = param.getExternalUserId().trim();
|
|
|
|
|
+
|
|
|
|
|
+ QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(corpId, qwUserIdStr);
|
|
|
|
|
+ if (qwUser == null || qwUser.getCompanyId() == null || qwUser.getCompanyUserId() == null) {
|
|
|
|
|
+ return R.error("员工未绑定销售公司,请先绑定后再试");
|
|
|
|
|
+ }
|
|
|
|
|
+ Long companyId = qwUser.getCompanyId();
|
|
|
|
|
+ Long companyUserId = qwUser.getCompanyUserId();
|
|
|
|
|
+
|
|
|
|
|
+ QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactByExternalUserIdAndQwUserId(
|
|
|
|
|
+ externalUserId, corpId, qwUserIdStr);
|
|
|
|
|
+ if (contact == null) {
|
|
|
|
|
+ return R.error("未找到该外部联系人,请确认客户归属");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (contact.getCompanyId() != null && !companyId.equals(contact.getCompanyId())) {
|
|
|
|
|
+ return R.error("无权操作该客户");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
|
|
+ CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
|
|
|
|
|
+ if (config == null) {
|
|
|
|
|
+ return R.error("课程配置为空,请联系管理员");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
|
|
|
|
|
+ return R.error("未配置看课域名,无法生成领取链接");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 完课判定:今天 + 该销售(company_user_id) + 该客户
|
|
|
|
|
+ LocalDate today = LocalDate.now();
|
|
|
|
|
+ Date startTime = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
|
|
|
+ Date endTime = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
|
|
|
+ TodayFinishWatchForRedPacketVO row = fsCourseWatchLogMapper.selectTodayFinishWatchForSidebarSales(
|
|
|
|
|
+ companyId, companyUserId, contact.getId(), startTime, endTime);
|
|
|
|
|
+ if (row == null || row.getUserId() == null || row.getCourseId() == null || row.getVideoId() == null) {
|
|
|
|
|
+ log.info("[侧边栏发红包] 今日该销售下未完课 companyId={}, companyUserId={}, contactId={}, externalUserId={}",
|
|
|
|
|
+ companyId, companyUserId, contact.getId(), externalUserId);
|
|
|
|
|
+ return R.error("该客户今天在此销售下尚未完课,无法发红包")
|
|
|
|
|
+ .put("action", "not_finished")
|
|
|
|
|
+ .put("canSend", false);
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("[侧边栏发红包] 命中今日完课 companyId={}, companyUserId={}, contactId={}, courseId={}, videoId={}, logId={}",
|
|
|
|
|
+ companyId, companyUserId, contact.getId(), row.getCourseId(), row.getVideoId(), row.getLogId());
|
|
|
|
|
+
|
|
|
|
|
+ FsCourseRedPacketLog existLog = redPacketLogMapper.selectTodayRedPacketLog(
|
|
|
|
|
+ companyId, row.getUserId(), row.getCourseId(), row.getVideoId(), startTime, endTime);
|
|
|
|
|
+ if (existLog != null) {
|
|
|
|
|
+ if (Integer.valueOf(1).equals(existLog.getStatus())) {
|
|
|
|
|
+ return R.error("该客户今日红包已领取,无需重复发送")
|
|
|
|
|
+ .put("action", "claimed")
|
|
|
|
|
+ .put("canSend", false);
|
|
|
|
|
+ }
|
|
|
|
|
+ // status=0 发送中/待确认:复用原链接
|
|
|
|
|
+ String reuseUrl = rebuildConfirmUrlFromLog(existLog, config);
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(reuseUrl)) {
|
|
|
|
|
+ return R.error("红包已发放但领取链接已失效,请稍后重试或联系管理员")
|
|
|
|
|
+ .put("action", "link_expired")
|
|
|
|
|
+ .put("canSend", false);
|
|
|
|
|
+ }
|
|
|
|
|
+ String cover = resolveCourseCoverUrl(row.getCourseId());
|
|
|
|
|
+ log.info("[侧边栏发红包] 复用已有链接 contactId={}, externalUserId={}, logId={}, courseId={}, videoId={}",
|
|
|
|
|
+ contact.getId(), externalUserId, existLog.getLogId(), row.getCourseId(), row.getVideoId());
|
|
|
|
|
+ return R.ok("红包链接已生成,可再次发送")
|
|
|
|
|
+ .put("action", "resent")
|
|
|
|
|
+ .put("canSend", true)
|
|
|
|
|
+ .put("linkUrl", reuseUrl)
|
|
|
|
|
+ .put("linkTitle", "课程已完成")
|
|
|
|
|
+ .put("linkDescribe", "点击领取专属奖励")
|
|
|
|
|
+ .put("linkImageUrl", cover);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ RLock clickLock = redissonClient.getLock(
|
|
|
|
|
+ "sidebar:redpacket:click:" + companyId + ":" + contact.getId() + ":" + row.getVideoId());
|
|
|
|
|
+ boolean lockAcquired = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!clickLock.tryLock(3, 30, TimeUnit.SECONDS)) {
|
|
|
|
|
+ return R.error("正在发放中,请勿重复点击").put("canSend", false).put("action", "busy");
|
|
|
|
|
+ }
|
|
|
|
|
+ lockAcquired = true;
|
|
|
|
|
+
|
|
|
|
|
+ // 锁内再查一次,避免并发重复创建
|
|
|
|
|
+ existLog = redPacketLogMapper.selectTodayRedPacketLog(
|
|
|
|
|
+ companyId, row.getUserId(), row.getCourseId(), row.getVideoId(), startTime, endTime);
|
|
|
|
|
+ if (existLog != null) {
|
|
|
|
|
+ if (Integer.valueOf(1).equals(existLog.getStatus())) {
|
|
|
|
|
+ return R.error("该客户今日红包已领取,无需重复发送")
|
|
|
|
|
+ .put("action", "claimed")
|
|
|
|
|
+ .put("canSend", false);
|
|
|
|
|
+ }
|
|
|
|
|
+ String reuseUrl = rebuildConfirmUrlFromLog(existLog, config);
|
|
|
|
|
+ if (StringUtils.isNotEmpty(reuseUrl)) {
|
|
|
|
|
+ return R.ok("红包链接已生成,可再次发送")
|
|
|
|
|
+ .put("action", "resent")
|
|
|
|
|
+ .put("canSend", true)
|
|
|
|
|
+ .put("linkUrl", reuseUrl)
|
|
|
|
|
+ .put("linkTitle", "课程已完成")
|
|
|
|
|
+ .put("linkDescribe", "点击领取专属奖励")
|
|
|
|
|
+ .put("linkImageUrl", resolveCourseCoverUrl(row.getCourseId()));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ FsUser user = fsUserMapper.selectFsUserByUserId(row.getUserId());
|
|
|
|
|
+ if (user == null) {
|
|
|
|
|
+ return R.error("会员不存在").put("canSend", false).put("action", "fail");
|
|
|
|
|
+ }
|
|
|
|
|
+ FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(row.getVideoId());
|
|
|
|
|
+ BigDecimal amount = resolveAmount(row.getVideoId(), companyId, row.getCourseId(), video);
|
|
|
|
|
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
|
|
|
+ return R.error("红包金额为0,无法发放").put("canSend", false).put("action", "fail");
|
|
|
|
|
+ }
|
|
|
|
|
+ String openId = resolveOpenId(contact);
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(openId)) {
|
|
|
|
|
+ String reason = (StringUtils.isNotEmpty(contact.getExternalUserId())
|
|
|
|
|
+ && contact.getExternalUserId().startsWith("wo"))
|
|
|
|
|
+ ? "企微客户不支持转openid" : "openid转换失败";
|
|
|
|
|
+ return R.error(reason).put("canSend", false).put("action", "fail");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
|
|
|
|
|
+ packetParam.setOpenId(openId);
|
|
|
|
|
+ packetParam.setAmount(amount);
|
|
|
|
|
+ packetParam.setCompanyId(companyId);
|
|
|
|
|
+ packetParam.setSource(2);
|
|
|
|
|
+ packetParam.setRedPacketMode(config.getRedPacketMode());
|
|
|
|
|
+ packetParam.setUser(user);
|
|
|
|
|
+ String contactCorpId = StringUtils.isNotEmpty(contact.getCorpId()) ? contact.getCorpId() : corpId;
|
|
|
|
|
+ packetParam.setCorpId(contactCorpId);
|
|
|
|
|
+ packetParam.setAppId(contactCorpId);
|
|
|
|
|
+
|
|
|
|
|
+ R sendResult = sendOneWithDeduction(packetParam, amount, config,
|
|
|
|
|
+ companyId, companyUserId, contact, row.getCourseId(),
|
|
|
|
|
+ row.getVideoId(), row.getPeriodId(), row.getLogId());
|
|
|
|
|
+ if (sendResult == null || !Integer.valueOf(200).equals(sendResult.get("code"))) {
|
|
|
|
|
+ return R.error(resolveFailMsg(sendResult)).put("canSend", false).put("action", "fail");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String state = extractTransferState(sendResult);
|
|
|
|
|
+ String packageInfo = extractPackageInfo(sendResult);
|
|
|
|
|
+ String mchId = sendResult.get("mchId") != null ? String.valueOf(sendResult.get("mchId")) : null;
|
|
|
|
|
+ String appId = packetParam.getAppId();
|
|
|
|
|
+ if (!"WAIT_USER_CONFIRM".equals(state) || StringUtil.strIsNullOrEmpty(packageInfo)
|
|
|
|
|
+ || StringUtil.strIsNullOrEmpty(mchId) || StringUtil.strIsNullOrEmpty(appId)) {
|
|
|
|
|
+ log.warn("[侧边栏发红包] 转账成功但无法生成确认链接 contactId={}, state={}, packageEmpty={}",
|
|
|
|
|
+ contact.getId(), state, StringUtil.strIsNullOrEmpty(packageInfo));
|
|
|
|
|
+ return R.error("红包已发起,但暂无确认收款链接(可能已自动到账)")
|
|
|
|
|
+ .put("action", "no_confirm_link")
|
|
|
|
|
+ .put("canSend", false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String confirmUrl = buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
|
|
|
|
|
+ String cover = resolveCourseCoverUrl(row.getCourseId());
|
|
|
|
|
+ log.info("[侧边栏发红包] 新建链接成功 contactId={}, externalUserId={}, courseId={}, videoId={}, amount={}",
|
|
|
|
|
+ contact.getId(), externalUserId, row.getCourseId(), row.getVideoId(), amount);
|
|
|
|
|
+ return R.ok("红包链接已生成")
|
|
|
|
|
+ .put("action", "send")
|
|
|
|
|
+ .put("canSend", true)
|
|
|
|
|
+ .put("linkUrl", confirmUrl)
|
|
|
|
|
+ .put("linkTitle", "课程已完成")
|
|
|
|
|
+ .put("linkDescribe", "点击领取专属奖励")
|
|
|
|
|
+ .put("linkImageUrl", cover);
|
|
|
|
|
+ } catch (InterruptedException e) {
|
|
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
|
|
+ return R.error("系统繁忙,请稍后重试").put("canSend", false).put("action", "busy");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (lockAcquired && clickLock.isHeldByCurrentThread()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ clickLock.unlock();
|
|
|
|
|
+ } catch (IllegalMonitorStateException ignored) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 从已有红包流水 result 中还原 confirmReceive 领取链接
|
|
|
|
|
+ */
|
|
|
|
|
+ private String rebuildConfirmUrlFromLog(FsCourseRedPacketLog existLog, CourseConfig config) {
|
|
|
|
|
+ if (existLog == null || config == null || StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String packageInfo = null;
|
|
|
|
|
+ String mchId = existLog.getMchId();
|
|
|
|
|
+ String appId = existLog.getAppId();
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (StringUtils.isNotEmpty(existLog.getResult())) {
|
|
|
|
|
+ com.alibaba.fastjson.JSONObject root = JSON.parseObject(existLog.getResult());
|
|
|
|
|
+ if (root != null) {
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(mchId) && root.get("mchId") != null) {
|
|
|
|
|
+ mchId = String.valueOf(root.get("mchId"));
|
|
|
|
|
+ }
|
|
|
|
|
+ Object pkg = root.get("package");
|
|
|
|
|
+ if (pkg != null && StringUtils.isNotEmpty(String.valueOf(pkg))) {
|
|
|
|
|
+ packageInfo = String.valueOf(pkg);
|
|
|
|
|
+ }
|
|
|
|
|
+ com.alibaba.fastjson.JSONObject data = root.getJSONObject("data");
|
|
|
|
|
+ if (data != null) {
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(packageInfo)) {
|
|
|
|
|
+ packageInfo = data.getString("packageInfo");
|
|
|
|
|
+ if (StringUtils.isEmpty(packageInfo)) {
|
|
|
|
|
+ packageInfo = data.getString("package_info");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(appId) && StringUtils.isNotEmpty(data.getString("appId"))) {
|
|
|
|
|
+ appId = data.getString("appId");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("[侧边栏发红包] 解析已有红包 result 失败 logId={}, err={}", existLog.getLogId(), e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtil.strIsNullOrEmpty(packageInfo) || StringUtil.strIsNullOrEmpty(mchId)
|
|
|
|
|
+ || StringUtil.strIsNullOrEmpty(appId)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String resolveFailMsg(R sendRedPacket) {
|
|
|
|
|
+ if (sendRedPacket == null) {
|
|
|
|
|
+ return "发放失败";
|
|
|
|
|
+ }
|
|
|
|
|
+ Object msg = sendRedPacket.get("msg");
|
|
|
|
|
+ return msg != null ? msg.toString() : "发放失败";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|