Bläddra i källkod

手机号授权登录

yjwang 1 dag sedan
förälder
incheckning
180ee13584
28 ändrade filer med 1618 tillägg och 12 borttagningar
  1. 7 0
      fs-service/src/main/java/com/fs/his/domain/FsUser.java
  2. 4 0
      fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java
  3. 15 0
      fs-service/src/main/java/com/fs/lotus/config/LotusSignatureConfigurer.java
  4. 23 0
      fs-service/src/main/java/com/fs/lotus/config/LotusSignatureProperties.java
  5. 70 0
      fs-service/src/main/java/com/fs/lotus/domain/LotusActivationUser.java
  6. 21 0
      fs-service/src/main/java/com/fs/lotus/domain/LotusClaimRecord.java
  7. 119 0
      fs-service/src/main/java/com/fs/lotus/domain/LotusEntinementActivation.java
  8. 45 0
      fs-service/src/main/java/com/fs/lotus/domain/LotusInquiryOrderMsg.java
  9. 72 0
      fs-service/src/main/java/com/fs/lotus/domain/LotusPaymentConfig.java
  10. 18 0
      fs-service/src/main/java/com/fs/lotus/mapper/LotusActivationUserMapper.java
  11. 38 0
      fs-service/src/main/java/com/fs/lotus/mapper/LotusEntinementActivationMapper.java
  12. 88 0
      fs-service/src/main/java/com/fs/lotus/mapper/LotusHealthMapper.java
  13. 22 0
      fs-service/src/main/java/com/fs/lotus/mapper/LotusInquiryOrderMsgMapper.java
  14. 42 0
      fs-service/src/main/java/com/fs/lotus/param/ActivateInfoDTO.java
  15. 18 0
      fs-service/src/main/java/com/fs/lotus/param/ActivateUserDTO.java
  16. 31 0
      fs-service/src/main/java/com/fs/lotus/param/InitUserDto.java
  17. 29 0
      fs-service/src/main/java/com/fs/lotus/param/LoginDto.java
  18. 142 0
      fs-service/src/main/java/com/fs/lotus/param/LotusClaimParam.java
  19. 27 0
      fs-service/src/main/java/com/fs/lotus/param/LotusHealthPayParam.java
  20. 29 0
      fs-service/src/main/java/com/fs/lotus/param/OpenApiRequest.java
  21. 95 0
      fs-service/src/main/java/com/fs/lotus/utils/LotusSignUtil.java
  22. 189 0
      fs-service/src/main/java/com/fs/lotus/utils/SignatureUtil.java
  23. 3 0
      fs-service/src/main/resources/application-config-druid-bjczwh.yml
  24. 4 0
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  25. 353 12
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  26. 6 0
      fs-user-app/src/main/java/com/fs/app/param/FsUserEditPhoneParam.java
  27. 11 0
      fs-user-app/src/main/java/com/fs/app/param/FsUserLoginParam.java
  28. 97 0
      fs-user-app/src/main/java/com/fs/app/utils/UniVerifyUtil.java

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

@@ -283,6 +283,13 @@ public class FsUser extends BaseEntity
     @TableField(exist = false)
     private String nicknameExact;
 
+
+    /** 首次登录时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date firstLoginTime;
+
+
+
 //    /**
 //     * 搜索关键词-电话号码/会员id/会员昵称
 //     * **/

+ 4 - 0
fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -117,4 +117,8 @@ public class PhoneUtil {
         }
         return text;
     }
+
+    public static void main(String[] args) {
+        System.out.println(encryptPhone("19111374008"));
+    }
 }

+ 15 - 0
fs-service/src/main/java/com/fs/lotus/config/LotusSignatureConfigurer.java

@@ -0,0 +1,15 @@
+package com.fs.lotus.config;
+
+import com.fs.lotus.utils.SignatureUtil;
+import org.springframework.stereotype.Component;
+
+/**
+ * Inject lotus.signature into SignatureUtil on startup.
+ */
+@Component
+public class LotusSignatureConfigurer {
+
+    public LotusSignatureConfigurer(LotusSignatureProperties properties) {
+        SignatureUtil.init(properties);
+    }
+}

+ 23 - 0
fs-service/src/main/java/com/fs/lotus/config/LotusSignatureProperties.java

@@ -0,0 +1,23 @@
+package com.fs.lotus.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Lotus open API signature config (override per env; keep secrets out of hardcode).
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "lotus.signature")
+public class LotusSignatureProperties {
+
+    /** app id */
+    private String appId;
+
+    /** app secret */
+    private String appSecret;
+
+    /** request expire seconds */
+    private long expireSeconds;
+}

+ 70 - 0
fs-service/src/main/java/com/fs/lotus/domain/LotusActivationUser.java

@@ -0,0 +1,70 @@
+package com.fs.lotus.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+* <p>
+* lotus_activation_user 实体类
+* </p>
+*
+* @author Administrator
+* @since 2026-06-18 16:33:15
+*/
+@Getter
+@Setter
+@TableName("lotus_activation_user")
+public class LotusActivationUser implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+    * 主键ID
+    */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+    * 权益激活主表ID (外键关联)
+    */
+    private Long activationId;
+
+    private Long userId;
+
+    /**
+    * 证件号码
+    */
+    private String certNo;
+
+    /**
+    * 证件类型  0: 居民身份证;1: 户口簿;2: 港澳居民来往内地通行证;3: 台湾居民来往大陆通行证;4: 外国人永久居留身份证;5: 中国人民解放军军官证;6: 中国人民解放军士兵证;7: 临时居民身份证;8: 普通护照;9: 中国人民武装警察部队警官证;10: 中国人民武装警察部队士兵证;11: 大陆居民来往台湾通行证;12: 前往港澳通行证;13: 来往港澳通行证;14: 人民警察证;15: 其他
+    */
+    private String certType;
+
+    /**
+    * 创建时间
+    */
+    private LocalDateTime createTime;
+
+    /**
+    * 状态: 0-正常/激活成功, 1-已注销
+    */
+    private Integer status;
+
+    /**
+    * 更新时间
+    */
+    private LocalDateTime updateTime;
+
+    /**
+    * 激活使用人姓名
+    */
+    private String userName;
+
+
+}

+ 21 - 0
fs-service/src/main/java/com/fs/lotus/domain/LotusClaimRecord.java

@@ -0,0 +1,21 @@
+package com.fs.lotus.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class LotusClaimRecord {
+    private Long id;
+    private Long orderId;
+    private String thirdPartyReceiveId;
+    private String activationCode;
+    private String orderNo;
+    private Integer claimStatus;
+    private Integer errorCode;
+    private String errorMsg;
+    private String requestData;
+    private String responseData;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 119 - 0
fs-service/src/main/java/com/fs/lotus/domain/LotusEntinementActivation.java

@@ -0,0 +1,119 @@
+package com.fs.lotus.domain;
+
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+* <p>
+* lotus_entinement_activation 实体类
+* </p>
+*
+* @author Administrator
+* @since 2026-06-18 16:28:41
+*/
+@Getter
+@Setter
+@TableName("lotus_entinement_activation")
+public class LotusEntinementActivation implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+    * 主键ID
+    */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+    * 激活唯一编码(业务主键)
+    */
+    private String activateBizCode;
+
+    /**
+    * 激活唯一码
+    */
+    private String activationCode;
+
+    /**
+    * 创建时间
+    */
+    private LocalDateTime createTime;
+
+    /**
+    * 结束时间
+    */
+    private LocalDateTime endTime;
+
+    /**
+    * 请求流水号
+    */
+    private String requestSerialNo;
+
+    /**
+    * 开始时间
+    */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startTime;
+
+    /**
+    * 状态: 0-正常/激活成功, 1-已注销/整单回滚
+    */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Integer status;
+
+    /**
+    * 三方产品ID
+    */
+    private String thirdPartyProductId;
+
+    /**
+    * 更新时间
+    */
+    private LocalDateTime updateTime;
+
+    /**
+    * 用户ID
+    */
+    private Long userId;
+
+    /**
+     * 总保险金额,单位:分
+     */
+    private Long totalInsuredAmount;
+
+    /**
+     * 每月剩余赔付次数
+     */
+    private Integer remainMonthClaimTimes;
+
+    /**
+     * 全年剩余赔付次数
+     */
+    private Integer remainYearClaimTimes;
+
+    /**
+     * 单次赔付上限,单位:分
+     */
+    private Long singleMaxLimit;
+
+    /**
+     * 赔付标准
+     */
+    private String claimCondition;
+
+    /**
+     * 剩余额度
+     */
+    private Long remainQuota;
+
+    private String lastResetMonth;
+
+
+}

+ 45 - 0
fs-service/src/main/java/com/fs/lotus/domain/LotusInquiryOrderMsg.java

@@ -0,0 +1,45 @@
+package com.fs.lotus.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * lotus保单咨询消息对象 lotus_inquiry_order_msg
+ */
+@Data
+public class LotusInquiryOrderMsg {
+
+    /** ID */
+    private Long msgId;
+
+    /** 消息类型 1会员消息 2医生消息 */
+    private String msgType;
+
+    /** 会员ID */
+    private String fromAccount;
+
+    /** 医生ID */
+    private String toAccount;
+
+    /** 消息内容 */
+    private String content;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 订单ID */
+    private Long orderId;
+
+    /** 消息key */
+    private String msgKey;
+
+    /** 1 文本 2语音 3图像 4 视频 */
+    private Integer msgContentType;
+
+    /** 销售ID */
+    private String companyUserAccount;
+
+    /** app_customer_role_id */
+    private String appCustomerRoleAccount;
+}

+ 72 - 0
fs-service/src/main/java/com/fs/lotus/domain/LotusPaymentConfig.java

@@ -0,0 +1,72 @@
+package com.fs.lotus.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 保险公司支付配置表
+ * @author YourName
+ * @date 2026-07-07
+ */
+@Data
+public class LotusPaymentConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * insurance_company主键
+     */
+    private Long insuranceId;
+
+    /**
+     * 支付类型:WX-微信
+     */
+    private String payType;
+
+    /**
+     * 小程序appid
+     */
+    private String appid;
+
+    /**
+     * 微信商户号
+     */
+    private String wxMerchantNo;
+
+    /**
+     * 微信Key
+     */
+    private String wxKey;
+
+    /**
+     * 回调地址
+     */
+    private String notifyUrl;
+
+    /**
+     * 证书
+     */
+    private String keyPath;
+
+    /**
+     * 状态,1启用,0禁用
+     */
+    private Integer status;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/lotus/mapper/LotusActivationUserMapper.java

@@ -0,0 +1,18 @@
+package com.fs.lotus.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.lotus.domain.LotusActivationUser;
+
+/**
+* <p>
+* lotus_activation_user Mapper 接口
+* </p>
+*
+* @author Administrator
+* @since 2026-06-18 17:04:58
+*/
+public interface LotusActivationUserMapper extends BaseMapper<LotusActivationUser> {
+
+
+}

+ 38 - 0
fs-service/src/main/java/com/fs/lotus/mapper/LotusEntinementActivationMapper.java

@@ -0,0 +1,38 @@
+package com.fs.lotus.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.his.domain.FsPatient;
+import com.fs.lotus.domain.LotusEntinementActivation;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+* <p>
+* lotus_entinement_activation Mapper 接口
+* </p>
+*
+* @author Administrator
+* @since 2026-06-18 17:06:30
+*/
+@Mapper
+public interface LotusEntinementActivationMapper extends BaseMapper<LotusEntinementActivation> {
+    Map<String, Object> selectBythirdPartyProductId(String thirdPartyProductId);
+
+    List<FsPatient> selectPatientByCertNo(@Param("certNoList") List<String> certNoList);
+
+    LotusEntinementActivation selectActivation(Long userId);
+
+    /**
+     * 查询保险产品配置
+     */
+    Map<String, Object> selectInsuranceCoverage();
+
+    /**
+     * 批量重置月度剩余次数
+     */
+    int batchResetMonthlyClaimTimes(@Param("currentMonth") String currentMonth, @Param("maxMonthClaimTimes") int maxMonthClaimTimes);
+}

+ 88 - 0
fs-service/src/main/java/com/fs/lotus/mapper/LotusHealthMapper.java

@@ -0,0 +1,88 @@
+package com.fs.lotus.mapper;
+
+import com.fs.his.domain.FsInquiryOrder;
+import com.fs.his.domain.FsPatient;
+import com.fs.his.domain.FsStoreOrder;
+import com.fs.his.domain.FsUser;
+import com.fs.lotus.domain.LotusClaimRecord;
+import com.fs.lotus.domain.LotusEntinementActivation;
+import com.fs.lotus.domain.LotusPaymentConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface LotusHealthMapper {
+    /**
+     * 根据诊断名称查询icd (精准)
+     * @param diagnosisName
+     * @return
+     */
+    String selectIcdBydiagnosisName(String diagnosisName);
+
+    /**
+     * 根据诊断名称查询icd (模糊)
+     * @param diagnosisName
+     * @return
+     */
+    String searchByDiagnosisName(String diagnosisName);
+
+    FsPatient selectUserByIdCard(@Param("idCard") String idCard, @Param("activateBizCode") String activateBizCode);
+
+    int updatePatient(FsPatient fsPatient);
+
+    int updateFsUser(FsUser fsUser);
+
+    List<FsPatient> getPatientList(long userId);
+
+    List<FsInquiryOrder> getInquiryOrder(long userId);
+
+    LotusPaymentConfig selectPayConfig();
+
+    LotusPaymentConfig selectPayConfigByMchId(@Param("mchId") String mchId);
+
+    int insertClaimRecord(LotusClaimRecord record);
+
+    String selectOpenid(Long userId);
+
+    Map<String, Object> selectInfo(Long orderId);
+
+    Map<String, Object> selectReport(String orderId);
+
+    Map<String, Object> selectInquiry(String orderId);
+
+    Map<String, Object> selectPrescribe(String prescribeId);
+
+    Map<String, Object> selectIcd(String inquiryResult);
+
+    List<Map<String, Object>> selectBillLog(Long orderId);
+
+    String selectIcdByName(String diagnose);
+
+    /**
+     * 查询lotus聊天记录
+     */
+    List<Map<String, Object>> selectLotusMsgList(@Param("orderId") Long orderId);
+
+    /**
+     * 查询医生姓名
+     */
+    String selectDoctorNameById(@Param("doctorId") String doctorId);
+
+    /**
+     * 查询用户昵称
+     */
+    String selectUserNickNameById(@Param("userId") String userId);
+
+    LotusEntinementActivation selectByUserId(String userId);
+
+    List<FsInquiryOrder> selectInquiryList();
+
+    List<FsStoreOrder> selectStoreOrderList(List<FsInquiryOrder> inquiryOrders);
+
+    String getPrescribe(Long prescribeId);
+
+    String getBill(Long orderId);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/lotus/mapper/LotusInquiryOrderMsgMapper.java

@@ -0,0 +1,22 @@
+package com.fs.lotus.mapper;
+
+import com.fs.lotus.domain.LotusInquiryOrderMsg;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * lotus保单咨询消息Mapper接口
+ */
+public interface LotusInquiryOrderMsgMapper {
+
+    /**
+     * 新增消息
+     */
+    int insertLotusInquiryOrderMsg(LotusInquiryOrderMsg msg);
+
+    /**
+     * 根据msgKey删除消息
+     */
+    @Delete("delete from lotus_inquiry_order_msg where msg_key = #{msgKey}")
+    void deleteByMsgKey(@Param("msgKey") String msgKey);
+}

+ 42 - 0
fs-service/src/main/java/com/fs/lotus/param/ActivateInfoDTO.java

@@ -0,0 +1,42 @@
+package com.fs.lotus.param;
+
+
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Data
+public class ActivateInfoDTO {
+
+//    @NotBlank(message = "三方产品ID不能为空")
+    private String thirdPartyProductId;
+
+    @NotBlank(message = "请求流水号不能为空")
+    private String requestSerialNo;
+
+//    @NotBlank(message = "开始时间不能为空")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startTime;
+
+//    @NotBlank(message = "结束时间不能为空")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime endTime;
+
+    @NotNull(message = "用户ID不能为空")
+    private Long userId;
+
+    @NotBlank(message = "激活唯一码不能为空")
+    private String activationCode;
+
+
+    private List<ActivateUserDTO> activateUser;
+    private List<ActivateUserDTO> cancelUser;
+}
+
+
+

+ 18 - 0
fs-service/src/main/java/com/fs/lotus/param/ActivateUserDTO.java

@@ -0,0 +1,18 @@
+package com.fs.lotus.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class ActivateUserDTO {
+    // 针对文档中的 useName 字段名,如果实际代码叫 userName 请自行替换
+    @NotBlank(message = "激活使用人姓名不能为空")
+    private String userName;
+
+    @NotBlank(message = "证件类型不能为空")
+    private String certType;
+
+    @NotBlank(message = "证件号码不能为空")
+    private String certNo;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/lotus/param/InitUserDto.java

@@ -0,0 +1,31 @@
+package com.fs.lotus.param;
+
+import com.baidu.dev2.thirdparty.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.util.Date;
+
+@Data
+public class InitUserDto {
+    @NotBlank(message = "用户姓名不能为空")
+    private String userName;
+    @NotBlank(message = "手机号不能为空")
+    private String phone;
+    @NotBlank(message = "身份证号不能为空")
+    private String idCard;
+
+    @NotBlank(message = "出生日期不能为空")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date birthday;
+
+    @NotBlank(message = "性别不能为空")
+    private Integer sex;
+
+    @NotBlank(message = "类型不能为空")
+    private String entranceType;
+
+    private String activateBizCode;
+
+    private String openId;
+}

+ 29 - 0
fs-service/src/main/java/com/fs/lotus/param/LoginDto.java

@@ -0,0 +1,29 @@
+package com.fs.lotus.param;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class LoginDto {
+    /**
+     * 应用ID
+     */
+    private String appId;
+
+    /**
+     * 时间戳
+     */
+    private Long timestamp;
+
+    /**
+     * 签名
+     */
+    private String sign;
+
+    /**
+     * 业务数据
+     */
+    private Map<String,Object> data;
+
+}

+ 142 - 0
fs-service/src/main/java/com/fs/lotus/param/LotusClaimParam.java

@@ -0,0 +1,142 @@
+package com.fs.lotus.param;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 荷叶接口 - 三方理赔数据回传 参数
+ */
+@Data
+public class LotusClaimParam {
+    /** 操作类型:ADD-新增(目前只支持新增) */
+    private String operationType = "ADD";
+
+    /** 三方权益 ID,与 receiveId、orderNo 做新增唯一性校验 */
+    private String thirdPartyReceiveId;
+
+    /** 荷叶权益 激活码,用于校验权益关系是否存在 */
+    private String activationCode;
+
+    /** 三方订单号 */
+    private String orderNo;
+
+    /** 订单状态:50-已完成 */
+    private Integer orderStatus = 50;
+
+    /** 订单总金额,单位元,精确到小数点后 2 位 */
+    private BigDecimal orderTotalAmount;
+
+    /** 商品总金额,单位元,精确到小数点后 2 位 (必须等于发票金额 invoiceAmt) */
+    private BigDecimal productTotalAmount;
+
+    /** 订单优惠金额,单位元,不包含其他优惠,权益优惠金额 */
+    private BigDecimal channelProductDiscountAmount;
+
+    /** 运费/实付运费,单位元,免运费传 0 */
+    private BigDecimal deliveryAmount;
+
+    /** 运费原价,单位元,免运费传 0 */
+    private BigDecimal deliveryOriginAmount;
+
+    /** 优惠券金额,单位元,无优惠传 0 */
+    private BigDecimal couponAmount;
+
+    /** 实付金额,单位元 (商品总金额 + 运费 - 订单优惠金额 - 优惠券金额) */
+    private BigDecimal payAmount;
+
+    /** 订单创建时间,格式:yyyy-MM-dd HH:mm:ss */
+    private String orderCreateTime;
+
+    /** 订单完成时间,格式:yyyy-MM-dd HH:mm:ss */
+    private String orderCompletedTime;
+
+    /** 问诊单号 */
+    private String inquiryNo;
+
+    /** 处方单号 */
+    private String prescriptionNo;
+
+    /** 诊断名称,按荷叶提供的 ICD 编码对应名称回传 */
+    private String icdName;
+
+    /** 诊断编码,按荷叶提供的 ICD 编码回传 */
+    private String icd;
+
+    /** 开方时间,格式:yyyy-MM-dd HH:mm:ss */
+    private String prescriptionTime;
+
+    /** 问诊开始时间,格式:yyyy-MM-dd HH:mm:ss */
+    private String visitStartTime;
+
+    /** 问诊结束时间,格式:yyyy-MM-dd HH:mm:ss */
+    private String visitEndTime;
+
+    /** 互联网医院名称/就诊医院 */
+    private String hospital = "御君方互联网医院";
+
+    /** 互联网医院地址/出险地点详情 */
+    private String address = "四川省成都市温江区";
+
+    /** 医院所属省编码 */
+    private String provinceCode = "510000";
+
+    /** 医院所属省名称 */
+    private String provinceName = "四川省";
+
+    /** 医院所属市编码 */
+    private String cityCode = "510100";
+
+    /** 医院所属市名称 */
+    private String cityName = "成都市";
+
+    /** 医院所属区/县编码 */
+    private String countryCode = "510115";
+
+    /** 医院所属区/县名称 */
+    private String countryName = "温江区";
+
+    /** 门诊-医疗费票据-发票号码 */
+    private String receiptNumber;
+
+    /** 门诊-医疗费票据-发票金额,单位元 (须等于 productTotalAmount) */
+    private BigDecimal invoiceAmt;
+
+    /** 门诊-医疗费票据-发票类型,固定传 "1"(电子发票) */
+    private String invoiceTypeCd = "1";
+
+    /** 门诊-医疗费票据-电子票据代码,已废弃,默认传空字符串 */
+    private String electronicBillCode = "";
+
+    /** 门诊-医疗费票据-开票日期,格式:yyyy-MM-dd */
+    private String invoiceDate;
+
+    /** 门诊-医疗费票据-校验码,已废弃,默认传空字符串 */
+    private String verificationCode = "";
+
+    /** 理赔影像资料列表 (必须包含处方笺、问诊聊天记录、发票影像) */
+    private List<ClaimDocument> claimDocuments;
+
+    /**
+     * 内部类:理赔影像资料对象
+     */
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class ClaimDocument {
+        /** 文件类型:1-图片、2-pdf、3-excel */
+        private Integer type;
+
+        /** 影像类型:PRESCRIPTION-处方笺、CHAT_RECORD-问诊聊天记录、PRODUCT_INVOICE-发票影像 */
+        private String imageType;
+
+        /** 文件链接,可公网访问的 URL */
+        private String fileLink;
+
+        /** 文件名称 */
+        private String fileName;
+    }
+}

+ 27 - 0
fs-service/src/main/java/com/fs/lotus/param/LotusHealthPayParam.java

@@ -0,0 +1,27 @@
+package com.fs.lotus.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 荷叶健康-支付请求参数
+ */
+@Data
+public class LotusHealthPayParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 商城订单ID */
+    @NotNull(message = "订单ID不能为空")
+    private Long orderId;
+
+    private Long userId;
+
+    /** 保险公司小程序用户的openid */
+    private String openid;
+
+    /** 支付完成后跳转地址 */
+    private String redirectUrl = "https://userapp.his.cdwjyyh.com/lotusUI/";
+}

+ 29 - 0
fs-service/src/main/java/com/fs/lotus/param/OpenApiRequest.java

@@ -0,0 +1,29 @@
+package com.fs.lotus.param;
+
+import lombok.Data;
+
+@Data
+public class OpenApiRequest {
+
+    /**
+     * 应用ID
+     */
+    private String appId;
+
+    /**
+     * 时间戳
+     */
+    private Long timestamp;
+
+    /**
+     * 签名
+     */
+    private String sign;
+
+    private String  data;
+
+    /**
+     * 业务数据
+     */
+//    private InitUserDto data;
+}

+ 95 - 0
fs-service/src/main/java/com/fs/lotus/utils/LotusSignUtil.java

@@ -0,0 +1,95 @@
+package com.fs.lotus.utils;
+
+import cn.hutool.http.HttpUtil;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.lotus.param.LotusClaimParam;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.util.DigestUtils;
+
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+@Slf4j
+public class LotusSignUtil {
+
+    private LotusSignUtil(){}
+    // 测试环境
+    private static final String APPID = "ykqzhonghuacaiRdhlFlDS";
+    private static final String APPSECRET = "9c1814fad5959d3511e346b4e659925f";
+    private static final String LOTUSURL = "https://openapi-ykq.test.ybm100.com";
+
+    // 正式环境
+//    private static final String appId = "ykqtaipingcaisuicheKXH5rsci";
+//    private static final String appSecret = "c2b16086e63d2d0c69e633405a63c9f6";
+//    private static final String lotusUrl = "https://openapi-ykq.ybm100.com";
+    private static final String SIGN = "sign";
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
+    /**
+     * 生成sign
+     *
+     * @param 请求体参数
+     * @param secretKey 密钥,也就是appSecret
+     * @return sign
+     */
+    public static String generateSign(Map<String, Object> params, String secretKey) {
+        String encodeString = getEncodeString(params, secretKey);
+        return generateSign(encodeString);
+    }
+
+    private static String getEncodeString(Map<String, Object> params, String secretKey) {
+        Iterator<String> iterator = params.keySet().iterator();
+        Set<String> sortedParams = new TreeSet<>();
+        while (iterator.hasNext()) {
+            sortedParams.add(iterator.next());
+        }
+        StringBuilder stringBuilder = new StringBuilder(secretKey);
+        for (String key : sortedParams) {
+            if (SIGN.equals(key)) {
+                continue;
+            }
+            String value = params.get(key).toString();
+            if (StringUtils.isNotEmpty(value)) {
+                stringBuilder.append(key).append(value);
+            }
+        }
+        return stringBuilder.toString();
+    }
+
+    private static String generateSign(String content) {
+        return DigestUtils.md5DigestAsHex(content.getBytes());
+    }
+
+
+    /**
+     * 发送理赔同步请求的主入口
+     */
+    public static String executeSyncClaim(LotusClaimParam claimParam) {
+        try {
+
+            JSONObject reqParams = new JSONObject();
+            reqParams.put("appId", APPID);
+            reqParams.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
+            reqParams.put("paramJson", JSONObject.toJSONString(claimParam));
+            String sign = generateSign(reqParams, APPSECRET);
+            reqParams.put("sign", sign);
+            log.info("荷叶健康 理赔同步请求入参:{}", reqParams);
+            String responseStr = HttpUtil.createPost(LOTUSURL + "/openapi/zhonghuacai/channel/claim/sync/info")
+                    .header("Content-Type", "application/json")
+                    .body(reqParams.toJSONString())
+                    .execute()
+                    .body();
+            log.info("荷叶健康 理赔同步请求回参:{}", responseStr);
+            return responseStr;
+
+        } catch (Exception e) {
+            System.err.println("调用荷叶数据回传接口发生异常!");
+            e.printStackTrace();
+            return null;
+        }
+    }
+}

+ 189 - 0
fs-service/src/main/java/com/fs/lotus/utils/SignatureUtil.java

@@ -0,0 +1,189 @@
+package com.fs.lotus.utils;
+
+
+import com.alibaba.fastjson.JSON;
+import com.fs.lotus.config.LotusSignatureProperties;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 开放接口签名工具
+ */
+@Slf4j
+public class SignatureUtil {
+
+    /**
+     * HMAC算法
+     */
+    private static final String ALGORITHM = "HmacSHA256";
+
+    private static volatile LotusSignatureProperties properties;
+
+    private SignatureUtil() {}
+
+    /**
+     * 由 Spring 启动时注入配置
+     */
+    public static void init(LotusSignatureProperties lotusSignatureProperties) {
+        properties = lotusSignatureProperties;
+    }
+
+    private static String appId() {
+        return properties.getAppId();
+    }
+
+    private static String appSecret() {
+        return properties.getAppSecret();
+    }
+
+    private static long expireSeconds() {
+        return properties.getExpireSeconds();
+    }
+
+    /**
+     * 生成签名
+     *
+     * @param appId 应用ID
+     * @param timestamp 秒级时间戳
+     * @param data 业务数据对象
+     * @param secret 密钥
+     */
+    public static String generateSignature(String appId, Long timestamp, String data, String secret) {
+        try {
+
+            String content = buildContent(appId, timestamp, data, secret);
+
+            Mac mac = Mac.getInstance(ALGORITHM);
+
+            SecretKeySpec key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+
+            mac.init(key);
+
+            byte[] result = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
+
+            return bytesToHex(result).toUpperCase();
+
+        } catch (Exception e) {
+            log.error("生成签名失败", e);
+            throw new RuntimeException("生成签名失败", e);
+        }
+    }
+
+    /**
+     * 验签
+     */
+    public static boolean verifySignature(Long timestamp, String data,String clientSign) {
+
+        if (isExpired(timestamp)) {
+
+            log.warn("请求已过期,timestamp={}", timestamp);
+
+            return false;
+        }
+
+        String serverSign = generateSignature(appId(), timestamp, data, appSecret());
+
+        return serverSign.equalsIgnoreCase(clientSign);
+    }
+
+    /**
+     * 构建签名原文
+     */
+    private static String buildContent(String appId, Long timestamp, String data, String secret) {
+
+        String dataJson = data == null ? "{}" : data;
+
+        return new StringBuilder()
+                .append("appId:")
+                .append(appId)
+                .append("|timestamp:")
+                .append(timestamp)
+                .append("|data:")
+                .append(dataJson)
+                .append("|secret:")
+                .append(secret)
+                .toString();
+    }
+
+    /**
+     * 获取当前时间戳(秒)
+     */
+    public static long currentTimestamp() {
+        return System.currentTimeMillis() / 1000;
+    }
+
+    /**
+     * 判断请求是否过期
+     */
+    private static boolean isExpired(Long timestamp) {
+        if (timestamp == null) {
+            return true;
+        }
+        return Math.abs(currentTimestamp() - timestamp) > expireSeconds();
+    }
+
+    /**
+     * byte转16进制
+     */
+    private static String bytesToHex(byte[] bytes) {
+
+        StringBuilder sb = new StringBuilder();
+
+        for (byte b : bytes) {
+
+            String hex =
+                    Integer.toHexString(b & 0xff);
+
+            if (hex.length() == 1) {
+                sb.append('0');
+            }
+            sb.append(hex);
+        }
+
+        return sb.toString();
+    }
+
+    public static void main(String[] args) {
+        // 平台分配(演示用,正式请读配置 lotus.signature)
+        String appId = "default";
+        String appSecret = "default";
+
+        // 当前时间戳(秒)
+        Long timestamp = System.currentTimeMillis() / 1000;
+
+        // 业务参数
+        Map<String, Object> data = new HashMap<>();
+//        data.put("birthday", "1997-12-23");
+//        data.put("entranceType", "inquiry");
+//        data.put("phone", "13579246810");
+//        data.put("idCard", "440106198811205419");
+//        data.put("sex", 0);
+//        data.put("userName", "王五");
+//        data.put("activateBizCode", "LOTUS07254d7e705e429591a84b551cafe036");
+//        data.put("openId", "oS8Z768qv2cLQsYBLmPvrmON01uj");
+//        data.put("userId", 4057364113L);
+
+        String jsonString = JSON.toJSONString(data);
+
+        // 生成签名
+        String sign = generateSignature(appId, timestamp, jsonString, appSecret);
+
+        // 构建请求参数
+        Map<String, Object> request = new HashMap<>();
+        request.put("appId", appId);
+        request.put("timestamp", timestamp);
+        request.put("data", jsonString);
+        request.put("sign", sign);
+
+        // 请求报文
+        String requestJson = JSON.toJSONString(request);
+
+        System.out.println("请求报文:");
+        System.out.println(requestJson);
+    }
+}

+ 3 - 0
fs-service/src/main/resources/application-config-druid-bjczwh.yml

@@ -102,3 +102,6 @@ ipad:
 wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:
+
+univerify:
+  cloud-function-url: https://fc-mp-4ef6f5e8-16b2-40be-b2b0-3cab5dce5407.next.bspapp.com/getLoginMobile

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

@@ -13,6 +13,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="firstLoginAppTime"    column="first_login_app_time"    />
         <result property="firstLoginRewardAddress"    column="first_login_reward_address"    />
         <result property="status"    column="status"    />
+        <result property="firstLoginTime" column="first_login_time" />
         <result property="manageStatus"    column="manage_status"    />
         <result property="manageRemark"    column="manage_remark"    />
         <result property="redStatus"    column="red_status"    />
@@ -585,6 +586,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="nickName != null">nick_name,</if>
             <if test="avatar != null">avatar,</if>
             <if test="phone != null">phone,</if>
+            <if test="firstLoginTime != null">first_login_time,</if>
             <if test="integral != null">integral,</if>
             <if test="signNum != null">sign_num,</if>
             <if test="status != null">status,</if>
@@ -640,6 +642,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="nickName != null">#{nickName},</if>
             <if test="avatar != null">#{avatar},</if>
             <if test="phone != null">#{phone},</if>
+            <if test="firstLoginTime != null">#{firstLoginTime},</if>
             <if test="integral != null">#{integral},</if>
             <if test="signNum != null">#{signNum},</if>
             <if test="status != null">#{status},</if>
@@ -699,6 +702,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="nickName != null">nick_name = #{nickName},</if>
             <if test="avatar != null">avatar = #{avatar},</if>
             <if test="phone != null">phone = #{phone},</if>
+            <if test="firstLoginTime != null">first_login_time = #{firstLoginTime},</if>
             <if test="integral != null">integral = #{integral},</if>
             <if test="firstLoginAppTime != null">first_login_app_time = #{firstLoginAppTime},</if>
             <if test="firstLoginRewardAddress != null">first_login_reward_address = #{firstLoginRewardAddress},</if>

+ 353 - 12
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -3,16 +3,21 @@ package com.fs.app.controller;
 
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.date.DateTime;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.app.annotation.Login;
 import com.fs.app.param.*;
+import com.fs.app.utils.UniVerifyUtil;
 import com.fs.app.utils.WxUtil;
 import com.fs.common.VerifyCodeUtil;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.CustomException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.service.ISmsService;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.ip.IpUtils;
 import com.fs.common.utils.sign.Md5Utils;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.service.ICompanyUserService;
@@ -21,19 +26,15 @@ import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.utils.FsCoursePlaySourceConfigQuery;
 import com.fs.his.domain.FsUser;
-import com.fs.his.domain.FsUserNewTask;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsUserCouponService;
 import com.fs.his.service.IFsUserNewTaskService;
 import com.fs.his.service.IFsUserService;
 import com.fs.his.vo.FsUserRegisterParam;
 import com.fs.im.service.OpenIMService;
-import com.fs.watch.domain.WatchDeviceSetup;
-import com.fs.watch.domain.WatchUser;
-import com.fs.watch.service.WatchUserService;
+import com.fs.lotus.utils.SignatureUtil;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
-import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.ibatis.annotations.Param;
@@ -43,14 +44,11 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
-
-import java.time.LocalDateTime;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
 
-import static com.fs.his.utils.PhoneUtil.encryptPhone;
-import static com.fs.his.utils.PhoneUtil.encryptPhoneOldKey;
+import static com.fs.his.utils.PhoneUtil.*;
 
 @Api("app登录接口")
 @RestController
@@ -85,6 +83,10 @@ public class AppLoginController extends AppBaseController{
     private IFsCoursePlaySourceConfigService fsCoursePlaySourceConfigService;
     @Autowired
     private OpenIMService openIMService;
+
+    @Autowired
+    private UniVerifyUtil uniVerifyUtil;
+
     @Autowired
     private ICompanyUserService companyUserService;
 
@@ -143,8 +145,10 @@ public class AppLoginController extends AppBaseController{
         switch (loginType) {
             case 1:
                 return handleLoginType1(param);
-            case 3:
-                return handleLoginType3(param);
+//            case 3:
+//                return handleLoginType3(param);
+            case 4:
+                return handleLoginType4(param);
             default:
                 return R.error("请选择正确的登陆类型!");
         }
@@ -671,7 +675,7 @@ public class AppLoginController extends AppBaseController{
                         deleteUser = user;
                     }
                     keepUser.setUnionId(unionid);
-                    keepUser.setPhone(param.getPhone());
+                    keepUser.setPhone(decryptPhone(param.getPhone()));
                     keepUser.setSource(param.getSource() != null ? param.getSource() : null );
                     keepUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
                     keepUser.setNickName(nickname);
@@ -1082,4 +1086,341 @@ public class AppLoginController extends AppBaseController{
         return R.ok().put("data",openIMService.getFriendList(userRequestParam.getUserID(),userRequestParam.getPagination().getPageNumber(),userRequestParam.getPagination().getShowNumber(),userRequestParam.getApplyType()));
     }
 
+
+
+    /**
+     * UniApp 一键登录-多账号选择。
+     * 【安全要求】禁止仅凭 userId 发 Token:必须携带有效 accessToken+openId,经 uniCloud 校验换取手机号后,
+     * 再校验 userId 属于该手机号下账号(见 handleSecurePhoneLoginByUserId)。缺少第三方凭证时一律拒绝。
+     */
+    @ApiOperation("UniApp一键登录选择账号(安全)")
+    @PostMapping("/loginByUserIdUniverify")
+    @Transactional
+    public R loginByUserIdUniverify(@RequestBody FsUserLoginParam param) {
+        if (StringUtils.isEmpty(param.getAccessToken()) || StringUtils.isEmpty(param.getOpenId())) {
+            return R.error("一键登录凭证不完整");
+        }
+        String phone;
+        try {
+            phone = uniVerifyUtil.getPhoneNumber(param.getAccessToken(), param.getOpenId());
+        } catch (CustomException e) {
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            logger.error("UniVerify获取手机号失败: {}", e.getMessage());
+            return R.error("获取手机号失败,请重试");
+        }
+        if (StringUtils.isEmpty(phone)) {
+            return R.error("获取手机号失败");
+        }
+        param.setPhone(phone);
+        return handleSecurePhoneLoginByUserId(param);
+    }
+
+    @ApiOperation("UniApp一键登录(安全)")
+    @PostMapping("/loginByUniverify")
+    @Transactional
+    public R loginByUniverify(@RequestBody FsUserLoginParam param) {
+        if (StringUtils.isEmpty(param.getAccessToken()) || StringUtils.isEmpty(param.getOpenId())) {
+            return R.error("一键登录凭证不完整");
+        }
+        String phone;
+        try {
+            phone = uniVerifyUtil.getPhoneNumber(param.getAccessToken(), param.getOpenId());
+        } catch (CustomException e) {
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            logger.error("UniVerify获取手机号失败: {}", e.getMessage());
+            return R.error("获取手机号失败,请重试");
+        }
+        if (StringUtils.isEmpty(phone)) {
+            return R.error("获取手机号失败");
+        }
+        param.setPhone(phone);
+        return handleSecurePhoneLogin(param);
+    }
+
+    @ApiOperation("绑定手机号")
+    @PostMapping("/setPhoneOne")
+    public R setPhoneOne(@Validated @RequestBody FsUserEditPhoneParam param) {
+        if (StringUtils.isEmpty(param.getAccessToken()) || StringUtils.isEmpty(param.getOpenId())) {
+            return R.error("一键登录凭证不完整");
+        }
+        String phone;
+        try {
+            phone = uniVerifyUtil.getPhoneNumber(param.getAccessToken(), param.getOpenId());
+        } catch (CustomException e) {
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            logger.error("UniVerify获取手机号失败: {}", e.getMessage());
+            return R.error("获取手机号失败,请重试");
+        }
+        if (StringUtils.isEmpty(phone)) {
+            return R.error("获取手机号失败");
+        }
+
+        param.setPhone(phone);
+
+        FsUser user = userService.selectFsUserByUnionid(param.getUnionId());
+        if (user == null) {
+            return R.error("用户数据不存在");
+        }
+        //验证码绑定,需要校验验证码
+        if (param.getBindType()!=null){
+            String encryptPhone = encryptPhone(param.getPhone());
+            String redisCode = redisCache.getCacheObject("sms:code:" + encryptPhone);
+            if (StringUtils.isEmpty(redisCode)){
+                return R.error("验证码已过期,请重新发送");
+            }
+            if (!redisCode.equals(param.getCode())) {
+                return R.error("验证码错误");
+            }
+            redisCache.deleteObject("sms:code:" + encryptPhone);
+        }
+        FsUser userMap = findUserByPhone(param.getPhone());
+        //绑定的手机号已存在用户的情况,将微信登录的时候创建的新号的UnionId移动到老号中,删除新号(将两个号合并)
+        if (userMap!=null){
+            if (userMap.getUserId().equals(user.getUserId())) {
+                user.setPhone(param.getPhone());
+                user.setLoginDevice(param.getLoginDevice());
+                user.setHistoryApp("app");
+                if (user.getFirstLoginTime() == null) {
+                    user.setFirstLoginTime(new Date());
+                }
+                user.setSource(param.getSource());
+                userService.updateFsUser(user);
+                return generateTokenAndReturn(user);
+            }
+            if (StringUtils.isNotEmpty(userMap.getUnionId())&&!userMap.getUnionId().equals(user.getUnionId())){
+                return R.error("该手机号已绑定其他微信");
+            }
+            //如果存在手机号也有用户,微信也有用户,保留创建时间比较久的用户
+            FsUser keepUser;
+            FsUser deleteUser;
+            if (userMap.getCreateTime().before(user.getCreateTime())){
+                keepUser = userMap;
+                deleteUser = user;
+            }else {
+                keepUser = user;
+                deleteUser = userMap;
+            }
+            keepUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
+            keepUser.setSource(param.getSource());
+            keepUser.setHistoryApp("app");
+            if (keepUser.getFirstLoginTime() == null) {
+                keepUser.setFirstLoginTime(new Date());
+            }
+            keepUser.setUnionId(user.getUnionId());
+            keepUser.setPhone(param.getPhone());
+            keepUser.setIntegral(keepUser.getIntegral()+deleteUser.getIntegral());
+            if (userService.updateFsUser(keepUser)>0){
+                userService.deleteFsUserByUserId(deleteUser.getUserId());
+                return generateTokenAndReturn(keepUser);
+            }
+            return R.error("绑定手机号失败");
+        }
+        user.setPhone(param.getPhone());
+        user.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null );
+        user.setHistoryApp("app");
+        if (user.getFirstLoginTime() == null) {
+            user.setFirstLoginTime(new Date());
+        }
+        user.setSource(param.getSource()!= null ? param.getSource() : null);
+        userMap = new FsUser();
+        userMap.setLoginDevice(param.getLoginDevice());
+        userMap.setHistoryApp("app");
+        if (userMap.getFirstLoginTime() == null) {
+            userMap.setFirstLoginTime(new Date());
+        }
+        userMap.setSource(param.getSource());
+        if (StringUtils.isEmpty(user.getNickName()) || user.getNickName().equals("匿名用户**")) {
+            userMap.setNickName("匿名用户" + param.getPhone().substring(param.getPhone().length() - 4));
+        }
+        userMap.setUserId(user.getUserId());
+        userMap.setPhone(param.getPhone());
+        if (userService.updateFsUser(userMap)>0){
+            return generateTokenAndReturn(user);
+        }
+        return R.error("绑定手机号失败");
+    }
+
+    @ApiOperation("绑定手机号")
+    @PostMapping("/setIPhoneNumberOne")
+    public R setIPhoneNumberOne(@Validated @RequestBody FsUserEditPhoneParam param) {
+        if (StringUtils.isEmpty(param.getAccessToken()) || StringUtils.isEmpty(param.getOpenId())) {
+            return R.error("一键登录凭证不完整");
+        }
+        String phone;
+        try {
+            phone = uniVerifyUtil.getPhoneNumber(param.getAccessToken(), param.getOpenId());
+        } catch (CustomException e) {
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            logger.error("UniVerify获取手机号失败: {}", e.getMessage());
+            return R.error("获取手机号失败,请重试");
+        }
+        if (StringUtils.isEmpty(phone)) {
+            return R.error("获取手机号失败");
+        }
+        param.setPhone(phone);
+
+        FsUser userMap = findUserByPhone(param.getPhone());
+        if (userMap != null) {
+            if (StringUtils.isNotEmpty(userMap.getAppleKey()) && StringUtils.isNotEmpty(param.getAppleKey()) && !param.getAppleKey().equals(userMap.getAppleKey())) {
+                return R.error("该手机号已绑定其他账号");
+            }
+            if (param.getSimExist() == 0 && !Md5Utils.hash(param.getPassword()).equals(userMap.getPassword())) {
+                return R.error("密码不正确");
+            }
+        } else {
+            try {
+                userMap = createNewAppleUser(param, param.getPhone());
+            } catch (CustomException e) {
+                return R.error(e.getMessage());
+            }
+        }
+
+        userMap.setLoginDevice(param.getLoginDevice());
+        userMap.setHistoryApp("app");
+        if (userMap.getFirstLoginTime() == null) {
+            userMap.setFirstLoginTime(new Date());
+        }
+        userMap.setSource(param.getSource());
+        if (StringUtils.isEmpty(userMap.getNickName()) || userMap.getNickName().equals("匿名用户**")) {
+            userMap.setNickName("苹果用户" + param.getPhone().substring(param.getPhone().length() - 4));
+        }
+        userMap.setAppleKey(param.getAppleKey());
+        if (userService.updateFsUser(userMap)>0){
+            return generateTokenAndReturn(userMap);
+        }
+        return R.error("绑定手机号失败");
+    }
+
+
+    /**
+     * 多账号场景下按 userId 登录,手机号已由 uniCloud 校验
+     */
+    private R handleSecurePhoneLoginByUserId(FsUserLoginParam param) {
+        List<FsUser> usersByPhone = findUsersByPhone(param.getPhone());
+        if (CollectionUtil.isEmpty(usersByPhone)) {
+            return R.error("账号不存在,请先注册账号");
+        }
+        if (usersByPhone.size() == 1) {
+            FsUser user = usersByPhone.get(0);
+            if (StringUtils.isNotEmpty(param.getJpushId())) {
+                updateExistingUserJpushId(user, param.getJpushId());
+            }
+            updateLoginDevice(user.getUserId(), param.getLoginDevice(), param.getSource());
+            return generateTokenAndReturn(user);
+        }
+        if (param.getUserId() == null) {
+            return R.error("请选择登录账号");
+        }
+        for (FsUser fsUser : usersByPhone) {
+            if (fsUser.getUserId().equals(param.getUserId())) {
+                if (StringUtils.isNotEmpty(param.getJpushId())) {
+                    updateExistingUserJpushId(fsUser, param.getJpushId());
+                }
+                updateLoginDevice(fsUser.getUserId(), param.getLoginDevice(), param.getSource());
+                return generateTokenAndReturn(fsUser);
+            }
+        }
+        return R.error("账号不匹配,请检查账号");
+    }
+
+    /**
+     * 更新用户登录设备信息
+     * @param userId 用户ID
+     * @param loginDevice 登录设备
+     * @param source 来源
+     */
+    private void updateLoginDevice(Long userId, String loginDevice, String source) {
+        FsUser updateUser = new FsUser();
+        updateUser.setUserId(userId);
+        if (StringUtils.isNotBlank(loginDevice)) {
+            updateUser.setLoginDevice(loginDevice);
+        }
+        if (StringUtils.isNotBlank(source)) {
+            updateUser.setSource(source);
+        }
+        updateUser.setLastIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        userService.updateFsUser(updateUser);
+    }
+
+    /**
+     * UniApp 一键登录(安全):手机号已由 uniCloud 校验,不信任客户端传入
+     * <ul>
+     *   <li>无用户 → 自动注册新用户</li>
+     *   <li>单用户 → 直接登录</li>
+     *   <li>多用户 → 返回 users 列表,前端选择后调 /loginByUserIdUniverify</li>
+     * </ul>
+     */
+    private R handleSecurePhoneLogin(FsUserLoginParam param) {
+        String phone = param.getPhone();
+        List<FsUser> usersByPhone = findUsersByPhone(phone);
+        if (CollectionUtil.isEmpty(usersByPhone)) {
+            // 新用户自动注册
+            String appid = openProperties.getAppId();
+            param.setAppId(appid);
+            FsUser newUser = createNewUser(param);
+            if (StringUtils.isNotEmpty(param.getJpushId())) {
+                updateExistingUserJpushId(newUser, param.getJpushId());
+            }
+            updateLoginDevice(newUser.getUserId(), param.getLoginDevice(), param.getSource());
+            R r = generateTokenAndReturn(newUser);
+            r.put("isNew", true);
+            return r;
+        }
+        if (usersByPhone.size() == 1) {
+            FsUser user = usersByPhone.get(0);
+            // 历史用户首次一键登录:手机号还是明文,加密后落库
+            if (user.getUnionId() == null) {
+                if (user.getPhone() != null && user.getPhone().length() <= 11) {
+                    FsUser fsUser = new FsUser();
+                    fsUser.setUserId(user.getUserId());
+                    fsUser.setPhone(encryptPhone(phone));
+                    userMapper.updateFsUser(fsUser);
+                }
+                return R.ok().put("isNew", true).put("phone", encryptPhone(phone));
+            }
+            if (StringUtils.isNotEmpty(param.getJpushId())) {
+                updateExistingUserJpushId(user, param.getJpushId());
+            }
+            updateLoginDevice(user.getUserId(), param.getLoginDevice(), param.getSource());
+            return generateTokenAndReturn(user);
+        }
+        // 多账号场景,脱敏后返回让前端选择
+        for (FsUser fsUser : usersByPhone) {
+            fsUser.setPhone("***********");
+        }
+        return R.ok().put("users", usersByPhone);
+    }
+
+    private R handleLoginType4(FsUserLoginParam param) {
+        if (param.getUserId() == null || StringUtils.isEmpty(param.getSign()) || param.getTimestamp() == null) {
+            return R.error("登录参数不完整");
+        }
+        // 验签
+        JSONObject reqJson = new JSONObject();
+        reqJson.put("userId", param.getUserId());
+        if (!SignatureUtil.verifySignature(param.getTimestamp(), reqJson.toString(), param.getSign())) {
+            return R.error("签名验证失败");
+        }
+        // 签名一次性:同一 sign 5 分钟内只能换票一次,防止截包重放
+        String usedKey = "lotus:login:sign:" + param.getSign();
+        if (!redisCache.setIfAbsent(usedKey, "1", 300, TimeUnit.SECONDS)) {
+            return R.error("登录凭证已失效,请重新授权");
+        }
+        Long userId = param.getUserId();
+        FsUser fsUser = userMapper.selectFsUserByUserId(userId);
+        if (fsUser == null) {
+            return R.error("用户不存在");
+        }
+        String token = jwtUtils.generateToken(userId);
+        redisCache.setCacheObject("userToken:" + userId, token, 604800, TimeUnit.SECONDS);
+        JSONObject res = new JSONObject();
+        res.put("token", token);
+        res.put("user", fsUser);
+        return R.ok().put("data", res);
+    }
+
 }

+ 6 - 0
fs-user-app/src/main/java/com/fs/app/param/FsUserEditPhoneParam.java

@@ -26,8 +26,14 @@ public class FsUserEditPhoneParam implements Serializable {
 
     private String password;
 
+    //验证码
+    private String code;
+
+    private String openId;
     /**
      * 是否有SIM卡 0不存在 1存在
      */
     private Integer simExist;
+    private Integer bindType;
+    private String accessToken;
 }

+ 11 - 0
fs-user-app/src/main/java/com/fs/app/param/FsUserLoginParam.java

@@ -23,4 +23,15 @@ public class FsUserLoginParam implements Serializable {
 
     private String loginDevice;
     private String source;
+
+    /**-------------------------------------------------------------------------------**/
+    //登录id
+    private String appId;
+    private Long userId;
+    /** UniApp 一键登录(univerify)access_token,由客户端从 uni.login 获取 */
+    private String accessToken;
+    /** 荷叶无感登录签名用:时间戳(秒) */
+    private Long timestamp;
+    /** 荷叶无感登录签名用:签名 */
+    private String sign;
 }

+ 97 - 0
fs-user-app/src/main/java/com/fs/app/utils/UniVerifyUtil.java

@@ -0,0 +1,97 @@
+package com.fs.app.utils;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.http.HttpUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * UniApp 一键登录(univerify)工具类,通过 uniCloud 云函数 URL 化接口换取手机号。
+ * <p>
+ * 客户端调用 uni.login 拿到 access_token + openid 后,传给后端,
+ * 后端调用云函数换取真实手机号,避免信任客户端传入的手机号。
+ * </p>
+ *
+ * <h3>yml 配置</h3>
+ * <pre>{@code
+ * univerify:
+ *   cloud-function-url: https://fc-mp-xxx.next.bspapp.com/getLoginMobile
+ * }</pre>
+ *
+ * @author fs
+ */
+@Component
+public class UniVerifyUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(UniVerifyUtil.class);
+
+    @Value("${univerify.cloud-function-url}")
+    private String cloudFunctionUrl;
+
+    /**
+     * 使用 access_token 和 openid 向 uniCloud 换取真实手机号
+     *
+     * @param accessToken 客户端从 uni.login 获取的 access_token
+     * @param openId      客户端从 uni.login 获取的 openid
+     * @return 真实手机号
+     * @throws CustomException 凭证不完整、服务未配置、云函数返回失败时抛出
+     */
+    public String getPhoneNumber(String accessToken, String openId) {
+        if (StringUtils.isBlank(cloudFunctionUrl)) {
+            throw new CustomException("一键登录服务未配置,请联系管理员");
+        }
+        if (StringUtils.isAnyBlank(accessToken, openId)) {
+            throw new CustomException("一键登录凭证不完整");
+        }
+
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("access_token", accessToken);
+        requestBody.put("openid", openId);
+
+        String response = HttpUtils.doPost(cloudFunctionUrl, requestBody.toJSONString());
+        log.info("UniVerify 换号响应: {}", response);
+        if (StringUtils.isBlank(response)) {
+            throw new CustomException("获取手机号失败");
+        }
+
+        JSONObject json = JSON.parseObject(response);
+        if (Boolean.FALSE.equals(json.getBoolean("success"))) {
+            JSONObject error = json.getJSONObject("error");
+            if (error != null) {
+                throw new CustomException(StringUtils.defaultIfBlank(error.getString("message"), "获取手机号失败"));
+            }
+            throw new CustomException("获取手机号失败");
+        }
+
+        Integer code = json.getInteger("code");
+        if (code != null && code != 0) {
+            String message = json.getString("message");
+            if (StringUtils.isBlank(message)) {
+                message = json.getString("errMsg");
+            }
+            throw new CustomException(StringUtils.defaultIfBlank(message, "获取手机号失败"));
+        }
+
+        String phoneNumber = json.getString("phoneNumber");
+        if (StringUtils.isBlank(phoneNumber) && json.containsKey("data")) {
+            Object dataObj = json.get("data");
+            if (dataObj instanceof JSONObject) {
+                phoneNumber = ((JSONObject) dataObj).getString("phoneNumber");
+            } else if (dataObj instanceof String) {
+                JSONObject data = JSON.parseObject((String) dataObj);
+                if (data != null) {
+                    phoneNumber = data.getString("phoneNumber");
+                }
+            }
+        }
+        if (StringUtils.isBlank(phoneNumber)) {
+            throw new CustomException("获取手机号失败");
+        }
+        return phoneNumber;
+    }
+}