Jelajahi Sumber

H5手机号绑定、医生端验签、APP发课定时任务优化等相关代码提交

yjwang 1 hari lalu
induk
melakukan
bfe3dc1890

+ 10 - 7
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -1572,13 +1572,16 @@ public class Task {
             return;
         }
         for (Map.Entry<String, BatchSendCourseAllDTO> entry : toSendMap) {
-            //执行发送消息任务
-            BatchSendCourseAllDTO batchSendCourseAllDTO = entry.getValue();
-            openIMService.batchUrgeCourseTask(batchSendCourseAllDTO.getOpenImBatchMsgDTO(), batchSendCourseAllDTO.getImMsgSendDetailList());
-
-            // 执行结束,删除
-            this.redisTemplate.<String, BatchSendCourseAllDTO>opsForHash().delete(redisKey, entry.getKey());
-
+            try {
+                BatchSendCourseAllDTO batchSendCourseAllDTO = entry.getValue();
+                openIMService.batchUrgeCourseTask(batchSendCourseAllDTO.getOpenImBatchMsgDTO(), batchSendCourseAllDTO.getImMsgSendDetailList());
+            } catch (Exception e) {
+                // 单条失败不影响后续任务;空内容等脏数据也会在此处被清理,避免每分钟重试打爆 OpenIM
+                logger.error("会员-IM催课任务执行失败, redisField={}", entry.getKey(), e);
+            } finally {
+                // 无论成功失败都删除,防止异常任务反复执行
+                this.redisTemplate.<String, BatchSendCourseAllDTO>opsForHash().delete(redisKey, entry.getKey());
+            }
         }
     }
 

+ 21 - 1
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -440,7 +440,7 @@ public class FsUserCourseVideoController extends AppBaseController {
     /**
      * 获取APP好友注册绑定链接
      * <p>将 companyId/companyUserId/projectId 以 JSON 写入 Redis(APPBIND:雪花ID,8小时过期),
-     * 返回「APP好友注册链接域名 + APPBIND:雪花ID」。</p>
+     * 返回「APP好友注册链接域名 + ?uuid=雪花ID」。</p>
      */
     @Login
     @PostMapping("/getBindLink")
@@ -461,6 +461,26 @@ public class FsUserCourseVideoController extends AppBaseController {
         }
     }
 
+    /**
+     * 获取APP手机号H5绑定链接
+     * <p>从登录 token 解析销售公司ID、销售用户ID,写入 Redis(APPBIND:雪花ID,8小时过期),
+     * 返回「APP手机号H5绑定域名 + ?uuid=雪花ID」。无需入参。</p>
+     */
+    @Login
+    @PostMapping("/getPhoneH5BindLink")
+    @ApiOperation("获取APP手机号H5绑定链接")
+    public ResponseResult<String> getPhoneH5BindLink() {
+        try {
+            Long companyId = getCompanyId();
+            Long companyUserId = getCompanyUserId();
+            String link = courseLinkService.getPhoneH5BindLink(companyId, companyUserId);
+            return ResponseResult.ok(link);
+        } catch (Exception e) {
+            log.error("获取APP手机号H5绑定链接失败", e);
+            return ResponseResult.fail(500, e.getMessage());
+        }
+    }
+
     /**
      * 根据 uuid 获取绑定信息(从 Redis APPBIND:uuid 读取,无需登录)
      */

+ 5 - 5
fs-company-app/src/main/java/com/fs/app/param/FsAppBindLinkParam.java

@@ -7,20 +7,20 @@ import lombok.Data;
 import java.io.Serializable;
 
 /**
- * APP\u597d\u53cb\u6ce8\u518c\u7ed1\u5b9a\u94fe\u63a5\u53c2\u6570
+ * APP好友注册绑定链接参数
  */
 @Data
-@ApiModel("APP\u597d\u53cb\u6ce8\u518c\u7ed1\u5b9a\u94fe\u63a5\u53c2\u6570")
+@ApiModel("APP好友注册绑定链接参数")
 public class FsAppBindLinkParam implements Serializable {
 
     private static final long serialVersionUID = 1L;
 
-    @ApiModelProperty(value = "\u516c\u53f8ID", required = true)
+    @ApiModelProperty(value = "公司ID", required = true)
     private Long companyId;
 
-    @ApiModelProperty(value = "\u5458\u5de5ID", required = true)
+    @ApiModelProperty(value = "员工ID", required = true)
     private Long companyUserId;
 
-    @ApiModelProperty(value = "\u9879\u76eeID", required = true)
+    @ApiModelProperty(value = "项目ID", required = true)
     private Long projectId;
 }

+ 3 - 3
fs-company-app/src/main/java/com/fs/app/param/FsAppBindUuidParam.java

@@ -7,14 +7,14 @@ import lombok.Data;
 import java.io.Serializable;
 
 /**
- * \u6839\u636euuid\u67e5\u8be2APP\u597d\u53cb\u6ce8\u518c\u7ed1\u5b9a\u4fe1\u606f\u53c2\u6570
+ * 根据uuid查询APP好友注册绑定信息参数
  */
 @Data
-@ApiModel("\u6839\u636euuid\u67e5\u8be2\u7ed1\u5b9a\u4fe1\u606f\u53c2\u6570")
+@ApiModel("根据uuid查询绑定信息参数")
 public class FsAppBindUuidParam implements Serializable {
 
     private static final long serialVersionUID = 1L;
 
-    @ApiModelProperty(value = "\u7ed1\u5b9a\u94fe\u63a5\u96ea\u82b1ID\uff08\u5bf9\u5e94Redis Key\uff1aAPPBIND:uuid\uff09", required = true)
+    @ApiModelProperty(value = "绑定链接雪花ID(对应Redis Key:APPBIND:uuid)", required = true)
     private String uuid;
 }

+ 37 - 1
fs-doctor-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -2,11 +2,11 @@ package com.fs.app.controller;
 
 
 import cn.hutool.json.JSONUtil;
-import com.alibaba.fastjson.JSON;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fs.app.annotation.Login;
 import com.fs.app.param.SignParam;
 import com.fs.app.utils.CityTreeUtil;
+import com.fs.app.utils.DoctorSignTokenUtils;
 import com.fs.app.utils.JwtUtils;
 import com.fs.app.vo.CityVO;
 import com.fs.app.vo.ImMsgVO;
@@ -103,6 +103,10 @@ public class CommonController {
 	private IImService imService;
 	@Autowired
 	private OpenIMService openIMService;
+
+	@Autowired
+	private DoctorSignTokenUtils doctorSignTokenUtils;
+
 	@ApiOperation("测试")
 	@GetMapping(value = "/getTest")
 	public AjaxResult getTest()
@@ -176,6 +180,38 @@ public class CommonController {
 		return R.ok().put("url",url);
 	}
 
+	/**
+	 * 药师扫码签名H5专用上传接口
+	 * <p>
+	 * 不依赖 AppToken 登录态,通过定向签名 token(doctor_sign)校验身份后上传OSS。
+	 * </p>
+	 *
+	 * @param token 扫码生成的签名临时token
+	 * @param file  签名图片文件
+	 */
+	@ApiOperation("药师签名H5上传OSS(签名token校验)")
+	@PostMapping("uploadOSSBySignToken")
+	public R uploadOSSBySignToken(@RequestParam("token") String token,
+								  @RequestParam("file") MultipartFile file) throws Exception
+	{
+		Long doctorId = doctorSignTokenUtils.validateToken(token);
+		if (doctorId == null) {
+			return R.error(401, "token无效或已过期,请重新扫码");
+		}
+		if (file == null || file.isEmpty())
+		{
+			throw new OssException("上传文件不能为空");
+		}
+		String fileName = file.getOriginalFilename();
+		if (fileName == null || fileName.lastIndexOf(".") < 0) {
+			throw new OssException("上传文件格式不正确");
+		}
+		String suffix = fileName.substring(fileName.lastIndexOf("."));
+		CloudStorageService storage = OSSFactory.build();
+		String url = storage.uploadSuffix(file.getBytes(), suffix);
+		return R.ok().put("url", url);
+	}
+
 
 //	@Login
 //	@GetMapping(value = "/getTlsSig")

+ 1 - 1
fs-doctor-app/src/main/java/com/fs/app/controller/DoctorSignController.java

@@ -65,7 +65,7 @@ public class DoctorSignController {
     /**
      * H5 页面保存签名图片URL
      * <p>
-     * 流程:H5端 canvas 生成签名图片 → 调用 /app/common/uploadOSS 上传得到 url
+     * 流程:H5端 canvas 生成签名图片 → 调用 /app/common/uploadOSSBySignToken 上传得到 url
      * → 调用本接口将 url 保存到 fs_doctor.sign_url 字段
      * </p>
      *

+ 2 - 1
fs-doctor-app/src/main/resources/application.yml

@@ -6,4 +6,5 @@ server:
 spring:
   profiles:
 #    active: dev
-    active: dev-yjb
+    #    active: dev-yjb
+    active: druid-yjb-test #医健宝测试库

+ 6 - 1
fs-doctor-app/src/main/resources/static/doctor-sign.html

@@ -272,12 +272,17 @@
                 uploadSign: function(blob) {
                     var self = this;
                     var formData = new FormData();
+                    formData.append('token', this.token);
                     formData.append('file', blob, 'sign_' + Date.now() + '.png');
-                    axios.post('/app/common/uploadOSS', formData, {
+                    // 使用签名token专用上传接口,避免走AppToken登录校验
+                    axios.post('/app/common/uploadOSSBySignToken', formData, {
                         headers: { 'Content-Type': 'multipart/form-data' }
                     }).then(function(res) {
                         if (res.data.code === 200 && res.data.url) {
                             self.saveSignUrl(res.data.url);
+                        } else if (res.data.code === 401) {
+                            self.saving = false;
+                            self.showToast('凭证已过期,请重新扫码', 3000);
                         } else {
                             self.saving = false;
                             self.showToast(res.data.msg || '签名上传失败');

+ 2 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -23,6 +23,8 @@ public class CourseConfig implements Serializable {
     private String registerDomainName;//注册域名
     /** APP好友注册链接域名 */
     private String appFriendRegisterDomainName;
+    /** APP手机号H5绑定域名 */
+    private String appPhoneH5BindDomainName;
     private String courseDomainName;//链接域名
     private String miniprogramAppid;//链接域名
     private Integer rewardType; // 奖励类型 1红包 2积分 3红包+积分

+ 9 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseLinkService.java

@@ -110,6 +110,15 @@ public interface IFsCourseLinkService
      */
     String getBindLink(Long companyId, Long companyUserId, Long projectId);
 
+    /**
+     * 获取APP手机号H5绑定链接
+     *
+     * @param companyId     销售公司ID
+     * @param companyUserId 销售用户ID
+     * @return APP手机号H5绑定域名 + ?uuid=雪花ID
+     */
+    String getPhoneH5BindLink(Long companyId, Long companyUserId);
+
     /**
      * 根据 uuid 从 Redis 读取绑定信息(与 getBindLink 存入规则一致:APPBIND:uuid)
      *

+ 35 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java

@@ -1102,7 +1102,37 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         if (StringUtils.isEmpty(domain)) {
             throw new CustomException("未配置APP好友注册链接域名");
         }
+        return buildAppBindLink(domain, companyId, companyUserId, projectId);
+    }
 
+    @Override
+    public String getPhoneH5BindLink(Long companyId, Long companyUserId) {
+        if (companyId == null || companyUserId == null) {
+            throw new CustomException("companyId、companyUserId不能为空");
+        }
+        String json = configService.selectConfigByKey("course.config");
+        if (StringUtils.isEmpty(json)) {
+            throw new CustomException("点播配置不存在");
+        }
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        String domain = config.getAppPhoneH5BindDomainName();
+        if (StringUtils.isEmpty(domain)) {
+            throw new CustomException("未配置APP手机号H5绑定域名");
+        }
+        // 雪花ID,Redis Key 形如 APPBIND:1938...(手机号H5绑定不存项目ID)
+        String snowflakeId = IdUtil.getSnowflake(0, 0).nextIdStr();
+        String redisKey = "APPBIND:" + snowflakeId;
+        Map<String, Object> cacheValue = new HashMap<>(2);
+        cacheValue.put("companyId", companyId);
+        cacheValue.put("companyUserId", companyUserId);
+        redisCache.setCacheObject(redisKey, JSON.toJSONString(cacheValue), 8, TimeUnit.HOURS);
+        return domain + "?uuid=" + snowflakeId;
+    }
+
+    /**
+     * 生成APP绑定链接:域名 + ?uuid=雪花ID,并将绑定信息写入 Redis(APPBIND:uuid,8小时)
+     */
+    private String buildAppBindLink(String domain, Long companyId, Long companyUserId, Long projectId) {
         // 雪花ID,Redis Key 形如 APPBIND:1938...
         String snowflakeId = IdUtil.getSnowflake(0, 0).nextIdStr();
         String redisKey = "APPBIND:" + snowflakeId;
@@ -1111,7 +1141,6 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         cacheValue.put("companyUserId", companyUserId);
         cacheValue.put("projectId", projectId);
         redisCache.setCacheObject(redisKey, JSON.toJSONString(cacheValue), 8, TimeUnit.HOURS);
-
         return domain + "?uuid=" + snowflakeId;
     }
 
@@ -1133,13 +1162,16 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         Long companyId = cacheJson.getLong("companyId");
         Long companyUserId = cacheJson.getLong("companyUserId");
         Long projectId = cacheJson.getLong("projectId");
-        if (companyId == null || companyUserId == null || projectId == null) {
+        if (companyId == null || companyUserId == null) {
             throw new CustomException("绑定链接数据不完整");
         }
         Map<String, Object> result = new HashMap<>(4);
         result.put("companyId", companyId);
         result.put("companyUserId", companyUserId);
-        result.put("projectId", projectId);
+        // 手机号H5绑定链接可能不存项目ID,有则返回
+        if (projectId != null) {
+            result.put("projectId", projectId);
+        }
         result.put("uuid", key.startsWith("APPBIND:") ? key.substring("APPBIND:".length()) : key);
         return result;
     }

+ 59 - 5
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -1378,12 +1378,19 @@ public class OpenIMServiceImpl implements OpenIMService {
 
         //是否催课
         if(batchSendCourseDTO.getIsUrgeCourse()){
+            // 催课文本消息 Content 必填,空内容会导致 OpenIM TextElem 校验失败
+            if (StringUtils.isBlank(batchSendCourseDTO.getUrgeContent())) {
+                throw new ServiceException("催课内容不能为空");
+            }
+            if (batchSendCourseDTO.getUrgeTime() == null) {
+                throw new ServiceException("催课时间不能为空");
+            }
             // 组装催课消息数据
             OpenImBatchMsgDTO openImBatchUrgeCourse = makeOpenImBatchMsgDTO(batchSendCourseDTO, courseUrl, objectMapper, userIds, planSendTimeStamp, "催课");
 
             //缓存定时催课消息
             int urgSendType;
-            if(batchSendCourseDTO.getUrgeTime() != null && batchSendCourseDTO.getUrgeTime().compareTo(new Date()) > 0) {
+            if(batchSendCourseDTO.getUrgeTime().compareTo(new Date()) > 0) {
                 urgSendType = 1; //定时
             } else {
                 urgSendType = 2; //实时
@@ -1423,11 +1430,12 @@ public class OpenIMServiceImpl implements OpenIMService {
             openImBatchMsgDTO.setContent(content);
             openImBatchMsgDTO.setContentType(110);
         } else {
-            // 催课
-//            extension.setTitle(batchSendCourseDTO.getUrgeContent());
-//            extension.setSendTime(batchSendCourseDTO.getUrgeTime() != null ? batchSendCourseDTO.getUrgeTime() : new Date());
+            // 催课:OpenIM contentType=101 文本消息,Content 必填
+            if (StringUtils.isBlank(batchSendCourseDTO.getUrgeContent())) {
+                throw new ServiceException("催课内容不能为空");
+            }
             OpenImBatchMsgDTO.Content content = new OpenImBatchMsgDTO.Content();
-            content.setContent(batchSendCourseDTO.getUrgeContent());
+            content.setContent(batchSendCourseDTO.getUrgeContent().trim());
             openImBatchMsgDTO.setContent(content);
             openImBatchMsgDTO.setContentType(101);
             openImBatchMsgDTO.setSendTime(batchSendCourseDTO.getUrgeTime() != null ? batchSendCourseDTO.getUrgeTime().getTime() : System.currentTimeMillis());
@@ -1479,6 +1487,19 @@ public class OpenIMServiceImpl implements OpenIMService {
     @Transactional
     public OpenImResponseDTO batchUrgeCourseTask(OpenImBatchMsgDTO openImBatchMsgDTO, List<FsImMsgSendDetail> imMsgSendDetailList) {
         log.info("批量催课消息: \n{}", JSON.toJSONString(openImBatchMsgDTO));
+        // 兜底:历史脏数据可能 Content 为空,避免反复打 OpenIM 报 TextElem.Content required
+        if (openImBatchMsgDTO == null
+                || openImBatchMsgDTO.getContent() == null
+                || StringUtils.isBlank(openImBatchMsgDTO.getContent().getContent())) {
+            log.error("催课消息内容为空,跳过发送。recvIDs={}", openImBatchMsgDTO != null ? openImBatchMsgDTO.getRecvIDs() : null);
+            OpenImResponseDTO failResp = new OpenImResponseDTO();
+            failResp.setErrCode(400);
+            failResp.setErrMsg("催课内容不能为空");
+            failResp.setErrDlt("TextElem.Content is required but empty");
+            // 直接落库失败状态并返回,避免再抛异常导致事务回滚后状态丢失
+            markUrgeCourseSendFailed(openImBatchMsgDTO, imMsgSendDetailList, failResp);
+            return failResp;
+        }
         OpenImResponseDTO openImResponseDTO = openIMBatchSendMsg(openImBatchMsgDTO);
 
         // 修改发送记录
@@ -1486,6 +1507,34 @@ public class OpenIMServiceImpl implements OpenIMService {
         return openImResponseDTO;
     }
 
+    /**
+     * 催课发送前校验失败时,标记明细与主表为已处理失败(不抛异常,保证事务可提交)
+     */
+    private void markUrgeCourseSendFailed(OpenImBatchMsgDTO openImBatchMsgDTO,
+                                          List<FsImMsgSendDetail> imMsgSendDetailList,
+                                          OpenImResponseDTO openImResponseDTO) {
+        if (imMsgSendDetailList == null || imMsgSendDetailList.isEmpty()) {
+            return;
+        }
+        Date now = new Date();
+        List<FsImMsgSendDetail> updateList = imMsgSendDetailList.stream().map(v -> {
+            v.setSendStatus(1)
+                    .setParamJson(openImBatchMsgDTO == null ? null : JSON.toJSONString(openImBatchMsgDTO))
+                    .setStatus(1)
+                    .setResultMessage(StringUtils.substring(JSON.toJSONString(openImResponseDTO), 0, 3999))
+                    .setExceptionInfo(StringUtils.substring(openImResponseDTO.getErrDlt(), 0, 1999))
+                    .setUpdateTime(now);
+            return v;
+        }).collect(Collectors.toList());
+        fsImMsgSendDetailServiceImpl.updateBatchById(updateList);
+
+        FsImMsgSendLog fsImMsgSendLog = new FsImMsgSendLog();
+        fsImMsgSendLog.setLogId(imMsgSendDetailList.get(0).getLogId());
+        fsImMsgSendLog.setSendStatus(1);
+        fsImMsgSendLog.setUpdateTime(now);
+        fsImMsgSendLogMapper.updateById(fsImMsgSendLog);
+    }
+
     @Override
     @Transactional
     public OpenImResponseDTO batchUrgeCourse(BatchUrgeCourseDTO batchUrgeCourseDTO, FsImMsgSendLog fsImMsgSendLog, String url) throws JsonProcessingException {
@@ -1526,6 +1575,11 @@ public class OpenIMServiceImpl implements OpenIMService {
         }
 
          // 催课
+        if (StringUtils.isBlank(batchUrgeCourseDTO.getUrgeContent())) {
+            openImResponseDTO.setErrCode(400);
+            openImResponseDTO.setErrMsg("催课内容不能为空");
+            return openImResponseDTO;
+        }
         // 组装催课消息
         BatchSendCourseDTO batchSendCourseDTO = new BatchSendCourseDTO();
         BeanUtils.copyProperties(fsImMsgSendLog, batchSendCourseDTO);

+ 1 - 0
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -146,4 +146,5 @@ public class CourseFsUserController extends AppBaseController {
         logger.error("zyp \n【h5看课中途报错】:{}",msg);
     }
 
+
 }

+ 46 - 0
fs-user-app/src/main/java/com/fs/app/controller/course/FsUserCourseVideoController.java

@@ -0,0 +1,46 @@
+package com.fs.app.controller.course;
+
+
+import com.baidu.dev2.thirdparty.swagger.annotations.Api;
+import com.baidu.dev2.thirdparty.swagger.annotations.ApiOperation;
+import com.fs.app.controller.AppBaseController;
+import com.fs.app.param.FsAppBindUuidParam;
+import com.fs.common.core.domain.ResponseResult;
+import com.fs.course.service.IFsCourseLinkService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@Api("课程库相关接口")
+@RestController
+@RequestMapping("/app/fs/course")
+@Slf4j
+public class FsUserCourseVideoController extends AppBaseController {
+    @Autowired
+    private IFsCourseLinkService courseLinkService;
+
+    /**
+     * 根据 uuid 获取绑定信息(从 Redis APPBIND:uuid 读取,无需登录)
+     */
+    @PostMapping("/getBindInfoByUuid")
+    @ApiOperation("根据uuid获取APP好友注册绑定信息")
+    public ResponseResult<Map<String, Object>> getBindInfoByUuid(@RequestBody FsAppBindUuidParam param) {
+        if (param == null || StringUtils.isEmpty(param.getUuid())) {
+            return ResponseResult.fail(500, "uuid不能为空");
+        }
+        try {
+            Map<String, Object> info = courseLinkService.getBindInfoByUuid(param.getUuid());
+            return ResponseResult.ok(info);
+        } catch (Exception e) {
+            log.error("根据uuid获取绑定信息失败", e);
+            return ResponseResult.fail(500, e.getMessage());
+        }
+    }
+
+}

+ 20 - 0
fs-user-app/src/main/java/com/fs/app/param/FsAppBindUuidParam.java

@@ -0,0 +1,20 @@
+package com.fs.app.param;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 根据uuid查询APP好友注册绑定信息参数
+ */
+@Data
+@ApiModel("根据uuid查询绑定信息参数")
+public class FsAppBindUuidParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "绑定链接雪花ID(对应Redis Key:APPBIND:uuid)", required = true)
+    private String uuid;
+}