Преглед изворни кода

Merge branch 'refs/heads/master' into master-clq-temp

caoliqin пре 2 недеља
родитељ
комит
7fcb2da957
51 измењених фајлова са 1785 додато и 35 уклоњено
  1. 6 0
      fs-admin/src/main/java/com/fs/his/task/Task.java
  2. 73 0
      fs-admin/src/main/java/com/fs/task/MiniProgramSubTask.java
  3. 83 1
      fs-ipad-task/src/main/java/com/fs/app/task/SendSmsMsg.java
  4. 71 9
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  5. 8 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchCommentServiceImpl.java
  6. 1 1
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  7. 26 0
      fs-service/src/main/java/com/fs/his/config/IntegralConfig.java
  8. 28 0
      fs-service/src/main/java/com/fs/his/constant/IntegralTaskConstants.java
  9. 3 0
      fs-service/src/main/java/com/fs/his/domain/FsIntegralGoods.java
  10. 20 0
      fs-service/src/main/java/com/fs/his/domain/FsUserIntegralBatch.java
  11. 7 0
      fs-service/src/main/java/com/fs/his/domain/FsUserIntegralLogs.java
  12. 2 0
      fs-service/src/main/java/com/fs/his/enums/FsUserIntegralLogTypeEnum.java
  13. 4 1
      fs-service/src/main/java/com/fs/his/mapper/FsIntegralGoodsMapper.java
  14. 19 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralBatchMapper.java
  15. 4 0
      fs-service/src/main/java/com/fs/his/param/FsIntegralGoodsListUParam.java
  16. 3 0
      fs-service/src/main/java/com/fs/his/param/FsUserAddIntegralTemplateParam.java
  17. 11 0
      fs-service/src/main/java/com/fs/his/param/IntegralBrowseCompleteParam.java
  18. 12 0
      fs-service/src/main/java/com/fs/his/param/IntegralBrowseStartParam.java
  19. 30 0
      fs-service/src/main/java/com/fs/his/param/SignNotifyParam.java
  20. 25 0
      fs-service/src/main/java/com/fs/his/service/IIntegralTaskService.java
  21. 15 0
      fs-service/src/main/java/com/fs/his/service/ISignSubscribeNotifyService.java
  22. 85 10
      fs-service/src/main/java/com/fs/his/service/impl/FsUserIntegralLogsServiceImpl.java
  23. 437 0
      fs-service/src/main/java/com/fs/his/service/impl/IntegralTaskServiceImpl.java
  24. 103 0
      fs-service/src/main/java/com/fs/his/service/impl/SignSubscribeNotifyServiceImpl.java
  25. 73 0
      fs-service/src/main/java/com/fs/his/utils/IntegralConfigHelper.java
  26. 140 0
      fs-service/src/main/java/com/fs/his/utils/IntegralTaskScopeHelper.java
  27. 4 0
      fs-service/src/main/java/com/fs/his/vo/FsIntegralGoodsListVO.java
  28. 3 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsIntegralGoodsScrmMapper.java
  29. 4 0
      fs-service/src/main/java/com/fs/hisStore/param/FsIntegralGoodsListUParam.java
  30. 69 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  31. 5 0
      fs-service/src/main/java/com/fs/live/domain/LiveMiniprogramSubNotifyTask.java
  32. 16 2
      fs-service/src/main/java/com/fs/live/mapper/LiveMiniprogramSubNotifyTaskMapper.java
  33. 5 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
  34. 2 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  35. 130 0
      fs-service/src/main/java/com/fs/qw/utils/QwExternalContactRemarkMarkHelper.java
  36. 3 0
      fs-service/src/main/java/com/fs/sop/mapper/QwSopLogsMapper.java
  37. 1 0
      fs-service/src/main/resources/application-config-druid-sxjz.yml
  38. 2 2
      fs-service/src/main/resources/application-config-druid-sxsm.yml
  39. 4 4
      fs-service/src/main/resources/application-druid-sxsm.yml
  40. 72 0
      fs-service/src/main/resources/db/changelog/changes/20260613-live-user-add-is-del.sql
  41. 39 0
      fs-service/src/main/resources/db/changelog/dictData/dict_data_update.sql
  42. 3 0
      fs-service/src/main/resources/mapper/fastGpt/FastGptChatSessionMapper.xml
  43. 5 1
      fs-service/src/main/resources/mapper/his/FsIntegralGoodsMapper.xml
  44. 25 0
      fs-service/src/main/resources/mapper/his/FsUserIntegralBatchMapper.xml
  45. 4 0
      fs-service/src/main/resources/mapper/his/FsUserIntegralLogsMapper.xml
  46. 3 0
      fs-service/src/main/resources/mapper/live/LiveMiniprogramSubNotifyTaskMapper.xml
  47. 29 0
      fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml
  48. 9 2
      fs-user-app/src/main/java/com/fs/app/controller/CommonController.java
  49. 38 1
      fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java
  50. 13 0
      fs-user-app/src/main/java/com/fs/app/controller/UserController.java
  51. 8 1
      fs-user-app/src/main/java/com/fs/app/controller/store/CommonScrmController.java

+ 6 - 0
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -148,6 +148,8 @@ public class Task {
     @Autowired
     IFsUserIntegralLogsService fsUserIntegralLogsService;
     @Autowired
+    IIntegralTaskService integralTaskService;
+    @Autowired
     SmsServiceImpl smsService;
     @Autowired
     FsStoreOrderItemMapper fsStoreOrderItemMapper;
@@ -1259,6 +1261,10 @@ public class Task {
         fsUserIntegralLogsService.subFsUserIntegralLogsByOrder5();
     }
 
+    public void processExpiredIntegralBatches() {
+        integralTaskService.processExpiredIntegralBatches();
+    }
+
     public void finishInquiry() {
         List<FsInquiryOrder> orders = inquiryOrderMapper.selectFsInquiryOrderByFinish();
         for (FsInquiryOrder order : orders) {

+ 73 - 0
fs-admin/src/main/java/com/fs/task/MiniProgramSubTask.java

@@ -3,6 +3,7 @@ package com.fs.task;
 import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.TypeReference;
+import com.fs.his.mapper.FsUserSignMapper;
 import com.fs.live.domain.LiveMiniprogramSubNotifyTask;
 import com.fs.live.mapper.LiveMiniprogramSubNotifyTaskMapper;
 import com.fs.store.enums.MiniAppNotifyTaskStatusEnum;
@@ -34,6 +35,8 @@ public class MiniProgramSubTask {
 
     private final LiveMiniprogramSubNotifyTaskMapper notifyTaskMapper;
 
+    private final FsUserSignMapper fsUserSignMapper;
+
     private WxMaProperties.Config config = null;
 
     @Autowired
@@ -108,4 +111,74 @@ public class MiniProgramSubTask {
         }
 
     }
+
+    /**
+     * 签到提醒订阅消息:发送计划时间已到且今日未签到的用户
+     */
+    public void notifySignSubscribe() {
+        log.info("小程序签到提醒订阅通知定时任务开始");
+        List<LiveMiniprogramSubNotifyTask> pendingData = notifyTaskMapper.selectSignPendingData();
+        if (CollectionUtils.isEmpty(pendingData)) {
+            log.info("小程序签到提醒订阅通知定时任务, 无待处理数据");
+            return;
+        }
+        LocalDateTime now = LocalDateTime.now();
+        for (LiveMiniprogramSubNotifyTask pendingDatum : pendingData) {
+            if (pendingDatum.getUpdateTime() != null && pendingDatum.getUpdateTime().isAfter(now)) {
+                continue;
+            }
+            Long userId = pendingDatum.getFsUserId();
+            if (userId == null) {
+                pendingDatum.setStatus(MiniAppNotifyTaskStatusEnum.FAILED.getValue());
+                pendingDatum.setErrorMessage("缺少fsUserId,无法校验签到状态");
+                pendingDatum.setRetryCount(pendingDatum.getRetryCount() + 1);
+                pendingDatum.setUpdateTime(LocalDateTime.now());
+                continue;
+            }
+            if (fsUserSignMapper.getToDayIsSign(userId) > 0) {
+                pendingDatum.setStatus(MiniAppNotifyTaskStatusEnum.CANCELED.getValue());
+                pendingDatum.setErrorMessage("今日已签到,跳过发送");
+                pendingDatum.setUpdateTime(LocalDateTime.now());
+                continue;
+            }
+            sendSubscribeMessage(pendingDatum);
+        }
+        notifyTaskMapper.updateBatchById(pendingData);
+        log.info("小程序签到提醒订阅通知定时任务结束, 处理数量={}", pendingData.size());
+    }
+
+    private void sendSubscribeMessage(LiveMiniprogramSubNotifyTask pendingDatum) {
+        pendingDatum.setUpdateTime(LocalDateTime.now());
+        ClientCredGrantReqDTO clientCredGrantReqDTO = new ClientCredGrantReqDTO();
+        clientCredGrantReqDTO.setAppid(config.getAppid());
+        clientCredGrantReqDTO.setSecret(config.getSecret());
+        clientCredGrantReqDTO.setGrant_type("client_credential");
+        try {
+            WeXinAccessTokenDTO stableToken = wechatMiniProgrService.getStableToken(clientCredGrantReqDTO);
+            String accessToken = stableToken.getAccessToken();
+
+            TemplateMessageSendRequestDTO sendRequestDTO = new TemplateMessageSendRequestDTO();
+            sendRequestDTO.setTemplate_id(pendingDatum.getTemplateId());
+            sendRequestDTO.setTouser(pendingDatum.getTouser());
+            sendRequestDTO.setPage(pendingDatum.getPage());
+            TypeReference<Map<String, TemplateMessageSendRequestDTO.TemplateDataValue>> typeReference =
+                    new TypeReference<Map<String, TemplateMessageSendRequestDTO.TemplateDataValue>>() {};
+            sendRequestDTO.setData(JSON.parseObject(pendingDatum.getData(), typeReference));
+            MiniGramSubsMsgResultDTO result = wechatMiniProgrService.sendSubscribeMsg(accessToken, sendRequestDTO);
+            pendingDatum.setRequestBody(JSON.toJSONString(sendRequestDTO));
+            pendingDatum.setResponseBody(JSON.toJSONString(result));
+            if (result.getErrcode() == 0) {
+                pendingDatum.setStatus(MiniAppNotifyTaskStatusEnum.SUCCESS.getValue());
+            } else {
+                pendingDatum.setStatus(MiniAppNotifyTaskStatusEnum.FAILED.getValue());
+                pendingDatum.setErrorMessage(JSON.toJSONString(result));
+                pendingDatum.setRetryCount(pendingDatum.getRetryCount() + 1);
+            }
+        } catch (Throwable e) {
+            pendingDatum.setStatus(MiniAppNotifyTaskStatusEnum.FAILED.getValue());
+            pendingDatum.setErrorMessage(ExceptionUtils.getStackTrace(e));
+            pendingDatum.setRetryCount(pendingDatum.getRetryCount() + 1);
+            log.error("小程序签到提醒订阅通知异常, taskId={}", pendingDatum.getId(), e);
+        }
+    }
 }

+ 83 - 1
fs-ipad-task/src/main/java/com/fs/app/task/SendSmsMsg.java

@@ -7,6 +7,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.service.ISmsService;
+import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.CompanySmsLogs;
 import com.fs.company.mapper.CompanySmsLogsMapper;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -116,6 +117,8 @@ public class SendSmsMsg {
     // 批量失败状态更新阈值
     private static final int BATCH_UPDATE_SIZE = 1000;
 
+    private static final String JINAN_LIANZHI_HEALTH = "济南联志健康";
+
     public SendSmsMsg(QwIpadServerMapper qwIpadServerMapper,
                       IQwSopSmsLogsService qwSopSmsLogsService,
                       @Lazy ISmsService smsService,
@@ -304,7 +307,9 @@ public class SendSmsMsg {
      */
     private SendResult processServerBatch(List<QwSopSmsLogs> batch, Map<Long, QwSopLogs> sopLogsMap, boolean sendByContactMobile) {
         int success = 0;
+        boolean updateSuccessStatus = isJinanLianzhiHealth();
         List<SendResultDetailDTO> failReasonsList = Collections.synchronizedList(new ArrayList<>());
+        List<QwSopSmsLogs> successRecords = Collections.synchronizedList(new ArrayList<>());
         List<Long> failedIds = Collections.synchronizedList(new ArrayList<>());
         // 批量获取手机号
         Map<Long, String> phoneMap = resolvePhones(batch, sendByContactMobile);
@@ -342,6 +347,9 @@ public class SendSmsMsg {
                 SendResultDetailDTO detail = sendSingleSms(logRecord, phoneMap, redisKey);
                 if (detail.isSuccess()) {
                     success++;
+                    if (updateSuccessStatus) {
+                        successRecords.add(logRecord);
+                    }
                 } else {
                     redisCache.deleteObject(redisKey);
                     failReasonsList.add(detail);
@@ -360,16 +368,27 @@ public class SendSmsMsg {
                 failReasonsList.clear();
                 failedIds.clear();
             }
+            if (updateSuccessStatus && successRecords.size() >= BATCH_UPDATE_SIZE) {
+                submitSuccessUpdate(new ArrayList<>(successRecords), sopLogsMap);
+                successRecords.clear();
+            }
         }
 
         // 提交剩余的失败记录
         if (!failReasonsList.isEmpty()) {
             submitFailedUpdate(failedIds, failReasonsList,sopLogsMap);
         }
+        if (updateSuccessStatus && !successRecords.isEmpty()) {
+            submitSuccessUpdate(successRecords, sopLogsMap);
+        }
 
         return new SendResult(success, failedIds.size(), failedIds);
     }
 
+    private boolean isJinanLianzhiHealth() {
+        return JINAN_LIANZHI_HEALTH.equals(SpringUtils.getProperty("cloud_host.company_name"));
+    }
+
     /**
      * 解析待发送记录的手机号(key 为 qw_sop_sms_logs.id)
      */
@@ -510,7 +529,7 @@ public class SendSmsMsg {
             );
 
             if (r != null && "200".equals(String.valueOf(r.get("code")))) {
-                return new SendResultDetailDTO(true, null, null);
+                return new SendResultDetailDTO(true, null, logRecord.getSopLogId());
             } else {
                 String msg = r != null && r.get("msg") != null ? r.get("msg").toString() : "未知错误";
                 log.warn("短信发送失败 id={}, phone={}, msg={}", logRecord.getId(), phone, msg);
@@ -522,6 +541,69 @@ public class SendSmsMsg {
         }
     }
 
+    /**
+     * 异步批量更新成功状态(济南联志健康:短信接口返回成功后立即标记为发送成功)
+     */
+    private void submitSuccessUpdate(List<QwSopSmsLogs> successRecords, Map<Long, QwSopLogs> sopLogsMap) {
+        if (successRecords == null || successRecords.isEmpty()) {
+            return;
+        }
+        statusExecutor.submit(() -> {
+            try {
+                List<Long> successIds = successRecords.stream()
+                        .map(QwSopSmsLogs::getId)
+                        .collect(Collectors.toList());
+                qwSopSmsLogsService.updateStatusByIds(successIds, "2");
+
+                List<QwSopLogs> updateList = successRecords.stream().map(record -> {
+                            QwSopLogs qwSopLogs = sopLogsMap.get(record.getSopLogId());
+                            if (qwSopLogs == null) {
+                                return null;
+                            }
+                            if (qwSopLogs.getSendStatus() == 5 || qwSopLogs.getReceivingStatus() == 4) {
+                                return null;
+                            }
+
+                            QwSopLogs update = new QwSopLogs();
+                            update.setSmsLogsId(record.getSopLogId());
+                            update.setSendStatus(1L);
+                            update.setReceivingStatus(1L);
+                            if (sopLogsMap.containsKey(record.getSopLogId())) {
+                                try {
+                                    String contentJson = sopLogsMap.get(record.getSopLogId()).getContentJson();
+                                    JSONObject jsonObject = JSONObject.parseObject(contentJson);
+                                    JSONArray settingArray = jsonObject.getJSONArray("setting");
+                                    if (settingArray != null && !settingArray.isEmpty()) {
+                                        Integer smsIndex = record.getSmsIndex();
+                                        if (smsIndex != null && smsIndex >= 0 && smsIndex < settingArray.size()) {
+                                            settingArray.getJSONObject(smsIndex).put("sendStatus", "1");
+                                        } else {
+                                            for (int i = 0; i < settingArray.size(); i++) {
+                                                settingArray.getJSONObject(i).put("sendStatus", "1");
+                                            }
+                                        }
+                                        update.setContentJson(jsonObject.toJSONString());
+                                    }
+                                } catch (Exception e) {
+                                    log.warn("解析ContentJson失败,sopLogId:{}", record.getSopLogId(), e);
+                                    update.setContentJson(sopLogsMap.get(record.getSopLogId()).getContentJson());
+                                }
+                            }
+                            return update;
+                        })
+                        .filter(Objects::nonNull)
+                        .collect(Collectors.toList());
+
+                if (!updateList.isEmpty()) {
+                    qwSopLogsMapper.batchUpdateSuccessQwSopLogsBySmsLogsId(updateList);
+                }
+                log.debug("批量更新成功状态完成,条数:{}", successIds.size());
+            } catch (Exception e) {
+                log.error("批量更新成功状态异常", e);
+            }
+        });
+    }
+
     /**
      * 异步批量更新失败状态
      */

+ 71 - 9
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -6,9 +6,11 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.redis.RedisCache;
 
 import com.fs.common.service.ISmsService;
+import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
@@ -51,6 +53,7 @@ import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
+import com.fs.system.service.ISysDictTypeService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.google.gson.Gson;
 import lombok.Synchronized;
@@ -132,6 +135,16 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private ISysDictTypeService sysDictTypeService;
+
+    private static final String SMS_SEND_URL_CONFIG = "sms_send_url_config";
+    private static final long DICT_SORT_SMS_SEND_URL_ENABLED = 1L;
+    private static final long DICT_SORT_SMS_SEND_URL_URL = 2L;
+    private static final long DICT_SORT_SMS_SEND_URL_ACCOUNT = 3L;
+    private static final long DICT_SORT_SMS_SEND_URL_PASSWORD = 4L;
+    private static final long DICT_SORT_SMS_SEND_URL_CODE = 5L;
+
     private static final String  LINK_SUFFIX = "?customer_channel=up:";
 
     @Override
@@ -643,13 +656,24 @@ public class SmsServiceImpl implements ISmsService
 
             try {
                 content = content.replace("${sms.sign}",sms.getRfSign());
-                if (temp.getTempType().equals(1)) {
-                    urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
-                } else if (temp.getTempType().equals(2)) {
-                    urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
-                }
-                if(StringUtils.isEmpty(urls)){
-                    urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
+                if(CloudHostUtils.hasCloudHostName("鸿森堂")){
+                    if (temp.getTempType().equals(1)) {
+                        urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content="  + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
+                    } else if (temp.getTempType().equals(2)) {
+                        urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
+                    }
+                    if(StringUtils.isEmpty(urls)){
+                        urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
+                    }
+                } else {
+                    if (temp.getTempType().equals(1)) {
+                        urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
+                    } else if (temp.getTempType().equals(2)) {
+                        urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
+                    }
+                    if(StringUtils.isEmpty(urls)){
+                        urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(sms.getRfSign() + content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
+                    }
                 }
             } catch (UnsupportedEncodingException e) {
                 e.printStackTrace();
@@ -727,8 +751,11 @@ public class SmsServiceImpl implements ISmsService
         FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
         if (sms.getType().equals("rf")) {
             try {
-//                content = content.replace("${sms.sign}",sms.getRfSign2());
-                urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
+                String rfUrl = resolveRfSendUrlParam(DICT_SORT_SMS_SEND_URL_URL, sms.getRfUrl2());
+                String rfAccount = resolveRfSendUrlParam(DICT_SORT_SMS_SEND_URL_ACCOUNT, sms.getRfAccount2());
+                String rfPassword = resolveRfSendUrlParam(DICT_SORT_SMS_SEND_URL_PASSWORD, sms.getRfPassword2());
+                String rfCode = resolveRfSendUrlParam(DICT_SORT_SMS_SEND_URL_CODE, sms.getRfCode2());
+                urls = rfUrl + "sms?action=send&account=" + rfAccount + "&password=" + rfPassword + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + rfCode + "&rt=json";
             } catch (UnsupportedEncodingException e) {
                 log.error("{}发送失败", phone, e);
                 return R.error("短信发送失败:" + e.getMessage());
@@ -818,6 +845,41 @@ public class SmsServiceImpl implements ISmsService
         return R.ok();
     }
 
+    private boolean isSmsSendUrlConfigEnabled() {
+        List<SysDictData> dictList = sysDictTypeService.selectDictDataByType(SMS_SEND_URL_CONFIG);
+        if (dictList == null || dictList.isEmpty()) {
+            return false;
+        }
+        return dictList.stream()
+                .filter(item -> "0".equals(item.getStatus()))
+                .filter(item -> item.getDictSort() != null && DICT_SORT_SMS_SEND_URL_ENABLED == item.getDictSort())
+                .anyMatch(item -> "1".equals(StringUtils.trimToEmpty(item.getDictValue())));
+    }
+
+    private String getSmsSendUrlDictValue(long dictSort) {
+        List<SysDictData> dictList = sysDictTypeService.selectDictDataByType(SMS_SEND_URL_CONFIG);
+        if (dictList == null || dictList.isEmpty()) {
+            return null;
+        }
+        for (SysDictData item : dictList) {
+            if (!"0".equals(item.getStatus())) {
+                continue;
+            }
+            if (item.getDictSort() != null && dictSort == item.getDictSort()) {
+                return StringUtils.trimToEmpty(item.getDictValue());
+            }
+        }
+        return null;
+    }
+
+    private String resolveRfSendUrlParam(long dictSort, String defaultValue) {
+        if (!isSmsSendUrlConfigEnabled()) {
+            return defaultValue;
+        }
+        String configValue = getSmsSendUrlDictValue(dictSort);
+        return StringUtils.isNotBlank(configValue) ? configValue : defaultValue;
+    }
+
     /**
      * 发送简单短信
      * @param phone 接收方手机号

+ 8 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchCommentServiceImpl.java

@@ -24,6 +24,7 @@ import org.springframework.stereotype.Service;
 import com.fs.course.mapper.FsCourseWatchCommentMapper;
 import com.fs.course.domain.FsCourseWatchComment;
 import com.fs.course.service.IFsCourseWatchCommentService;
+import com.fs.his.service.IIntegralTaskService;
 
 /**
  * 看课评论Service业务层处理
@@ -48,6 +49,9 @@ public class FsCourseWatchCommentServiceImpl extends ServiceImpl<FsCourseWatchCo
     @Autowired
     private SysKeywordMapper mapper;
 
+    @Autowired
+    private IIntegralTaskService integralTaskService;
+
     /**
      * 查询看课评论
      *
@@ -148,6 +152,10 @@ public class FsCourseWatchCommentServiceImpl extends ServiceImpl<FsCourseWatchCo
         fsCourseWatchComment.setCreateTime(DateUtils.getNowDate());
         int i = baseMapper.insertFsCourseWatchComment(fsCourseWatchComment);
         if (i > 0) {
+            int success = integralTaskService.tryGrantCourseCommentIntegral(param.getUserId(), param.getVideoId(), fsCourseWatchComment.getCommentId());
+            if (1 == success) {
+                return R.ok().put("status", true).put("toast", "评价积分已发放");
+            }
             return R.ok().put("status", true);
         } else {
             return R.error();

+ 1 - 1
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -1841,7 +1841,7 @@ public class AiHookServiceImpl implements AiHookService {
                             String courseTitleName = log.getCourseName() + log.getTitle();
                             courseTitleName = courseTitleName.replaceAll("[【】]", "");
                             userInfo.put("课程章节",courseTitleName);
-                            userInfo.put("课状态",log.getLogType()==3?"待看课":log.getLogType()==1?"已完课":log.getLogType()==2?"已完课":"看课中断");
+                            userInfo.put("课状态",log.getLogType()==3?"待看课":log.getLogType()==1?"已完课":log.getLogType()==2?"已完课":"看课中断");
                         }
                         Object value = map.get(name);
                         if(value != null){

+ 26 - 0
fs-service/src/main/java/com/fs/his/config/IntegralConfig.java

@@ -3,6 +3,7 @@ package com.fs.his.config;
 import lombok.Data;
 
 import java.io.Serializable;
+import java.util.Map;
 
 @Data
 public class IntegralConfig implements Serializable {
@@ -28,4 +29,29 @@ public class IntegralConfig implements Serializable {
     private Integer integralSubscriptCourse;//付费课程订阅积分比例
     private Integer downloadAppIntegral; // 首次下载app获取积分
     private Integer integralPlayGame; //  游戏获取积分
+
+    /** 全渠道首次完成购物奖励积分 */
+    private Integer integralFirstPurchase;
+    /** 邀请新用户单日上限次数 */
+    private Integer integralInviteDailyLimit;
+    /** 分享单日上限次数(APP+小程序合计) */
+    private Integer integralShareDailyLimit;
+    /** 观看课程单日上限次数 */
+    private Integer integralCourseDailyLimit;
+    /** 浏览商城单日上限次数 */
+    private Integer integralProductDailyLimit;
+    /** 浏览商城所需秒数 */
+    private Integer integralProductBrowseSeconds;
+    /** 点播课留言积分 */
+    private Integer integralComment;
+    /** 积分有效天数 */
+    private Integer integralExpireDays;
+    /** 积分有效期功能上线日(历史积分迁移基准) yyyy-MM-dd */
+    private String integralExpireLaunchDate;
+
+    /** 任务展示端配置,key=任务code,value=app / mini / app,mini */
+    private Map<String, String> taskDisplayScope;
+
+    /** 积分流水类型展示端配置,key=logType(dict_value),value=app / mini / app,mini */
+    private Map<String, String> logTypeDisplayScope;
 }

+ 28 - 0
fs-service/src/main/java/com/fs/his/constant/IntegralTaskConstants.java

@@ -0,0 +1,28 @@
+package com.fs.his.constant;
+
+/**
+ * 0701 任务体系常量
+ */
+public final class IntegralTaskConstants {
+
+    private IntegralTaskConstants() {
+    }
+
+    public static final String TASK_SIGN = "SIGN";
+    public static final String TASK_REGISTER = "REGISTER";
+    public static final String TASK_BIND_PHONE = "BIND_PHONE";
+    public static final String TASK_FIRST_PURCHASE = "FIRST_PURCHASE";
+    public static final String TASK_DOWNLOAD_APP = "DOWNLOAD_APP";
+    public static final String TASK_INVITE = "INVITE";
+    public static final String TASK_SHARE = "SHARE";
+    public static final String TASK_COURSE = "COURSE";
+    public static final String TASK_BROWSE_MALL = "BROWSE_MALL";
+    public static final String TASK_INVITED = "INVITED";
+
+    public static final String REDIS_BROWSE_PREFIX = "integral:browse:";
+    public static final String REDIS_SIGN_POPUP_PREFIX = "integral:sign:popup:";
+    public static final String REDIS_TASK_DONE_PREFIX = "integral:task:done:";
+
+    /** 新手任务完成后多少小时隐藏 */
+    public static final int NEWBIE_COMPLETED_HIDE_HOURS = 24;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/his/domain/FsIntegralGoods.java

@@ -70,6 +70,9 @@ public class FsIntegralGoods extends BaseEntity
 
     private String appIds;
 
+    /** 可见范围 app,mini */
+    private String visibleScope;
+
     @TableField(exist = false)
     private String appId;
 

+ 20 - 0
fs-service/src/main/java/com/fs/his/domain/FsUserIntegralBatch.java

@@ -0,0 +1,20 @@
+package com.fs.his.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 用户积分批次 fs_user_integral_batch
+ */
+@Data
+public class FsUserIntegralBatch extends BaseEntity {
+
+    private Long batchId;
+    private Long userId;
+    private Long amount;
+    private Long remain;
+    private Date expireTime;
+    private Long sourceLogId;
+}

+ 7 - 0
fs-service/src/main/java/com/fs/his/domain/FsUserIntegralLogs.java

@@ -6,6 +6,7 @@ import io.swagger.models.auth.In;
 import lombok.Data;
 
 import java.math.BigDecimal;
+import java.util.Date;
 
 /**
  * 积分记录对象 fs_user_integral_logs
@@ -50,4 +51,10 @@ public class FsUserIntegralLogs extends BaseEntity
     private String phone;
 
     private BigDecimal commission;
+
+    /** 该笔获得积分过期时间 */
+    private Date expireTime;
+
+    /** 来源端 app/mini */
+    private String platform;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/his/enums/FsUserIntegralLogTypeEnum.java

@@ -39,6 +39,8 @@ public enum FsUserIntegralLogTypeEnum {
     TYPE_29( 29,"玩游戏获取积分"),
     TYPE_30(30, "购物积分发放"),
     TYPE_31(31, "购物积分退款扣除"),
+    TYPE_32(32, "首次完成购物"),
+    TYPE_33(33, "内容留言积分"),
     ;
 
 

+ 4 - 1
fs-service/src/main/java/com/fs/his/mapper/FsIntegralGoodsMapper.java

@@ -68,7 +68,7 @@ public interface FsIntegralGoodsMapper
      */
     public int deleteFsIntegralGoodsByGoodsIds(Long[] goodsIds);
 
-    @Select({"<script> select goods_id,bar_code, img_url, images, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, descs, create_time from fs_integral_goods" +
+    @Select({"<script> select goods_id,bar_code, img_url, images, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, visible_scope, descs, create_time from fs_integral_goods" +
             "<where>  \n" +
             "            <if test=\"goodsName != null  and goodsName != ''\"> and goods_name like concat('%', #{goodsName}, '%')</if>\n" +
             "            <if test=\"goodsType != null \"> and goods_type = #{goodsType}</if>\n" +
@@ -93,6 +93,9 @@ public interface FsIntegralGoodsMapper
             "<if test = 'maps.appId != null and maps.appId != \" \" '> " +
             " and ((FIND_IN_SET(#{maps.appId}, g.app_ids) > 0)) " +
             "</if>"+
+            "<if test = 'maps.platform != null and maps.platform != \"\" '> " +
+            " and ((FIND_IN_SET(#{maps.platform}, g.visible_scope) > 0) or g.visible_scope is null or g.visible_scope = '') " +
+            "</if>"+
             " order by g.goods_id desc "+
             "</script>"})
     List<FsIntegralGoodsListUVO> selectFsIntegralGoodsListUVO(@Param("maps")FsIntegralGoodsListUParam param);

+ 19 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralBatchMapper.java

@@ -0,0 +1,19 @@
+package com.fs.his.mapper;
+
+import com.fs.his.domain.FsUserIntegralBatch;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface FsUserIntegralBatchMapper {
+
+    int insertFsUserIntegralBatch(FsUserIntegralBatch batch);
+
+    List<FsUserIntegralBatch> selectExpiredBatches(@Param("now") Date now,
+                                                   @Param("launchDate") Date launchDate,
+                                                   @Param("expireDays") int expireDays,
+                                                   @Param("limit") int limit);
+
+    int updateRemain(@Param("batchId") Long batchId, @Param("remain") Long remain);
+}

+ 4 - 0
fs-service/src/main/java/com/fs/his/param/FsIntegralGoodsListUParam.java

@@ -30,4 +30,8 @@ public class FsIntegralGoodsListUParam  implements Serializable {
     private Integer status;
 
     private String appId;
+
+    /** 可见端 app / mini(兼容旧参数) */
+    @ApiModelProperty(value = "可见端 app/mini,兼容旧参数 platform")
+    private String platform;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/his/param/FsUserAddIntegralTemplateParam.java

@@ -15,4 +15,7 @@ public class FsUserAddIntegralTemplateParam {
     private Integer points;
 
     private String remark;
+
+    /** 来源端 app / mini */
+    private String platform;
 }

+ 11 - 0
fs-service/src/main/java/com/fs/his/param/IntegralBrowseCompleteParam.java

@@ -0,0 +1,11 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class IntegralBrowseCompleteParam implements Serializable {
+    private String browseToken;
+    private String platform;
+}

+ 12 - 0
fs-service/src/main/java/com/fs/his/param/IntegralBrowseStartParam.java

@@ -0,0 +1,12 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class IntegralBrowseStartParam implements Serializable {
+    private String pageType;
+    private Long goodsId;
+    private String platform;
+}

+ 30 - 0
fs-service/src/main/java/com/fs/his/param/SignNotifyParam.java

@@ -0,0 +1,30 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.util.HashMap;
+
+/**
+ * 签到提醒小程序订阅参数(模板与内容由前端传入)
+ */
+@Data
+public class SignNotifyParam {
+
+    private String templateId;
+
+    private String maOpenId;
+
+    private Long userId;
+
+    private String appId;
+
+    /** 点击消息跳转页面,默认任务中心 */
+    private String page;
+
+    /**
+     * 计划发送时间 yyyy-MM-dd HH:mm:ss,不传则默认次日 08:00
+     */
+    private String sendTime;
+
+    private HashMap<String, String> data;
+}

+ 25 - 0
fs-service/src/main/java/com/fs/his/service/IIntegralTaskService.java

@@ -0,0 +1,25 @@
+package com.fs.his.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.his.param.IntegralBrowseCompleteParam;
+import com.fs.his.param.IntegralBrowseStartParam;
+
+/**
+ * 0701 任务体系
+ */
+public interface IIntegralTaskService {
+
+    R getTaskCenter(Long userId, String platform);
+
+    R startBrowse(Long userId, IntegralBrowseStartParam param);
+
+    R completeBrowse(Long userId, IntegralBrowseCompleteParam param);
+
+    void tryGrantFirstPurchase(Long userId, Long orderId);
+
+    int tryGrantCourseCommentIntegral(Long userId, Long videoId, Long commentId);
+
+    void processExpiredIntegralBatches();
+
+    void markSignPopupShown(Long userId);
+}

+ 15 - 0
fs-service/src/main/java/com/fs/his/service/ISignSubscribeNotifyService.java

@@ -0,0 +1,15 @@
+package com.fs.his.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.his.param.SignNotifyParam;
+
+/**
+ * 签到提醒小程序订阅
+ */
+public interface ISignSubscribeNotifyService {
+
+    /**
+     * 用户订阅签到提醒,写入待发送任务
+     */
+    R subNotifySign(SignNotifyParam param);
+}

+ 85 - 10
fs-service/src/main/java/com/fs/his/service/impl/FsUserIntegralLogsServiceImpl.java

@@ -9,9 +9,11 @@ import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.domain.FsUserNewTask;
 import com.fs.his.enums.FsUserIntegralLogTypeEnum;
+import com.fs.his.mapper.FsUserIntegralBatchMapper;
 import com.fs.his.mapper.FsUserIntegralLogsMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserNewTaskMapper;
+import com.fs.his.domain.FsUserIntegralBatch;
 import com.fs.his.param.FsUserAddIntegralParam;
 import com.fs.his.param.FsUserAddIntegralTemplateParam;
 import com.fs.his.param.FsUserIntegralLogsListUParam;
@@ -19,8 +21,10 @@ import com.fs.his.param.FsUserIntegralLogsParam;
 import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.his.vo.FsUserIntegralLogsListUVO;
 import com.fs.his.vo.FsUserIntegralLogsListVO;
+import com.fs.his.utils.IntegralConfigHelper;
 import com.fs.his.vo.SubIntegralVO;
 import com.fs.system.service.ISysConfigService;
+import cn.hutool.core.date.DateUtil;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -49,6 +53,8 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
     private ISysConfigService configService;
     @Autowired
     private FsUserNewTaskMapper fsUserNewTaskMapper;
+    @Autowired
+    private FsUserIntegralBatchMapper fsUserIntegralBatchMapper;
     /**
      * 查询积分记录
      *
@@ -196,7 +202,7 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
         FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
         if (user!=null){
             String json =configService.selectConfigByKey("his.integral");
-            IntegralConfig config= JSONUtil.toBean(json,IntegralConfig.class);
+            IntegralConfig config= IntegralConfigHelper.load(configService);
             System.out.println("Types: " + Arrays.toString(config.getIntegralTypeByOneDay()));
             System.out.println("User ID: " + param.getUserId());
             Integer todayTotalIntegral= fsUserIntegralLogsMapper.selectTodayTotalIntegral(config.getIntegralTypeByOneDay(),param.getUserId());
@@ -227,7 +233,7 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
         FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
         if (user!=null){
             String json =configService.selectConfigByKey("his.integral");
-            IntegralConfig config= JSONUtil.toBean(json,IntegralConfig.class);
+            IntegralConfig config= IntegralConfigHelper.load(configService);
             Integer[] integralTypeByOneDay = config.getIntegralTypeByOneDay();
             System.out.println("Types: " + Arrays.toString(integralTypeByOneDay));
             System.out.println("User ID: " + param.getUserId());
@@ -267,8 +273,8 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
                     break;
                 case 3: //分享获得积分
                     integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), LocalDate.now());
-                    if (integralLogs.size() >= 5){
-                        return R.error("今日看课积分已达上限");
+                    if (integralLogs.size() >= config.getIntegralShareDailyLimit()){
+                        return R.error("今日分享积分已达上限");
                     }
                     integralNum = config.getIntegralShare();
                     logsType = FsUserIntegralLogTypeEnum.TYPE_3;
@@ -296,7 +302,7 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
 //                    break;
                 case 10: //观看课程获取积分
                     integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), LocalDate.now());
-                    if (integralLogs.size() >= 3){
+                    if (integralLogs.size() >= config.getIntegralCourseDailyLimit()){
                         return R.error("今日看课积分已达上限");
                     }
                     integralNum = config.getIntegralCourse();
@@ -319,6 +325,10 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
                     logsType = FsUserIntegralLogTypeEnum.TYPE_12;
                     break;
                 case 13: //浏览商品获得积分
+                    integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), LocalDate.now());
+                    if (integralLogs.size() >= config.getIntegralProductDailyLimit()){
+                        return R.error("今日浏览商城积分已达上限");
+                    }
                     integralNum = config.getIntegralProduct();
                     logsType = FsUserIntegralLogTypeEnum.TYPE_13;
                     break;
@@ -334,6 +344,10 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
 //                    logsType = FsUserIntegralLogTypeEnum.TYPE_17;
 //                    break;
                 case 18: //邀请新用户获取积分
+                    integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), LocalDate.now());
+                    if (integralLogs.size() >= config.getIntegralInviteDailyLimit()){
+                        return R.error("今日邀请积分已达上限");
+                    }
                     integralNum = config.getIntegralInvite();
                     logsType = FsUserIntegralLogTypeEnum.TYPE_18;
                     break;
@@ -388,13 +402,34 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
                         param.setRemark(logsType.getDesc());
                     }
                     break;
+                case 32: // 首次完成购物
+                    integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), null);
+                    if (!integralLogs.isEmpty()){
+                        return R.error("已领取过该积分");
+                    }
+                    integralNum = config.getIntegralFirstPurchase();
+                    logsType = FsUserIntegralLogTypeEnum.TYPE_32;
+                    break;
+                case 33: // 点播课留言积分
+                    integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(param.getUserId(),param.getLogType(), null);
+                    if (StringUtils.isNotBlank(param.getBusinessId())) {
+                        for (FsUserIntegralLogs item : integralLogs) {
+                            if (param.getBusinessId().equals(item.getBusinessId())) {
+                                return R.error("该素材留言积分已发放");
+                            }
+                        }
+                    }
+                    integralNum = config.getIntegralComment();
+                    logsType = FsUserIntegralLogTypeEnum.TYPE_33;
+                    break;
                 default:
                     return R.error("积分类型错误,联系管理员");
             }
             if (integralNum!=null && logsType!=null){
-                R r = updateAddIntegral(integralNum, user.getUserId(), user.getIntegral(), logsType, param.getBusinessId(), param.getRemark());
-                log.info("用户id为{},{}", user.getUserId(),r.get("msg").toString());
-                return R.ok();
+                R r = updateAddIntegral(integralNum, user.getUserId(), user.getIntegral(), logsType, param.getBusinessId(), param.getRemark(), param.getPlatform());
+                log.info("用户id为{},{}", user.getUserId(),r.get("msg"));
+                return r.put("taskName", logsType.getDesc()).put("rewardPoints", integralNum)
+                        .put("toast", logsType.getDesc() + " +" + integralNum + "积分");
             }
 
         }
@@ -408,25 +443,46 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
      * @param userIntegral
      * @return
      */
-    public R updateAddIntegral(Integer integralNum, Long userId, Long userIntegral, FsUserIntegralLogTypeEnum logsType, String businessId, String remark) {
+    public R updateAddIntegral(Integer integralNum, Long userId, Long userIntegral, FsUserIntegralLogTypeEnum logsType, String businessId, String remark, String platform) {
+        IntegralConfig config = IntegralConfigHelper.load(configService);
         FsUser userMap=new FsUser();
         userMap.setUserId(userId);
         userMap.setIntegral(userIntegral+integralNum);
         fsUserMapper.updateFsUser(userMap);
+        Date now = new Date();
+        Date expireTime = DateUtil.offsetDay(now, config.getIntegralExpireDays());
         FsUserIntegralLogs integralLogs = new FsUserIntegralLogs();
         integralLogs.setIntegral(integralNum.longValue());
         integralLogs.setUserId(userId);
         integralLogs.setBalance(userMap.getIntegral());
         integralLogs.setLogType(logsType.getValue());
-        integralLogs.setCreateTime(new Date());
+        integralLogs.setCreateTime(now);
         integralLogs.setBusinessId(businessId);
         integralLogs.setRemark(remark);
+        integralLogs.setExpireTime(expireTime);
+        integralLogs.setPlatform(platform);
         if (fsUserIntegralLogsMapper.insertFsUserIntegralLogs(integralLogs)>0){
+            createIntegralBatch(userId, integralNum.longValue(), expireTime, integralLogs.getId());
             return R.ok(logsType.getDesc()+":"+integralNum+"积分");
         }
         return R.error("获取积分失败");
     }
 
+    private void createIntegralBatch(Long userId, Long amount, Date expireTime, Long sourceLogId) {
+        try {
+            FsUserIntegralBatch batch = new FsUserIntegralBatch();
+            batch.setUserId(userId);
+            batch.setAmount(amount);
+            batch.setRemain(amount);
+            batch.setExpireTime(expireTime);
+            batch.setSourceLogId(sourceLogId);
+            batch.setCreateTime(new Date());
+            fsUserIntegralBatchMapper.insertFsUserIntegralBatch(batch);
+        } catch (Exception e) {
+            log.warn("写入积分批次失败,请确认已执行 20250701-任务体系.sql: {}", e.getMessage());
+        }
+    }
+
     private R addVideoIntegral(Integer integralFirstVideo,Integer integralFinishVideo,Long userId,Long userIntegral) {
         Integer videoIntegralCount = fsUserIntegralLogsMapper.selectTodayVideoIntegralCount(userId);
         Integer addIntegral = integralFirstVideo-videoIntegralCount;
@@ -499,6 +555,9 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
         int isNewUser = 1;
         int isFinishConsultation = 0;
         int isFinishFirstOrderPoint = 0;
+        int taskSix = 0;
+        int isFinishDownloadAppIntegral = 0;
+        int isFinishFirstPurchase = 0;
 //        long expireDays = 0;
         Date createTime = null;
 //        FsUser fsUser = fsUserMapper.selectFsUserIsNew(userId);
@@ -546,14 +605,30 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
             isFinishFirstOrderPoint = 1;
         }
 
+        integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, FsUserIntegralLogTypeEnum.TYPE_19.getValue(), null);
+        if (!integralLogs.isEmpty()) {
+            taskSix = 1;
+        }
+        integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, FsUserIntegralLogTypeEnum.TYPE_28.getValue(), null);
+        if (!integralLogs.isEmpty()) {
+            isFinishDownloadAppIntegral = 1;
+        }
+        integralLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, FsUserIntegralLogTypeEnum.TYPE_32.getValue(), null);
+        if (!integralLogs.isEmpty()) {
+            isFinishFirstPurchase = 1;
+        }
+
         Map<String,Object> map = new HashMap<>();
         map.put("taskOne",taskOne);
         map.put("taskTwo",taskTwo);
         map.put("taskThree",taskThree);
         map.put("taskFour",taskFour);
         map.put("taskFive",taskFive);
+        map.put("taskSix", taskSix);
         map.put("isFinishConsultation", isFinishConsultation);
         map.put("isFinishFirstOrderPoint", isFinishFirstOrderPoint);
+        map.put("isFinishDownloadAppIntegral", isFinishDownloadAppIntegral);
+        map.put("isFinishFirstPurchase", isFinishFirstPurchase);
         return R.ok().put("data",map).put("isNewUser",isNewUser).put("createTime",createTime);
     }
 

+ 437 - 0
fs-service/src/main/java/com/fs/his/service/impl/IntegralTaskServiceImpl.java

@@ -0,0 +1,437 @@
+package com.fs.his.service.impl;
+
+import cn.hutool.core.date.DateUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.uuid.IdUtils;
+import com.fs.his.config.IntegralConfig;
+import com.fs.his.constant.IntegralTaskConstants;
+import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserIntegralBatch;
+import com.fs.his.domain.FsUserIntegralLogs;
+import com.fs.his.enums.FsUserIntegralLogTypeEnum;
+import com.fs.his.mapper.FsUserIntegralBatchMapper;
+import com.fs.his.mapper.FsUserIntegralLogsMapper;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.param.FsUserAddIntegralTemplateParam;
+import com.fs.his.param.IntegralBrowseCompleteParam;
+import com.fs.his.param.IntegralBrowseStartParam;
+import com.fs.his.service.IFsUserIntegralLogsService;
+import com.fs.his.service.IFsUserSignService;
+import com.fs.his.service.IIntegralTaskService;
+import com.fs.his.utils.IntegralConfigHelper;
+import com.fs.his.utils.IntegralTaskScopeHelper;
+import com.fs.system.service.ISysConfigService;
+import org.apache.commons.lang3.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 org.springframework.transaction.support.TransactionTemplate;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class IntegralTaskServiceImpl implements IIntegralTaskService {
+
+    private static final Logger log = LoggerFactory.getLogger(IntegralTaskServiceImpl.class);
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+    @Autowired
+    private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
+    @Autowired
+    private IFsUserIntegralLogsService userIntegralLogsService;
+    @Autowired
+    private IFsUserSignService userSignService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FsUserIntegralBatchMapper fsUserIntegralBatchMapper;
+    @Autowired
+    private TransactionTemplate transactionTemplate;
+
+    @Override
+    public R getTaskCenter(Long userId, String platform) {
+        IntegralConfig config = IntegralConfigHelper.load(configService);
+        FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+        if (user == null) {
+            return R.error("用户不存在");
+        }
+
+        String normalizedPlatform = IntegralTaskScopeHelper.normalizePlatform(platform);
+        Map<String, String> taskDisplayScope = IntegralTaskScopeHelper.resolveTaskDisplayScope(config);
+
+        String signJson = configService.selectConfigByKey("his.sign");
+        Long signNum = userSignService.getSign(user);
+        Boolean isDaySign = userSignService.isDaySign(user);
+        String popupKey = IntegralTaskConstants.REDIS_SIGN_POPUP_PREFIX + userId + ":" + LocalDate.now();
+        boolean needPopup = !Boolean.TRUE.equals(isDaySign) && redisCache.getCacheObject(popupKey) == null;
+
+        Map<String, Object> sign = new HashMap<>();
+        sign.put("signNum", signNum);
+        sign.put("isDaySign", isDaySign);
+        sign.put("needPopup", needPopup);
+        sign.put("configs", signJson);
+        sign.put("visibleScope", IntegralTaskScopeHelper.getTaskDisplayScope(taskDisplayScope, IntegralTaskConstants.TASK_SIGN));
+        sign.put("hidden", !IntegralTaskScopeHelper.isTaskVisible(taskDisplayScope, IntegralTaskConstants.TASK_SIGN, normalizedPlatform));
+
+        List<Map<String, Object>> newbieTasks = filterTasksByPlatform(buildNewbieTasks(userId, config, taskDisplayScope), taskDisplayScope, normalizedPlatform);
+        List<Map<String, Object>> dailyTasks = filterTasksByPlatform(buildDailyTasks(userId, config, taskDisplayScope), taskDisplayScope, normalizedPlatform);
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("platform", normalizedPlatform);
+        data.put("sign", sign);
+        data.put("newbieTasks", newbieTasks);
+        data.put("dailyTasks", dailyTasks);
+        data.put("integralBalance", user.getIntegral());
+        data.put("expireTip", buildExpireTip(config));
+        return R.ok().put("data", data);
+    }
+
+    @Override
+    public R startBrowse(Long userId, IntegralBrowseStartParam param) {
+        IntegralConfig config = IntegralConfigHelper.load(configService);
+        List<FsUserIntegralLogs> todayLogs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(
+                userId, FsUserIntegralLogTypeEnum.TYPE_13.getValue(), LocalDate.now());
+        if (todayLogs.size() >= config.getIntegralProductDailyLimit()) {
+            return R.error("今日浏览商城积分已达上限");
+        }
+        String token = IdUtils.simpleUUID();
+        Map<String, Object> cache = new HashMap<>();
+        cache.put("userId", userId);
+        cache.put("startTime", System.currentTimeMillis());
+        cache.put("pageType", param.getPageType());
+        cache.put("goodsId", param.getGoodsId());
+        cache.put("platform", param.getPlatform());
+        redisCache.setCacheObject(IntegralTaskConstants.REDIS_BROWSE_PREFIX + token, cache, 5, TimeUnit.MINUTES);
+        return R.ok().put("browseToken", token).put("requiredSeconds", config.getIntegralProductBrowseSeconds());
+    }
+
+    @Override
+    @Transactional
+    public R completeBrowse(Long userId, IntegralBrowseCompleteParam param) {
+        if (StringUtils.isBlank(param.getBrowseToken())) {
+            return R.error("browseToken不能为空");
+        }
+        String key = IntegralTaskConstants.REDIS_BROWSE_PREFIX + param.getBrowseToken();
+        Map<String, Object> cache = redisCache.getCacheObject(key);
+        if (cache == null) {
+            return R.error("浏览计时已过期,请重新开始");
+        }
+        Object cacheUserId = cache.get("userId");
+        if (cacheUserId == null || !userId.equals(Long.valueOf(cacheUserId.toString()))) {
+            return R.error("浏览计时无效");
+        }
+        IntegralConfig config = IntegralConfigHelper.load(configService);
+        long startTime = Long.parseLong(cache.get("startTime").toString());
+        long elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000;
+        if (elapsedSeconds < config.getIntegralProductBrowseSeconds()) {
+            return R.error("浏览时长不足" + config.getIntegralProductBrowseSeconds() + "秒");
+        }
+        redisCache.deleteObject(key);
+
+        FsUserAddIntegralTemplateParam templateParam = new FsUserAddIntegralTemplateParam();
+        templateParam.setUserId(userId);
+        templateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_13.getValue());
+        Object goodsId = cache.get("goodsId");
+        templateParam.setBusinessId(goodsId != null ? goodsId.toString() : LocalDate.now().toString());
+        templateParam.setPlatform(param.getPlatform());
+        R result = userIntegralLogsService.addIntegralTemplate(templateParam);
+        if (!isSuccess(result)) {
+            return result;
+        }
+        markTaskDone(userId, IntegralTaskConstants.TASK_BROWSE_MALL, true);
+        return enrichTaskToast(result, "浏览商城60秒", config.getIntegralProduct());
+    }
+
+    @Override
+    @Transactional
+    public void tryGrantFirstPurchase(Long userId, Long orderId) {
+        if (userId == null || orderId == null) {
+            return;
+        }
+        List<FsUserIntegralLogs> exists = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(
+                userId, FsUserIntegralLogTypeEnum.TYPE_32.getValue(), null);
+        if (!exists.isEmpty()) {
+            return;
+        }
+        FsUserAddIntegralTemplateParam param = new FsUserAddIntegralTemplateParam();
+        param.setUserId(userId);
+        param.setLogType(FsUserIntegralLogTypeEnum.TYPE_32.getValue());
+        param.setBusinessId(orderId.toString());
+        R r = userIntegralLogsService.addIntegralTemplate(param);
+        if (isSuccess(r)) {
+            markTaskDone(userId, IntegralTaskConstants.TASK_FIRST_PURCHASE, false);
+        }
+    }
+
+    @Override
+    @Transactional
+    public int tryGrantCourseCommentIntegral(Long userId, Long videoId, Long commentId) {
+        if (userId == null || videoId == null) {
+            return 0;
+        }
+        List<FsUserIntegralLogs> exists = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(
+                userId, FsUserIntegralLogTypeEnum.TYPE_33.getValue(), null);
+        for (FsUserIntegralLogs item : exists) {
+            if (videoId.toString().equals(item.getBusinessId())) {
+                return 0;
+            }
+        }
+        FsUserAddIntegralTemplateParam param = new FsUserAddIntegralTemplateParam();
+        param.setUserId(userId);
+        param.setLogType(FsUserIntegralLogTypeEnum.TYPE_33.getValue());
+        param.setBusinessId(videoId.toString());
+        param.setRemark(commentId != null ? commentId.toString() : null);
+        userIntegralLogsService.addIntegralTemplate(param);
+        return 1;
+    }
+
+    @Override
+    public void processExpiredIntegralBatches() {
+        IntegralConfig config = IntegralConfigHelper.load(configService);
+        int expireDays = config.getIntegralExpireDays();
+        Date launchDate = DateUtils.parseDate(config.getIntegralExpireLaunchDate());
+        if (launchDate == null) {
+            launchDate = DateUtils.parseDate("2026-07-10");
+        }
+        Date now = new Date();
+        int batchSize = 500;
+        int totalExpired = 0;
+        while (true) {
+            List<FsUserIntegralBatch> batches = fsUserIntegralBatchMapper.selectExpiredBatches(
+                    now, launchDate, expireDays, batchSize);
+            if (batches == null || batches.isEmpty()) {
+                break;
+            }
+            for (FsUserIntegralBatch batch : batches) {
+                try {
+                    transactionTemplate.execute(status -> {
+                        expireIntegralBatch(batch);
+                        return null;
+                    });
+                    totalExpired++;
+                } catch (Exception e) {
+                    log.error("积分批次过期处理失败,batchId={},userId={}", batch.getBatchId(), batch.getUserId(), e);
+                }
+            }
+            if (batches.size() < batchSize) {
+                break;
+            }
+        }
+        if (totalExpired > 0) {
+            log.info("积分过期处理完成,共失效 {} 笔批次,规则:{} 之后获得且超过 {} 天",
+                    totalExpired, config.getIntegralExpireLaunchDate(), expireDays);
+        }
+    }
+
+    private void expireIntegralBatch(FsUserIntegralBatch batch) {
+        if (batch.getRemain() == null || batch.getRemain() <= 0) {
+            return;
+        }
+        FsUser user = fsUserMapper.selectFsUserByUserId(batch.getUserId());
+        if (user == null) {
+            return;
+        }
+        long deduct = batch.getRemain();
+        long newBalance = Math.max(0, user.getIntegral() - deduct);
+        FsUser userMap = new FsUser();
+        userMap.setUserId(batch.getUserId());
+        userMap.setIntegral(newBalance);
+        fsUserMapper.updateFsUser(userMap);
+
+        fsUserIntegralBatchMapper.updateRemain(batch.getBatchId(), 0L);
+
+        FsUserIntegralLogs logs = new FsUserIntegralLogs();
+        logs.setIntegral(-deduct);
+        logs.setUserId(batch.getUserId());
+        logs.setBalance(newBalance);
+        logs.setLogType(FsUserIntegralLogTypeEnum.TYPE_7.getValue());
+        logs.setBusinessId(batch.getBatchId().toString());
+        logs.setCreateTime(new Date());
+        logs.setRemark("积分过期失效");
+        fsUserIntegralLogsMapper.insertFsUserIntegralLogs(logs);
+    }
+
+    @Override
+    @Transactional
+    public void markSignPopupShown(Long userId) {
+        String popupKey = IntegralTaskConstants.REDIS_SIGN_POPUP_PREFIX + userId + ":" + LocalDate.now();
+        redisCache.setCacheObject(popupKey, "1", 1, TimeUnit.DAYS);
+    }
+
+    private List<Map<String, Object>> buildNewbieTasks(Long userId, IntegralConfig config, Map<String, String> taskDisplayScope) {
+        List<Map<String, Object>> tasks = new ArrayList<>();
+        tasks.add(buildLifetimeTask(IntegralTaskConstants.TASK_REGISTER, "首次注册登录",
+                config.getIntegralRegister(), hasLogType(userId, 20), taskDisplayScope));
+        tasks.add(buildLifetimeTask(IntegralTaskConstants.TASK_DOWNLOAD_APP, "首次下载APP",
+                config.getDownloadAppIntegral(), hasLogType(userId, 28), taskDisplayScope));
+        tasks.add(buildLifetimeTask(IntegralTaskConstants.TASK_FIRST_PURCHASE, "首次完成购物",
+                config.getIntegralFirstPurchase(), hasLogType(userId, 32), taskDisplayScope));
+        tasks.add(buildLifetimeTask(IntegralTaskConstants.TASK_INVITED, "填写朋友邀请码",
+                config.getIntegralInvited(), hasLogType(userId, 19), taskDisplayScope));
+        hideCompletedNewbieTasks(tasks, userId);
+        return tasks;
+    }
+
+    private List<Map<String, Object>> buildDailyTasks(Long userId, IntegralConfig config, Map<String, String> taskDisplayScope) {
+        List<Map<String, Object>> tasks = new ArrayList<>();
+        int shareCount = countTodayLog(userId, 3);
+        tasks.add(buildDailyTask(IntegralTaskConstants.TASK_SHARE, "分享得积分",
+                config.getIntegralShare(), shareCount, config.getIntegralShareDailyLimit(), taskDisplayScope));
+
+        int courseCount = countTodayLog(userId, 10);
+        tasks.add(buildDailyTask(IntegralTaskConstants.TASK_COURSE, "学习首页课程",
+                config.getIntegralCourse(), courseCount, config.getIntegralCourseDailyLimit(), taskDisplayScope));
+
+        int browseCount = countTodayLog(userId, 13);
+        tasks.add(buildDailyTask(IntegralTaskConstants.TASK_BROWSE_MALL, "浏览商城60秒",
+                config.getIntegralProduct(), browseCount, config.getIntegralProductDailyLimit(), taskDisplayScope));
+
+        int inviteCount = countTodayLog(userId, 18);
+        tasks.add(buildDailyTask(IntegralTaskConstants.TASK_INVITE, "邀好友享积分",
+                config.getIntegralInvite(), inviteCount, config.getIntegralInviteDailyLimit(), taskDisplayScope));
+        return tasks;
+    }
+
+    private List<Map<String, Object>> filterTasksByPlatform(List<Map<String, Object>> tasks, Map<String, String> taskDisplayScope, String platform) {
+        List<Map<String, Object>> visibleTasks = new ArrayList<>();
+        for (Map<String, Object> task : tasks) {
+            if (Boolean.TRUE.equals(task.get("hidden"))) {
+                continue;
+            }
+            String code = task.get("code") == null ? null : task.get("code").toString();
+            if (IntegralTaskScopeHelper.isTaskVisible(taskDisplayScope, code, platform)) {
+                visibleTasks.add(task);
+            }
+        }
+        return visibleTasks;
+    }
+
+    private Map<String, Object> buildLifetimeTask(String code, String title, Integer reward, boolean finished, Map<String, String> taskDisplayScope) {
+        Map<String, Object> task = new HashMap<>();
+        task.put("code", code);
+        task.put("title", title);
+        task.put("reward", reward == null ? 0 : reward);
+        task.put("status", finished ? 2 : 0);
+        task.put("hidden", false);
+        task.put("visibleScope", IntegralTaskScopeHelper.getTaskDisplayScope(taskDisplayScope, code));
+        return task;
+    }
+
+    private Map<String, Object> buildDailyTask(String code, String title, Integer reward, int doneCount, int limitCount, Map<String, String> taskDisplayScope) {
+        Map<String, Object> task = new HashMap<>();
+        task.put("code", code);
+        task.put("title", title);
+        task.put("reward", reward == null ? 0 : reward);
+        task.put("doneCount", doneCount);
+        task.put("limitCount", limitCount);
+        int status = 0;
+        if (doneCount >= limitCount) {
+            status = 3;
+        } else if (doneCount > 0) {
+            status = 2;
+        }
+        task.put("status", status);
+        task.put("visibleScope", IntegralTaskScopeHelper.getTaskDisplayScope(taskDisplayScope, code));
+        return task;
+    }
+
+    private void hideCompletedNewbieTasks(List<Map<String, Object>> tasks, Long userId) {
+        Instant now = Instant.now();
+        for (Map<String, Object> task : tasks) {
+            if (!Integer.valueOf(2).equals(task.get("status"))) {
+                continue;
+            }
+            Integer logType = resolveNewbieLogType(task.get("code"));
+            if (logType == null) {
+                continue;
+            }
+            Date completedAt = getTaskCompletedTime(userId, logType);
+            if (completedAt == null) {
+                continue;
+            }
+            task.put("completedAt", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, completedAt));
+            long hours = ChronoUnit.HOURS.between(completedAt.toInstant(), now);
+            if (hours >= IntegralTaskConstants.NEWBIE_COMPLETED_HIDE_HOURS) {
+                task.put("hidden", true);
+            }
+        }
+    }
+
+    private Integer resolveNewbieLogType(Object code) {
+        if (code == null) {
+            return null;
+        }
+        switch (code.toString()) {
+            case IntegralTaskConstants.TASK_REGISTER:
+                return FsUserIntegralLogTypeEnum.TYPE_20.getValue();
+            case IntegralTaskConstants.TASK_DOWNLOAD_APP:
+                return FsUserIntegralLogTypeEnum.TYPE_28.getValue();
+            case IntegralTaskConstants.TASK_FIRST_PURCHASE:
+                return FsUserIntegralLogTypeEnum.TYPE_32.getValue();
+            case IntegralTaskConstants.TASK_INVITED:
+                return FsUserIntegralLogTypeEnum.TYPE_19.getValue();
+            default:
+                return null;
+        }
+    }
+
+    private Date getTaskCompletedTime(Long userId, int logType) {
+        List<FsUserIntegralLogs> logs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, logType, null);
+        if (logs == null || logs.isEmpty()) {
+            return null;
+        }
+        return logs.stream()
+                .map(FsUserIntegralLogs::getCreateTime)
+                .filter(Objects::nonNull)
+                .min(Date::compareTo)
+                .orElse(null);
+    }
+
+    private boolean hasLogType(Long userId, int logType) {
+        List<FsUserIntegralLogs> logs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, logType, null);
+        return logs != null && !logs.isEmpty();
+    }
+
+    private int countTodayLog(Long userId, int logType) {
+        List<FsUserIntegralLogs> logs = fsUserIntegralLogsMapper.selectFsUserIntegralLogsByUserIdAndLogType(userId, logType, LocalDate.now());
+        return logs == null ? 0 : logs.size();
+    }
+
+    private String buildExpireTip(IntegralConfig config) {
+        if (config.getIntegralExpireDays() == null) {
+            return null;
+        }
+        return "积分自获得起" + config.getIntegralExpireDays() + "天内有效,过期自动失效";
+    }
+
+    private void markTaskDone(Long userId, String taskCode, boolean daily) {
+        String period = daily ? LocalDate.now().toString() : "lifetime";
+        String key = IntegralTaskConstants.REDIS_TASK_DONE_PREFIX + userId + ":" + taskCode + ":" + period;
+        redisCache.setCacheObject(key, "1", daily ? 1 : 3650, TimeUnit.DAYS);
+    }
+
+    static R enrichTaskToast(R result, String taskName, Integer reward) {
+        if (result == null) {
+            return R.ok().put("taskName", taskName).put("rewardPoints", reward).put("toast", taskName + " +" + reward + "积分");
+        }
+        result.put("taskName", taskName);
+        result.put("rewardPoints", reward);
+        result.put("toast", taskName + " +" + reward + "积分");
+        return result;
+    }
+
+    private boolean isSuccess(R r) {
+        return r != null && r.get("code") != null && Integer.valueOf(200).equals(Integer.valueOf(r.get("code").toString()));
+    }
+}

+ 103 - 0
fs-service/src/main/java/com/fs/his/service/impl/SignSubscribeNotifyServiceImpl.java

@@ -0,0 +1,103 @@
+package com.fs.his.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUserWx;
+import com.fs.his.mapper.FsUserWxMapper;
+import com.fs.his.param.SignNotifyParam;
+import com.fs.his.service.ISignSubscribeNotifyService;
+import com.fs.live.domain.LiveMiniprogramSubNotifyTask;
+import com.fs.live.mapper.LiveMiniprogramSubNotifyTaskMapper;
+import com.fs.store.dto.TemplateMessageSendRequestDTO;
+import com.fs.store.enums.MiniAppNotifyTaskStatusEnum;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class SignSubscribeNotifyServiceImpl implements ISignSubscribeNotifyService {
+
+    private static final String TASK_NAME_SIGN = "签到提醒";
+    private static final String DEFAULT_PAGE = "pages/user/integral/points";
+    private static final DateTimeFormatter SEND_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private LiveMiniprogramSubNotifyTaskMapper liveMiniprogramSubNotifyTaskMapper;
+
+    @Autowired
+    private FsUserWxMapper fsUserWxMapper;
+
+    @Override
+    public R subNotifySign(SignNotifyParam param) {
+        if (StringUtils.isEmpty(param.getAppId())) {
+            return R.error("签到订阅失败:appId为空");
+        }
+        if (StringUtils.isEmpty(param.getTemplateId())) {
+            return R.error("签到订阅失败:templateId为空");
+        }
+        if (param.getUserId() == null) {
+            return R.error("签到订阅失败:userId为空");
+        }
+        if (param.getData() == null || param.getData().isEmpty()) {
+            return R.error("签到订阅失败:模板内容data为空");
+        }
+
+        Wrapper<FsUserWx> queryWrapper = Wrappers.<FsUserWx>lambdaQuery()
+                .eq(FsUserWx::getFsUserId, param.getUserId())
+                .eq(FsUserWx::getAppId, param.getAppId());
+        FsUserWx fsUserWx = fsUserWxMapper.selectOne(queryWrapper);
+
+        String maOpenId = fsUserWx == null ? param.getMaOpenId() : fsUserWx.getOpenId();
+        if (StringUtils.isEmpty(maOpenId)) {
+            log.error("用户没有绑定微信,无法订阅签到提醒: userId={}", param.getUserId());
+            return R.error("未绑定微信,无法订阅签到提醒");
+        }
+
+        liveMiniprogramSubNotifyTaskMapper.cancelPendingSignNotifyByUserId(param.getUserId());
+
+        LiveMiniprogramSubNotifyTask notifyTask = new LiveMiniprogramSubNotifyTask();
+        notifyTask.setTaskName(TASK_NAME_SIGN);
+        notifyTask.setTemplateId(param.getTemplateId());
+        notifyTask.setTouser(maOpenId);
+        notifyTask.setFsUserId(param.getUserId());
+        notifyTask.setPage(StringUtils.isEmpty(param.getPage()) ? DEFAULT_PAGE : param.getPage());
+        notifyTask.setStatus(MiniAppNotifyTaskStatusEnum.WAITING.getValue());
+        notifyTask.setRetryCount(0);
+        notifyTask.setMaxRetries(3);
+        notifyTask.setCreateTime(LocalDateTime.now());
+        notifyTask.setUpdateTime(resolvePlanSendTime(param.getSendTime()));
+
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("appId", param.getAppId());
+        notifyTask.setRequestParams(JSON.toJSONString(requestParams));
+
+        Map<String, TemplateMessageSendRequestDTO.TemplateDataValue> data = new HashMap<>();
+        param.getData().forEach((key, value) -> {
+            TemplateMessageSendRequestDTO.TemplateDataValue dataValue = new TemplateMessageSendRequestDTO.TemplateDataValue();
+            dataValue.setValue(value);
+            data.put(key, dataValue);
+        });
+        notifyTask.setData(JSON.toJSONString(data));
+
+        liveMiniprogramSubNotifyTaskMapper.insert(notifyTask);
+        return R.ok("success");
+    }
+
+    private LocalDateTime resolvePlanSendTime(String sendTime) {
+        if (StringUtils.isNotEmpty(sendTime)) {
+            return LocalDateTime.parse(sendTime, SEND_TIME_FORMATTER);
+        }
+        return LocalDateTime.of(LocalDate.now().plusDays(1), LocalTime.of(8, 0, 0));
+    }
+}

+ 73 - 0
fs-service/src/main/java/com/fs/his/utils/IntegralConfigHelper.java

@@ -0,0 +1,73 @@
+package com.fs.his.utils;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.his.config.IntegralConfig;
+import com.fs.system.service.ISysConfigService;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Map;
+
+/**
+ * 积分配置读取,补全 0701 任务体系默认值
+ */
+public final class IntegralConfigHelper {
+
+    private IntegralConfigHelper() {
+    }
+
+    public static IntegralConfig load(ISysConfigService configService) {
+        String json = configService.selectConfigByKey("his.integral");
+        IntegralConfig config = StringUtils.isBlank(json)
+                ? new IntegralConfig()
+                : JSONUtil.toBean(json, IntegralConfig.class);
+        if (config == null) {
+            config = new IntegralConfig();
+        }
+        applyDefaults(config);
+        return config;
+    }
+
+    public static void applyDefaults(IntegralConfig config) {
+        if (config.getIntegralFirstPurchase() == null) {
+            config.setIntegralFirstPurchase(20);
+        }
+        if (config.getIntegralInviteDailyLimit() == null) {
+            config.setIntegralInviteDailyLimit(3);
+        }
+        if (config.getIntegralShareDailyLimit() == null) {
+            config.setIntegralShareDailyLimit(5);
+        }
+        if (config.getIntegralCourseDailyLimit() == null) {
+            config.setIntegralCourseDailyLimit(5);
+        }
+        if (config.getIntegralProductDailyLimit() == null) {
+            config.setIntegralProductDailyLimit(1);
+        }
+        if (config.getIntegralProductBrowseSeconds() == null) {
+            config.setIntegralProductBrowseSeconds(60);
+        }
+        if (config.getIntegralComment() == null) {
+            config.setIntegralComment(100);
+        }
+        if (config.getIntegralExpireDays() == null) {
+            config.setIntegralExpireDays(90);
+        }
+        if (StringUtils.isBlank(config.getIntegralExpireLaunchDate())) {
+            config.setIntegralExpireLaunchDate("2026-07-10");
+        }
+        if (config.getTaskDisplayScope() == null || config.getTaskDisplayScope().isEmpty()) {
+            config.setTaskDisplayScope(IntegralTaskScopeHelper.defaultTaskDisplayScope());
+        } else {
+            Map<String, String> merged = IntegralTaskScopeHelper.defaultTaskDisplayScope();
+            merged.putAll(config.getTaskDisplayScope());
+            config.setTaskDisplayScope(merged);
+        }
+        if (config.getLogTypeDisplayScope() == null || config.getLogTypeDisplayScope().isEmpty()) {
+            config.setLogTypeDisplayScope(IntegralTaskScopeHelper.defaultLogTypeDisplayScope());
+        } else {
+            Map<String, String> merged = IntegralTaskScopeHelper.defaultLogTypeDisplayScope();
+            merged.putAll(config.getLogTypeDisplayScope());
+            config.setLogTypeDisplayScope(merged);
+        }
+    }
+}

+ 140 - 0
fs-service/src/main/java/com/fs/his/utils/IntegralTaskScopeHelper.java

@@ -0,0 +1,140 @@
+package com.fs.his.utils;
+
+import com.fs.his.config.IntegralConfig;
+import com.fs.his.constant.IntegralTaskConstants;
+import com.fs.system.vo.DictVO;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 积分任务 / 流水类型展示端(app / mini)配置
+ */
+public final class IntegralTaskScopeHelper {
+
+    public static final String INTEGRAL_LOG_TYPE_DICT = "sys_integral_log_type";
+
+    private IntegralTaskScopeHelper() {
+    }
+
+    public static Map<String, String> defaultTaskDisplayScope() {
+        Map<String, String> map = new LinkedHashMap<>();
+        map.put(IntegralTaskConstants.TASK_SIGN, "app,mini");
+        map.put(IntegralTaskConstants.TASK_REGISTER, "app,mini");
+        map.put(IntegralTaskConstants.TASK_DOWNLOAD_APP, "app");
+        map.put(IntegralTaskConstants.TASK_FIRST_PURCHASE, "app,mini");
+        map.put(IntegralTaskConstants.TASK_INVITED, "app,mini");
+        map.put(IntegralTaskConstants.TASK_SHARE, "app,mini");
+        map.put(IntegralTaskConstants.TASK_COURSE, "app");
+        map.put(IntegralTaskConstants.TASK_BROWSE_MALL, "app,mini");
+        map.put(IntegralTaskConstants.TASK_INVITE, "app,mini");
+        return map;
+    }
+
+    public static Map<String, String> resolveTaskDisplayScope(IntegralConfig config) {
+        Map<String, String> scopes = defaultTaskDisplayScope();
+        if (config != null && config.getTaskDisplayScope() != null) {
+            scopes.putAll(config.getTaskDisplayScope());
+        }
+        return scopes;
+    }
+
+    public static Map<String, String> defaultLogTypeDisplayScope() {
+        Map<String, String> taskScopes = defaultTaskDisplayScope();
+        Map<String, String> map = new LinkedHashMap<>();
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_SIGN, "1");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_SHARE, "3");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_COURSE, "10");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_BROWSE_MALL, "13");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_INVITE, "18");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_INVITED, "19");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_REGISTER, "20");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_DOWNLOAD_APP, "28");
+        applyTaskScopeToLogType(map, taskScopes, IntegralTaskConstants.TASK_FIRST_PURCHASE, "32");
+        map.put("33", "app,mini");
+        for (int i = 1; i <= 33; i++) {
+            map.putIfAbsent(String.valueOf(i), "app,mini");
+        }
+        return map;
+    }
+
+    public static Map<String, String> resolveLogTypeDisplayScope(IntegralConfig config) {
+        Map<String, String> scopes = defaultLogTypeDisplayScope();
+        if (config != null && config.getLogTypeDisplayScope() != null) {
+            scopes.putAll(config.getLogTypeDisplayScope());
+        }
+        return scopes;
+    }
+
+    private static void applyTaskScopeToLogType(Map<String, String> logTypeScopes,
+                                                Map<String, String> taskScopes,
+                                                String taskCode,
+                                                String logType) {
+        logTypeScopes.put(logType, getTaskDisplayScope(taskScopes, taskCode));
+    }
+
+    public static String normalizePlatform(String platform) {
+        if (StringUtils.isBlank(platform)) {
+            return "app";
+        }
+        String value = platform.trim().toLowerCase();
+        if ("miniapp".equals(value) || "miniprogram".equals(value) || "wx".equals(value) || "h5".equals(value)) {
+            return "mini";
+        }
+        return value;
+    }
+
+    public static boolean isTaskVisible(Map<String, String> scopes, String taskCode, String platform) {
+        return isScopeVisible(scopes, taskCode, platform);
+    }
+
+    public static boolean isLogTypeVisible(Map<String, String> scopes, String logType, String platform) {
+        return isScopeVisible(scopes, logType, platform);
+    }
+
+    public static boolean isScopeVisible(Map<String, String> scopes, String code, String platform) {
+        if (StringUtils.isBlank(code)) {
+            return true;
+        }
+        String normalizedPlatform = normalizePlatform(platform);
+        String scope = scopes.get(code);
+        if (StringUtils.isBlank(scope)) {
+            return true;
+        }
+        List<String> platforms = Arrays.asList(scope.split(","));
+        return platforms.contains(normalizedPlatform);
+    }
+
+    public static boolean shouldFilterIntegralLogType(String dictType, String platform) {
+        return INTEGRAL_LOG_TYPE_DICT.equals(dictType) && StringUtils.isNotBlank(platform);
+    }
+
+    public static List<DictVO> filterLogTypesByPlatform(List<DictVO> dicts, IntegralConfig config, String platform) {
+        if (dicts == null || dicts.isEmpty()) {
+            return dicts;
+        }
+        Map<String, String> scopes = resolveLogTypeDisplayScope(config);
+        String normalizedPlatform = normalizePlatform(platform);
+        List<DictVO> result = new ArrayList<>();
+        for (DictVO dict : dicts) {
+            if (isLogTypeVisible(scopes, dict.getDictValue(), normalizedPlatform)) {
+                result.add(dict);
+            }
+        }
+        return result;
+    }
+
+    public static String getLogTypeDisplayScope(Map<String, String> scopes, String logType) {
+        String scope = scopes.get(logType);
+        return StringUtils.isBlank(scope) ? "app,mini" : scope;
+    }
+
+    public static String getTaskDisplayScope(Map<String, String> scopes, String taskCode) {
+        String scope = scopes.get(taskCode);
+        return StringUtils.isBlank(scope) ? "app,mini" : scope;
+    }
+}

+ 4 - 0
fs-service/src/main/java/com/fs/his/vo/FsIntegralGoodsListVO.java

@@ -51,6 +51,10 @@ public class FsIntegralGoodsListVO {
     /** 库存 */
     @Excel(name = "库存")
     private Long stock;
+
+    /** 可见范围 app,mini */
+    private String visibleScope;
+
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsIntegralGoodsScrmMapper.java

@@ -86,6 +86,9 @@ public interface FsIntegralGoodsScrmMapper
             "<if test = 'maps.appId != null and maps.appId != \" \" '> " +
             " and ((FIND_IN_SET(#{maps.appId}, g.app_ids) > 0)) " +
             "</if>"+
+            "<if test = 'maps.platform != null and maps.platform != \"\" '> " +
+            " and ((FIND_IN_SET(#{maps.platform}, g.visible_scope) > 0) or g.visible_scope is null or g.visible_scope = '') " +
+            "</if>"+
             " order by g.goods_id desc "+
             "</script>"})
     List<FsIntegralGoodsListUVO> selectFsIntegralGoodsListUVO(@Param("maps")FsIntegralGoodsListUParam param);

+ 4 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsIntegralGoodsListUParam.java

@@ -32,4 +32,8 @@ public class FsIntegralGoodsListUParam extends BaseQueryParam implements Seriali
     private Integer status;
 
     private String appId;
+
+    /** 可见端 app / mini(兼容旧参数) */
+    @ApiModelProperty(value = "可见端 app/mini,兼容旧参数 platform")
+    private String platform;
 }

+ 69 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -129,6 +129,10 @@ import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.IQwExternalContactService;
+import com.fs.qw.utils.QwExternalContactRemarkMarkHelper;
+import com.fs.qwApi.domain.QwExternalContactRemarkResult;
+import com.fs.qwApi.param.QwExternalContactRemarkParam;
+import com.fs.qwApi.service.QwApiService;
 import com.fs.store.domain.FsStoreDelivers;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.service.ISysConfigService;
@@ -498,6 +502,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Autowired
     private IFsUserIntegralLogsService userIntegralLogsService;
     @Autowired
+    private IIntegralTaskService integralTaskService;
+    @Autowired
     private FsStoreProductPackageScrmMapper fsStoreProductPackageMapper;
     @Autowired
     private FsUserCompanyPackageScrmMapper fsUserCompanyPackageScrmMapper;
@@ -516,6 +522,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Autowired
     private QwUserMapper qwUserMapper;
     @Autowired
+    private QwApiService qwApiService;
+    @Autowired
     private FastGptRoleMapper fastGptRoleMapper;
     @Autowired
     private WxWorkService wxWorkService;
@@ -3130,6 +3138,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             final Long paySuccessOrderId = order.getId();
             CompletableFuture.runAsync(() -> safeSendPaySuccessMsgToWx(paySuccessOrderId));
         }
+        integralTaskService.tryGrantFirstPurchase(order.getUserId(), order.getId());
         return "SUCCESS";
     }
 
@@ -7834,6 +7843,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             return;
         }
         for (QwExternalContact externalContact : qwExternalContact) {
+            updatePurchaseRemarkForPaySuccess(externalContact);
             Long qwUserId = externalContact.getQwUserId();
             if (qwUserId == null) {
                 continue;
@@ -7876,6 +7886,65 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         }
     }
 
+    /**
+     * 支付成功:为订单对应销售的外部联系人追加购买备注(*MMdd购),与完课备注独立互不影响。
+     */
+    private void updatePurchaseRemarkForPaySuccess(QwExternalContact externalContact) {
+        // 下面的 输出错误日志 需要删除掉 todo yhq 先放一个星期观察情况 20260706
+        if (externalContact == null || externalContact.getId() == null || externalContact.getQwUserId() == null) {
+            log.error("支付成功购买备注更新失败,外部联系人不存在");
+            return;
+        }
+        QwExternalContact latestContact = qwExternalContactMapper.selectQwExternalContactById(externalContact.getId());
+        if (latestContact == null) {
+            log.error("支付成功购买备注更新失败,外部联系人不存在");
+            return;
+        }
+        QwUser qwUser = qwUserMapper.selectQwUserById(latestContact.getQwUserId());
+        if (qwUser == null || QwExternalContactRemarkMarkHelper.shouldSkipAutoRemark(qwUser.getIsSendMsg())) {
+            log.error("支付成功购买备注更新失败,用户无发送欢迎语权限,qwUserId={}", latestContact.getQwUserId());
+            return;
+        }
+        int isSendMsg = qwUser.getIsSendMsg() == null ? 1 : qwUser.getIsSendMsg();
+        String oldRemark = latestContact.getRemark();
+        if (StringUtils.isEmpty(oldRemark)) {
+            oldRemark = latestContact.getName();
+        }
+        String newRemark = QwExternalContactRemarkMarkHelper.buildPurchaseRemark(oldRemark, latestContact.getName(), isSendMsg);
+        if (StringUtils.equals(oldRemark, newRemark)) {
+            log.error("支付成功购买备注无需更新,externalContactId={},remark={}", latestContact.getId(), oldRemark);
+            return;
+        }
+        if (StringUtils.isEmpty(latestContact.getUserId())
+                || StringUtils.isEmpty(latestContact.getExternalUserId())
+                || StringUtils.isEmpty(latestContact.getCorpId())) {
+            log.error("支付成功购买备注失败,外部联系人信息不完整,externalContactId={}", latestContact.getId());
+            return;
+        }
+        QwExternalContactRemarkParam remarkParam = new QwExternalContactRemarkParam();
+        remarkParam.setRemark(newRemark);
+        remarkParam.setUserid(latestContact.getUserId());
+        remarkParam.setExternal_userid(latestContact.getExternalUserId());
+        try {
+            QwExternalContactRemarkResult result = qwApiService.externalcontactRemark(remarkParam, latestContact.getCorpId());
+            if (result != null && result.getErrcode() == 0) {
+                QwExternalContact contactNew = new QwExternalContact();
+                contactNew.setId(latestContact.getId());
+                contactNew.setRemark(newRemark);
+                qwExternalContactMapper.updateQwExternalContact(contactNew);
+                log.error("支付成功添加购买备注:{}|{}|{}|{}|{} -> {}",
+                        latestContact.getName(), latestContact.getExternalUserId(),
+                        latestContact.getCorpId(), latestContact.getUserId(), oldRemark, newRemark);
+            } else {
+                log.error("支付成功购买备注失败:{}|{}|{} -> {},原因:{}",
+                        latestContact.getExternalUserId(), latestContact.getUserId(),
+                        oldRemark, newRemark, result != null ? result.getErrmsg() : "null");
+            }
+        } catch (Exception e) {
+            log.error("支付成功购买备注异常,externalContactId={}", latestContact.getId(), e);
+        }
+    }
+
     private boolean sendWxImageMsg(Long sendId, String uuid, Long serverId, String imageUrl) {
         WxwUploadCdnLinkImgDTO uploadDto = new WxwUploadCdnLinkImgDTO();
         uploadDto.setUuid(uuid);

+ 5 - 0
fs-service/src/main/java/com/fs/live/domain/LiveMiniprogramSubNotifyTask.java

@@ -31,6 +31,11 @@ public class LiveMiniprogramSubNotifyTask {
      */
     private String touser;
 
+    /**
+     * fs_user.user_id
+     */
+    private Long fsUserId;
+
     /**
      * 点击消息跳转的页面路径(可选)
      */

+ 16 - 2
fs-service/src/main/java/com/fs/live/mapper/LiveMiniprogramSubNotifyTaskMapper.java

@@ -29,9 +29,9 @@ public interface LiveMiniprogramSubNotifyTaskMapper {
      * @param task 任务实体
      * @return
      */
-    @Insert("INSERT INTO fs_miniprogram_sub_notify_task (task_name, template_id, touser, page, `data`, status, " +
+    @Insert("INSERT INTO fs_miniprogram_sub_notify_task (task_name, template_id, touser, fs_user_id, page, `data`, status, " +
             "retry_count, max_retries, request_params,request_body, response_body, error_message, create_time, update_time) " +
-            "VALUES (#{taskName}, #{templateId}, #{touser}, #{page}, #{data}, #{status}, #{retryCount}, " +
+            "VALUES (#{taskName}, #{templateId}, #{touser}, #{fsUserId}, #{page}, #{data}, #{status}, #{retryCount}, " +
             "#{maxRetries}, #{requestParams},#{requestBody}, #{responseBody}, #{errorMessage}, #{createTime}, #{updateTime})")
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insert(LiveMiniprogramSubNotifyTask task);
@@ -87,4 +87,18 @@ public interface LiveMiniprogramSubNotifyTaskMapper {
     @Select("SELECT * FROM fs_miniprogram_sub_notify_task WHERE retry_count<3 and status in (0,3) " +
             "and task_name = '直播间预约提醒'" )
     List<LiveMiniprogramSubNotifyTask> selectLivePendingData();
+
+    /**
+     * 查询待发送的签到提醒订阅
+     */
+    @Select("SELECT * FROM fs_miniprogram_sub_notify_task WHERE retry_count < 3 AND status IN (0,3) " +
+            "AND task_name = '签到提醒'")
+    List<LiveMiniprogramSubNotifyTask> selectSignPendingData();
+
+    /**
+     * 取消用户未完成的签到提醒任务(重新订阅时)
+     */
+    @Update("UPDATE fs_miniprogram_sub_notify_task SET status = 4, error_message = '用户重新订阅', update_time = NOW() " +
+            "WHERE task_name = '签到提醒' AND status IN (0,3) AND fs_user_id = #{userId}")
+    int cancelPendingSignNotifyByUserId(@Param("userId") Long userId);
 }

+ 5 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java

@@ -72,6 +72,7 @@ import com.fs.his.mapper.*;
 import com.fs.his.service.IFsExpressService;
 import com.fs.his.service.IFsStoreProductService;
 import com.fs.his.service.IFsUserService;
+import com.fs.his.service.IIntegralTaskService;
 import com.fs.his.utils.ConfigUtil;
 import com.fs.hisStore.config.FsErpConfig;
 import com.fs.hisStore.config.StoreConfig;
@@ -272,6 +273,9 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private IIntegralTaskService integralTaskService;
+
 
     @Autowired
     private FsErpFinishPushMapper fsErpFinishPushMapper;
@@ -914,6 +918,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             } catch (Exception e) {
                 log.error("推送erp失败:{}", e.getMessage());
             }
+            integralTaskService.tryGrantFirstPurchase(Long.valueOf(order.getUserId()), order.getOrderId());
             return "SUCCESS";
         } catch (Exception e) {
             log.info("支付错误:" + e.getMessage());

+ 2 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -586,6 +586,8 @@ public class LiveServiceImpl implements ILiveService
 
         notifyTask.setTouser(maOpenId);
 
+        notifyTask.setFsUserId(userId);
+
         notifyTask.setCreateTime(LocalDateTime.now());
         // 状态等待执行
         notifyTask.setStatus(MiniAppNotifyTaskStatusEnum.WAITING.getValue());

+ 130 - 0
fs-service/src/main/java/com/fs/qw/utils/QwExternalContactRemarkMarkHelper.java

@@ -0,0 +1,130 @@
+package com.fs.qw.utils;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 企微外部联系人备注标记:完课(*MMdd完) 与 购买(*MMdd购) 独立维护,互不影响。
+ */
+public final class QwExternalContactRemarkMarkHelper {
+
+    private static final int REMARK_MAX_LENGTH = 20;
+    private static final Pattern FINISH_MARK_PATTERN = Pattern.compile("\\*(\\d{1,4})完");
+    private static final Pattern PURCHASE_MARK_PATTERN = Pattern.compile("\\*(\\d{1,4})购");
+
+    private QwExternalContactRemarkMarkHelper() {
+    }
+
+    public static boolean shouldSkipAutoRemark(Integer isSendMsg) {
+        return isSendMsg != null && isSendMsg == 5;
+    }
+
+    /**
+     * 构建购买备注,仅更新 *日期购 标记,保留 *日期完 标记不变。
+     * 始终按「去掉旧购标记 + 追加今日购标记」重组;仅当重组结果与原文完全一致时才跳过。
+     */
+    public static String buildPurchaseRemark(String oldRemark, String fallbackName, int isSendMsg) {
+        String remarkBase = resolveRemarkBase(oldRemark, fallbackName);
+        LocalDate today = LocalDate.now();
+        String monthDay = today.format(DateTimeFormatter.ofPattern("MMdd"));
+        String dayOfMonth = today.format(DateTimeFormatter.ofPattern("dd"));
+        String markToAdd = (isSendMsg == 3 || isSendMsg == 4) ? "*" + dayOfMonth + "购" : "*" + monthDay + "购";
+        String bodyWithoutPurchaseMark = removeMarks(remarkBase, PURCHASE_MARK_PATTERN);
+        String rebuilt = appendMark(bodyWithoutPurchaseMark, markToAdd, isSendMsg);
+        return rebuilt.equals(remarkBase) ? remarkBase : rebuilt;
+    }
+
+    /**
+     * 构建完课备注,仅更新 *日期完 标记,保留 *日期购 标记不变。
+     */
+    public static String buildFinishRemark(String oldRemark, String fallbackName, int isSendMsg) {
+        String remarkBase = resolveRemarkBase(oldRemark, fallbackName);
+        LocalDate today = LocalDate.now();
+        String monthDay = today.format(DateTimeFormatter.ofPattern("MMdd"));
+        String dayOfMonth = today.format(DateTimeFormatter.ofPattern("dd"));
+        String newMark = "*" + monthDay + "完";
+        String newMarkDay = "*" + dayOfMonth + "完";
+
+        if (!shouldUpdateMark(remarkBase, newMark, newMarkDay, FINISH_MARK_PATTERN)) {
+            return remarkBase;
+        }
+
+        String markToAdd = (isSendMsg == 3 || isSendMsg == 4) ? newMarkDay : newMark;
+        String bodyWithoutFinishMark = removeMarks(remarkBase, FINISH_MARK_PATTERN);
+        return appendMark(bodyWithoutFinishMark, markToAdd, isSendMsg);
+    }
+
+    private static String resolveRemarkBase(String oldRemark, String fallbackName) {
+        if (StringUtils.isBlank(oldRemark)) {
+            return StringUtils.defaultString(fallbackName, "");
+        }
+        return oldRemark;
+    }
+
+    private static boolean shouldUpdateMark(String remarkBase, String newMark, String newMarkDay, Pattern markPattern) {
+        List<String> existingMarks = extractMarks(remarkBase, markPattern);
+        for (String mark : existingMarks) {
+            String normalized = normalizeMark(mark, markPattern);
+            if (normalized.equals(newMark) || normalized.equals(newMarkDay)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static List<String> extractMarks(String remark, Pattern markPattern) {
+        List<String> marks = new ArrayList<>();
+        Matcher matcher = markPattern.matcher(remark);
+        while (matcher.find()) {
+            marks.add(matcher.group());
+        }
+        return marks;
+    }
+
+    private static String removeMarks(String remark, Pattern markPattern) {
+        if (StringUtils.isBlank(remark)) {
+            return "";
+        }
+        if (markPattern == FINISH_MARK_PATTERN) {
+            return remark.replaceAll("\\*\\d{1,4}完", "").trim();
+        }
+        return remark.replaceAll("\\*\\d{1,4}购", "").trim();
+    }
+
+    private static String appendMark(String body, String markToAdd, int isSendMsg) {
+        int keepLength = REMARK_MAX_LENGTH - markToAdd.length();
+        if (keepLength < 0) {
+            keepLength = 0;
+        }
+        String trimmedBody = body.length() > keepLength ? body.substring(0, keepLength) : body;
+        if (isSendMsg == 1 || isSendMsg == 3) {
+            return markToAdd + trimmedBody;
+        }
+        return trimmedBody + markToAdd;
+    }
+
+    private static String normalizeMark(String mark, Pattern markPattern) {
+        Matcher matcher = markPattern.matcher(mark);
+        if (!matcher.find()) {
+            return mark;
+        }
+        String digits = matcher.group(1);
+        String suffix = markPattern == FINISH_MARK_PATTERN ? "完" : "购";
+        switch (digits.length()) {
+            case 1:
+                return "*0" + digits + suffix;
+            case 2:
+                return mark;
+            case 3:
+                return "*0" + digits + suffix;
+            default:
+                return mark;
+        }
+    }
+}

+ 3 - 0
fs-service/src/main/java/com/fs/sop/mapper/QwSopLogsMapper.java

@@ -384,6 +384,9 @@ public interface QwSopLogsMapper extends BaseMapper<QwSopLogs> {
     @DataSource(DataSourceType.SOP)
     void batchUpdateQwSopLogsBySmsLogsId(@Param("list") List<QwSopLogs> list);
 
+    @DataSource(DataSourceType.SOP)
+    void batchUpdateSuccessQwSopLogsBySmsLogsId(@Param("list") List<QwSopLogs> list);
+
 
     @DataSource(DataSourceType.SOP)
     List<QwSopLogs> getQwSopInfoByUid(@Param("sopSmsLogIds") List<Long> sopSmsLogIds);

+ 1 - 0
fs-service/src/main/resources/application-config-druid-sxjz.yml

@@ -103,6 +103,7 @@ ipad:
   voiceApi:
   commonApi:
   aiApiV2: http://1.95.196.10:3000/api
+  aiApiV3: http://129.28.170.206:3000/api
 wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:

+ 2 - 2
fs-service/src/main/resources/application-config-druid-sxsm.yml

@@ -50,8 +50,8 @@ wx:
         aesKey: Eswa6VjwtVcw03qZy6Wllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
   # 开放平台app微信授权配置
   open:
-    app-id: wx974
-    secret: 32dfaa2b
+    app-id: wx134360220979c73d
+    secret: 6e9fdac615921b9a42fd27781f8e5761
 aifabu:  #爱链接
   appKey: 7b471be905ab17ef358c610dd117601d008
 watch:

+ 4 - 4
fs-service/src/main/resources/application-druid-sxsm.yml

@@ -186,12 +186,12 @@ rocketmq:
     name-server: rmq-16vj8de9bq.rocketmq.cd.qcloud.tencenttdmq.com:8080 # RocketMQ NameServer 地址
     producer:
         group: my-producer-group
-        access-key: ak16pknjrda5f27fdaa338ab # 替换为实际的 accessKey
-        secret-key: sk7d7d9b8d8d44d405 # 替换为实际的 secretKey
+        access-key: ak16vj8de9bq94afcd422462 # 替换为实际的 accessKey
+        secret-key: sk989bb2586d9c77b9 # 替换为实际的 secretKey
     consumer:
         group: voice-group
-        access-key: ak16pknjrda5f27fdaa338ab # 替换为实际的 accessKey
-        secret-key: sk7d7d9b8d8d44d405 # 替换为实际的 secretKey
+        access-key: ak16vj8de9bq94afcd422462 # 替换为实际的 accessKey
+        secret-key: sk989bb2586d9c77b9 # 替换为实际的 secretKey
 custom:
     token: "1o62d3YxvdHd4LEUiltnu7sK"
     encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"

+ 72 - 0
fs-service/src/main/resources/db/changelog/changes/20260613-live-user-add-is-del.sql

@@ -188,3 +188,75 @@ ALTER TABLE fs_store_payment_scrm
 ALTER TABLE fs_store_product_scrm
     ADD COLUMN share_rate int NULL DEFAULT NULL COMMENT '分账比例';
 --rollback ALTER TABLE fs_store_payment_scrm DROP COLUMN share_rate;
+
+--changeset yhq:20250701-fs-user-integral-batch
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_user_integral_batch'
+CREATE TABLE `fs_user_integral_batch` (
+    `batch_id` bigint NOT NULL AUTO_INCREMENT COMMENT '批次ID',
+    `user_id` bigint NOT NULL COMMENT '用户ID',
+    `amount` bigint NOT NULL DEFAULT 0 COMMENT '批次原始积分',
+    `remain` bigint NOT NULL DEFAULT 0 COMMENT '剩余可用积分',
+    `expire_time` datetime NOT NULL COMMENT '过期时间',
+    `source_log_id` bigint DEFAULT NULL COMMENT '来源流水ID',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`batch_id`),
+    KEY `idx_user_expire` (`user_id`,`expire_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户积分批次';
+--rollback DROP TABLE fs_user_integral_batch;
+
+--changeset yhq:20250701-fs-user-integral-logs-expire-time
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_user_integral_logs'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fs_user_integral_logs' AND column_name = 'expire_time'
+ALTER TABLE `fs_user_integral_logs`
+    ADD COLUMN `expire_time` datetime DEFAULT NULL COMMENT '该笔获得积分过期时间' AFTER `business_id`;
+--rollback ALTER TABLE fs_user_integral_logs DROP COLUMN expire_time;
+
+--changeset yhq:20250701-fs-user-integral-logs-platform
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_user_integral_logs'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fs_user_integral_logs' AND column_name = 'platform'
+ALTER TABLE `fs_user_integral_logs`
+    ADD COLUMN `platform` varchar(16) DEFAULT NULL COMMENT '来源端 app/mini' AFTER `expire_time`;
+--rollback ALTER TABLE fs_user_integral_logs DROP COLUMN platform;
+
+--changeset yhq:20250701-fs-integral-goods-visible-scope
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_integral_goods'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fs_integral_goods' AND column_name = 'visible_scope'
+ALTER TABLE `fs_integral_goods`
+    ADD COLUMN `visible_scope` varchar(64) NOT NULL DEFAULT 'app,mini' COMMENT '可见范围 app,mini 多选' AFTER `app_ids`;
+--rollback ALTER TABLE fs_integral_goods DROP COLUMN visible_scope;
+
+--changeset yhq:20250701-sys-integral-log-type-first-purchase
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '32'
+INSERT INTO `sys_dict_data`
+(`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    (32, '首次完成购物', '32', 'sys_integral_log_type', NULL, 'default', 'N', '0', 'admin', NOW(), '', NULL, NULL);
+--rollback DELETE FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '32';
+
+--changeset yhq:20250701-sys-integral-log-type-comment
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '33'
+INSERT INTO `sys_dict_data`
+(`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    (33, '内容留言积分', '33', 'sys_integral_log_type', NULL, 'default', 'N', '0', 'admin', NOW(), '', NULL, NULL);
+--rollback DELETE FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '33';
+
+--changeset yhq:20250701-sys-integral-log-type-28-label-fix
+UPDATE sys_dict_data
+SET dict_label = '首次下载APP获取积分', dict_sort = 28
+WHERE dict_type = 'sys_integral_log_type' AND dict_value = '28';
+--rollback UPDATE sys_dict_data SET dict_label = '管理员后台扣除', dict_sort = 28 WHERE dict_type = 'sys_integral_log_type' AND dict_value = '28';
+
+--changeset yhq:20250706-fs-miniprogram-sub-notify-task-fs-user-id
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fs_miniprogram_sub_notify_task'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fs_miniprogram_sub_notify_task' AND column_name = 'fs_user_id'
+ALTER TABLE `fs_miniprogram_sub_notify_task`
+    ADD COLUMN `fs_user_id` bigint DEFAULT NULL COMMENT 'fs_user.user_id' AFTER `touser`;
+--rollback ALTER TABLE fs_miniprogram_sub_notify_task DROP COLUMN fs_user_id;

+ 39 - 0
fs-service/src/main/resources/db/changelog/dictData/dict_data_update.sql

@@ -74,3 +74,42 @@ VALUES
 --rollback DELETE FROM sys_dict_data WHERE dict_type = 'store_mall_func';
 --rollback DELETE FROM sys_dict_type WHERE dict_type = 'store_mall_func';
 
+--changeset yhq:20260707-sms-send-url-config-dict
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_type WHERE dict_type = 'sms_send_url_config'
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_data WHERE dict_type = 'sms_send_url_config'
+INSERT INTO `sys_dict_type`
+(`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    ('短信发送URL配置', 'sms_send_url_config', '0', 'admin', NOW(), '', NULL, 'sendUrl方法短信发送参数配置,第1项值为1时使用字典中的url/account/password/extno覆盖his.sms配置');
+
+INSERT INTO `sys_dict_data`
+(`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    (1, '是否启用sendUrl独立配置', '0', 'sms_send_url_config', '', 'info', 'Y', '0', 'admin', NOW(), '', NULL, '值为1时使用字典配置的url/account/password/extno,默认0使用his.sms配置,sort就是id不能变'),
+    (2, 'sendUrl请求地址', '', 'sms_send_url_config', '', 'default', 'N', '0', 'admin', NOW(), '', NULL, '对应rfUrl2,为空则使用his.sms配置,sort就是id不能变'),
+    (3, 'sendUrl账号account', '', 'sms_send_url_config', '', 'default', 'N', '0', 'admin', NOW(), '', NULL, '对应rfAccount2,为空则使用his.sms配置,sort就是id不能变'),
+    (4, 'sendUrl密码password', '', 'sms_send_url_config', '', 'default', 'N', '0', 'admin', NOW(), '', NULL, '对应rfPassword2,为空则使用his.sms配置,sort就是id不能变'),
+    (5, 'sendUrl扩展码extno', '', 'sms_send_url_config', '', 'default', 'N', '0', 'admin', NOW(), '', NULL, '对应rfCode2,为空则使用his.sms配置,sort就是id不能变');
+--rollback DELETE FROM sys_dict_data WHERE dict_type = 'sms_send_url_config';
+--rollback DELETE FROM sys_dict_type WHERE dict_type = 'sms_send_url_config';
+
+
+
+--changeset yhq:20250701-sys-integral-log-type-first-purchase
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '32'
+INSERT INTO `sys_dict_data`
+(`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    (32, '首次完成购物', '32', 'sys_integral_log_type', NULL, 'default', 'N', '0', 'admin', NOW(), '', NULL, NULL);
+--rollback DELETE FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '32';
+
+--changeset yhq:20250701-sys-integral-log-type-comment
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '33'
+INSERT INTO `sys_dict_data`
+(`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES
+    (33, '内容留言积分', '33', 'sys_integral_log_type', NULL, 'default', 'N', '0', 'admin', NOW(), '', NULL, NULL);
+--rollback DELETE FROM sys_dict_data WHERE dict_type = 'sys_integral_log_type' AND dict_value = '33';

+ 3 - 0
fs-service/src/main/resources/mapper/fastGpt/FastGptChatSessionMapper.xml

@@ -115,6 +115,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="qwUserId != null">qw_user_id,</if>
             <if test="lastTime != null">last_time,</if>
             <if test="isReply != null">is_reply,</if>
+            <if test="userInfo != null">user_info,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="sessionId != null">#{sessionId},</if>
@@ -137,6 +138,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="qwUserId != null">#{qwUserId},</if>
             <if test="lastTime != null">#{lastTime},</if>
             <if test="isReply != null">#{isReply},</if>
+            <if test="userInfo != null">#{userInfo},</if>
          </trim>
     </insert>
 
@@ -162,6 +164,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="qwUserId != null">qw_user_id = #{qwUserId},</if>
             <if test="lastTime != null">last_time = #{lastTime},</if>
             <if test="isReply != null">is_reply = #{isReply},</if>
+            <if test="userInfo != null">user_info = #{userInfo},</if>
         </trim>
         where session_id = #{sessionId}
     </update>

+ 5 - 1
fs-service/src/main/resources/mapper/his/FsIntegralGoodsMapper.xml

@@ -20,10 +20,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="createTime"    column="create_time"    />
         <result property="barCode"    column="bar_code"    />
         <result property="appIds"    column="app_ids"    />
+        <result property="visibleScope"    column="visible_scope"    />
     </resultMap>
 
     <sql id="selectFsIntegralGoodsVo">
-        select goods_id, img_url, images,bar_code, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, descs, create_time,app_ids from fs_integral_goods
+        select goods_id, img_url, images,bar_code, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, descs, create_time,app_ids,visible_scope from fs_integral_goods
     </sql>
 
     <select id="selectFsIntegralGoodsList" parameterType="FsIntegralGoods" resultMap="FsIntegralGoodsResult">
@@ -61,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">create_time,</if>
             <if test="barCode != null">bar_code,</if>
             <if test="appIds != null">app_ids,</if>
+            <if test="visibleScope != null">visible_scope,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="goodsId != null">#{goodsId},</if>
@@ -78,6 +80,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">#{createTime},</if>
             <if test="barCode != null">#{barCode},</if>
             <if test="appIds != null">#{appIds},</if>
+            <if test="visibleScope != null">#{visibleScope},</if>
          </trim>
     </insert>
 
@@ -98,6 +101,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">create_time = #{createTime},</if>
             <if test="barCode != null">bar_code = #{barCode},</if>
             <if test="appIds != null">app_ids = #{appIds},</if>
+            <if test="visibleScope != null">visible_scope = #{visibleScope},</if>
         </trim>
         where goods_id = #{goodsId}
     </update>

+ 25 - 0
fs-service/src/main/resources/mapper/his/FsUserIntegralBatchMapper.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.his.mapper.FsUserIntegralBatchMapper">
+
+    <insert id="insertFsUserIntegralBatch" parameterType="com.fs.his.domain.FsUserIntegralBatch" useGeneratedKeys="true" keyProperty="batchId">
+        insert into fs_user_integral_batch (user_id, amount, remain, expire_time, source_log_id, create_time)
+        values (#{userId}, #{amount}, #{remain}, #{expireTime}, #{sourceLogId}, #{createTime})
+    </insert>
+
+    <select id="selectExpiredBatches" resultType="com.fs.his.domain.FsUserIntegralBatch">
+        select batch_id as batchId, user_id as userId, amount, remain, expire_time as expireTime,
+               source_log_id as sourceLogId, create_time as createTime
+        from fs_user_integral_batch
+        where remain > 0
+          and create_time &gt;= #{launchDate}
+          and create_time &lt;= DATE_SUB(#{now}, INTERVAL #{expireDays} DAY)
+        order by create_time asc
+        limit #{limit}
+    </select>
+
+    <update id="updateRemain">
+        update fs_user_integral_batch set remain = #{remain} where batch_id = #{batchId}
+    </update>
+
+</mapper>

+ 4 - 0
fs-service/src/main/resources/mapper/his/FsUserIntegralLogsMapper.xml

@@ -56,6 +56,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="integral != null">integral,</if>
             <if test="balance != null">balance,</if>
             <if test="businessId != null">business_id,</if>
+            <if test="expireTime != null">expire_time,</if>
+            <if test="platform != null">platform,</if>
             <if test="createTime != null">create_time,</if>
             <if test="businessType != null">business_type,</if>
             <if test="status != null">status,</if>
@@ -66,6 +68,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="integral != null">#{integral},</if>
             <if test="balance != null">#{balance},</if>
             <if test="businessId != null">#{businessId},</if>
+            <if test="expireTime != null">#{expireTime},</if>
+            <if test="platform != null">#{platform},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="businessType != null">#{businessType},</if>
             <if test="status != null">#{status},</if>

+ 3 - 0
fs-service/src/main/resources/mapper/live/LiveMiniprogramSubNotifyTaskMapper.xml

@@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         task_name,
         template_id,
         touser,
+        fs_user_id,
         page,
         data,
         status,
@@ -27,6 +28,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{item.taskName},
             #{item.templateId},
             #{item.touser},
+            #{item.fsUserId},
             #{item.page},
             #{item.data},
             #{item.status},
@@ -49,6 +51,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             task_name = #{item.taskName},
             template_id = #{item.templateId},
             touser = #{item.touser},
+            fs_user_id = #{item.fsUserId},
             page = #{item.page},
             data = #{item.data},
             status = #{item.status},

+ 29 - 0
fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml

@@ -968,6 +968,35 @@
         </foreach>
     </update>
 
+    <update id="batchUpdateSuccessQwSopLogsBySmsLogsId" parameterType="list">
+        UPDATE qw_sop_logs
+        <set>
+            send_status = 1,
+            receiving_status = 1,
+            real_send_time = NOW(),
+            content_json = COALESCE(
+            CASE sms_logs_id
+            <foreach collection="list" item="item" separator=" ">
+                WHEN #{item.smsLogsId} THEN
+                <choose>
+                    <when test="item.contentJson != null and item.contentJson != ''">
+                        #{item.contentJson}
+                    </when>
+                    <otherwise>
+                        NULL
+                    </otherwise>
+                </choose>
+            </foreach>
+            END,
+            content_json
+            )
+        </set>
+        WHERE sms_logs_id IN
+        <foreach collection="list" item="item" open="(" separator="," close=")">
+            #{item.smsLogsId}
+        </foreach>
+    </update>
+
     <select id="getQwSopInfoByUid" resultType="com.fs.sop.domain.QwSopLogs">
         select id,send_status,receiving_status,expiration_time,send_time,external_id,send_type,sms_logs_id,content_json,qw_user_key from qw_sop_logs where sms_logs_id IN
         <foreach item="item" collection="sopSmsLogIds" separator="," open="(" close=")">

+ 9 - 2
fs-user-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -50,6 +50,8 @@ import com.fs.his.param.FsInquiryOrderFinishParam;
 import com.fs.his.service.*;
 
 import com.fs.his.utils.ConfigUtil;
+import com.fs.his.utils.IntegralConfigHelper;
+import com.fs.his.utils.IntegralTaskScopeHelper;
 import com.fs.his.vo.InquirySubTypeVo;
 import com.fs.hisStore.mapper.FsWechatTemplateScrmMapper;
 import com.fs.hisStore.service.IFsWechatTemplateScrmService;
@@ -340,9 +342,14 @@ public class CommonController {
 
 	@ApiOperation("获取数据字典")
 	@GetMapping("/getDictByKey")
-    @Cacheable(value="dicts", key="#key")
-	public R getDictByKey(@RequestParam(value = "key", required = false) String key){
+    @Cacheable(value="dicts", key="#key + ':' + (#platform != null ? #platform : 'all')")
+	public R getDictByKey(@RequestParam(value = "key", required = false) String key,
+						  @RequestParam(value = "platform", required = false) String platform){
 		List<DictVO> dicts=dictDataService.selectDictDataListByType(key);
+		if (IntegralTaskScopeHelper.shouldFilterIntegralLogType(key, platform)) {
+			dicts = IntegralTaskScopeHelper.filterLogTypesByPlatform(
+					dicts, IntegralConfigHelper.load(configService), platform);
+		}
 		return R.ok().put("data",dicts);
 	}
 

+ 38 - 1
fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java

@@ -52,6 +52,9 @@ public class IntegralController extends  AppBaseController {
     private IFsIntegralCartService cartService;
     @Autowired
     private ISysConfigService configService;
+    @Autowired
+    private IIntegralTaskService integralTaskService;
+
     @ApiOperation("获取积分商品列表")
     @GetMapping("/getIntegralGoodsList")
     @Cacheable(value = "getIntegralGoodsList", key = "#param")
@@ -203,6 +206,37 @@ public class IntegralController extends  AppBaseController {
         return integralOrderService.payment(param, PaymentMethodEnum.H5_ALIPAY);
     }
 
+    @Login
+    @ApiOperation("任务中心")
+    @GetMapping("/taskCenter")
+    public R taskCenter(@RequestParam(value = "platform", defaultValue = "app") String platform) {
+        return integralTaskService.getTaskCenter(Long.parseLong(getUserId()), platform);
+    }
+
+    @Login
+    @ApiOperation("签到弹窗已展示")
+    @PostMapping("/signPopupShown")
+    public R signPopupShown() {
+        integralTaskService.markSignPopupShown(Long.parseLong(getUserId()));
+        return R.ok();
+    }
+
+    @Login
+    @ApiOperation("浏览商城开始计时")
+    @PostMapping("/browse/start")
+    public R browseStart(@RequestBody IntegralBrowseStartParam param) {
+        param.setPlatform(param.getPlatform() != null ? param.getPlatform() : "app");
+        return integralTaskService.startBrowse(Long.parseLong(getUserId()), param);
+    }
+
+    @Login
+    @ApiOperation("浏览商城完成计时")
+    @PostMapping("/browse/complete")
+    public R browseComplete(@RequestBody IntegralBrowseCompleteParam param) {
+        param.setPlatform(param.getPlatform() != null ? param.getPlatform() : "app");
+        return integralTaskService.completeBrowse(Long.parseLong(getUserId()), param);
+    }
+
     @Login
     @ApiOperation("获取用户签到记录")
     @GetMapping("/getUserSign")
@@ -221,7 +255,10 @@ public class IntegralController extends  AppBaseController {
     public R sign(HttpServletRequest request){
         FsUser user=userService.selectFsUserByUserId(Long.parseLong(getUserId()));
         Long integral=userSignService.sign(user);
-        return R.ok("签到获得" + integral + "积分");
+        return R.ok("签到获得" + integral + "积分")
+                .put("taskName", "签到奖励")
+                .put("rewardPoints", integral)
+                .put("toast", "今日签到积分已领取 +" + integral + "积分");
     }
 
     @Login

+ 13 - 0
fs-user-app/src/main/java/com/fs/app/controller/UserController.java

@@ -29,6 +29,8 @@ import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.param.FindUserByParam;
 import com.fs.his.param.FsUserCouponUParam;
 import com.fs.his.param.FsUserEditPushParam;
+import com.fs.his.param.SignNotifyParam;
+import com.fs.his.service.ISignSubscribeNotifyService;
 import com.fs.his.service.IFsDoctorService;
 import com.fs.his.service.IFsPackageService;
 import com.fs.his.service.IFsUserCouponService;
@@ -114,6 +116,17 @@ public class UserController extends  AppBaseController {
     @Autowired
     private IFsCourseWatchLogService watchLogService;
 
+    @Autowired
+    private ISignSubscribeNotifyService signSubscribeNotifyService;
+
+    @Login
+    @ApiOperation("签到提醒小程序订阅")
+    @PostMapping("/subNotifySign")
+    public R subNotifySign(@RequestBody SignNotifyParam param) {
+        param.setUserId(Long.parseLong(getUserId()));
+        return signSubscribeNotifyService.subNotifySign(param);
+    }
+
     @Login
     @ApiOperation("获取用户信息")
     @PostMapping("/friendsSearch")

+ 8 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/CommonScrmController.java

@@ -20,6 +20,8 @@ import com.fs.erp.service.IErpShopService;
 import com.fs.erp.service.IErpUserService;
 import com.fs.his.service.IFsCityService;
 import com.fs.his.utils.ConfigUtil;
+import com.fs.his.utils.IntegralConfigHelper;
+import com.fs.his.utils.IntegralTaskScopeHelper;
 import com.fs.hisStore.config.StoreConfig;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.hisStore.dto.ExpressDataDTO;
@@ -421,8 +423,13 @@ public class CommonScrmController extends AppBaseController {
 
     @ApiOperation("获取数据字典")
     @GetMapping("/getDictByKey")
-    public R getDictByKey(@ApiParam(required = false, name = "key", value = "key") @RequestParam(value = "key", required = false) String key) {
+    public R getDictByKey(@ApiParam(required = false, name = "key", value = "key") @RequestParam(value = "key", required = false) String key,
+                          @ApiParam(required = false, name = "platform", value = "app/mini") @RequestParam(value = "platform", required = false) String platform) {
         List<DictVO> dictVOS = dictDataService.selectDictDataListByType(key);
+        if (IntegralTaskScopeHelper.shouldFilterIntegralLogType(key, platform)) {
+            dictVOS = IntegralTaskScopeHelper.filterLogTypesByPlatform(
+                    dictVOS, IntegralConfigHelper.load(configService), platform);
+        }
         return R.ok().put("data", dictVOS);
     }