Jelajahi Sumber

积分支付积分回调,药师线上验签

yjwang 18 jam lalu
induk
melakukan
59a7efb307
44 mengubah file dengan 2032 tambahan dan 96 penghapusan
  1. 22 0
      fs-admin/src/main/java/com/fs/his/controller/FsIntegralOrderController.java
  2. 21 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsShippingTemplatesScrmController.java
  3. 2 1
      fs-admin/src/main/resources/application.yml
  4. 2 1
      fs-common-api/src/main/resources/application.yml
  5. 73 7
      fs-doctor-app/src/main/java/com/fs/app/controller/DoctorController.java
  6. 102 0
      fs-doctor-app/src/main/java/com/fs/app/controller/DoctorSignController.java
  7. 149 0
      fs-doctor-app/src/main/java/com/fs/app/utils/DoctorSignTokenUtils.java
  8. 319 0
      fs-doctor-app/src/main/resources/static/doctor-sign.html
  9. 2 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  10. 42 10
      fs-service/src/main/java/com/fs/his/domain/FsCoupon.java
  11. 7 0
      fs-service/src/main/java/com/fs/his/domain/FsIntegralGoods.java
  12. 4 0
      fs-service/src/main/java/com/fs/his/domain/FsIntegralOrder.java
  13. 74 26
      fs-service/src/main/java/com/fs/his/domain/FsUserAddress.java
  14. 44 0
      fs-service/src/main/java/com/fs/his/dto/ComputeIntegralOrderMoneyDTO.java
  15. 6 0
      fs-service/src/main/java/com/fs/his/mapper/FsCityMapper.java
  16. 24 0
      fs-service/src/main/java/com/fs/his/mapper/FsIntegralOrderMapper.java
  17. 21 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  18. 19 0
      fs-service/src/main/java/com/fs/his/param/FsIntegralOrderComputeParam.java
  19. 1 0
      fs-service/src/main/java/com/fs/his/param/FsIntegralOrderDoPayParam.java
  20. 5 0
      fs-service/src/main/java/com/fs/his/param/PayOrderParam.java
  21. 40 0
      fs-service/src/main/java/com/fs/his/service/IFsIntegralOrderService.java
  22. 20 0
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  23. 633 12
      fs-service/src/main/java/com/fs/his/service/impl/FsIntegralOrderServiceImpl.java
  24. 38 21
      fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java
  25. 37 0
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  26. 5 0
      fs-service/src/main/java/com/fs/his/vo/FsIntegralOrderListVO.java
  27. 6 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesFreeScrmMapper.java
  28. 5 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesRegionScrmMapper.java
  29. 6 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesScrmMapper.java
  30. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsShippingTemplatesScrmService.java
  31. 13 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsShippingTemplatesScrmServiceImpl.java
  32. 4 1
      fs-service/src/main/java/com/fs/huifuPay/sdk/opps/core/utils/HuiFuUtils.java
  33. 34 6
      fs-service/src/main/java/com/fs/ybPay/service/impl/YopPayServiceImpl.java
  34. 2 2
      fs-service/src/main/resources/application-config-druid-yjb.yml
  35. 2 2
      fs-service/src/main/resources/application-dev-yjb.yml
  36. 170 0
      fs-service/src/main/resources/application-druid-yjb-test.yml
  37. 5 1
      fs-service/src/main/resources/mapper/his/FsIntegralGoodsMapper.xml
  38. 11 1
      fs-service/src/main/resources/mapper/his/FsIntegralOrderMapper.xml
  39. 2 1
      fs-service/src/main/resources/mapper/his/FsUserAddressMapper.xml
  40. 9 0
      fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesRegionScrmMapper.xml
  41. 8 1
      fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesScrmMapper.xml
  42. 11 0
      fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java
  43. 25 1
      fs-user-app/src/main/java/com/fs/app/controller/store/PayScrmController.java
  44. 2 1
      fs-user-app/src/main/resources/application.yml

+ 22 - 0
fs-admin/src/main/java/com/fs/his/controller/FsIntegralOrderController.java

@@ -178,4 +178,26 @@ public class FsIntegralOrderController extends BaseController
         String orderCode = requestBody.get("orderCode");
         return toAjax(fsIntegralOrderService.cancelOrder(orderCode));
     }
+
+    /**
+     * 管理员强制退款(不校验是否发货)
+     * <p>流程:取消订单 → 还原库存 → 易宝原路退款 → 退回积分(logType=27)。</p>
+     * <p>幂等:通过 logType=27 存在性判断,重复退款不会重复退积分。</p>
+     */
+    @PreAuthorize("@ss.hasPermi('his:integralOrder:cancel')")
+    @Log(title = "积分商品订单", businessType = BusinessType.UPDATE)
+    @PostMapping("/mandatoryRefunds")
+    public AjaxResult mandatoryRefunds(@RequestBody Map<String, String> requestBody){
+        String orderCode = requestBody.get("orderCode");
+        return toAjax(fsIntegralOrderService.mandatoryRefunds(orderCode));
+    }
+
+    /**
+     * 确认收货(status: 2 待收货 → 3 已完成)
+     */
+    @Log(title = "积分商品订单", businessType = BusinessType.UPDATE)
+    @GetMapping("/finishOrder/{orderCode}")
+    public AjaxResult finishOrder(@PathVariable("orderCode") String orderCode) {
+        return toAjax(fsIntegralOrderService.finishOrder(orderCode));
+    }
 }

+ 21 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsShippingTemplatesScrmController.java

@@ -42,6 +42,18 @@ public class FsShippingTemplatesScrmController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 组件-查询运费模板列表(无权限限制,供前端组件选择使用)
+     */
+    @GetMapping("/componentList")
+    public TableDataInfo componentList(FsShippingTemplatesScrm fsShippingTemplates)
+    {
+        startPage();
+        fsShippingTemplates.setIsDel(0);
+        List<FsShippingTemplatesScrm> list = fsShippingTemplatesService.selectFsShippingTemplatesList(fsShippingTemplates);
+        return getDataTable(list);
+    }
+
     /**
      * 导出运费模板列表
      */
@@ -103,4 +115,13 @@ public class FsShippingTemplatesScrmController extends BaseController
         List<FsShippingTemplatesScrm> list = fsShippingTemplatesService.selectFsShippingTemplatesList(fsShippingTemplates);
         return R.ok().put("data",list);
     }
+
+    /**
+     * 根据id集合查询模板列表
+     */
+    @GetMapping(value = "/getByIds")
+    public AjaxResult getByIds(@RequestParam List<Long> ids)
+    {
+        return AjaxResult.success(fsShippingTemplatesService.selectByIds(ids));
+    }
 }

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

@@ -4,7 +4,8 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev-yjb
+#    active: dev-yjb
+    active: druid-yjb-test #医健宝测试库
 #    active: druid-hdt
 #    active: druid-yzt
 #    active: druid-sxjz

+ 2 - 1
fs-common-api/src/main/resources/application.yml

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

+ 73 - 7
fs-doctor-app/src/main/java/com/fs/app/controller/DoctorController.java

@@ -7,6 +7,7 @@ import com.fs.app.annotation.Login;
 import com.fs.app.param.DoctorEditParam;
 import com.fs.app.param.DoctorEditPwdParam;
 import com.fs.app.param.DoctorLoginParam;
+import com.fs.app.utils.DoctorSignTokenUtils;
 import com.fs.common.constant.Constants;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
@@ -20,6 +21,7 @@ import com.fs.his.param.FsDoctorExtractEditParam;
 import com.fs.his.param.FsDoctorExtractListSParam;
 import com.fs.his.service.*;
 import com.fs.his.utils.CityTreeUtil;
+import com.fs.his.utils.qrcode.QRCodeUtils;
 import com.fs.his.vo.CityVO;
 import com.fs.his.vo.FsDoctorBillListSVO;
 import com.fs.his.vo.FsDoctorExtractListSVO;
@@ -76,6 +78,8 @@ public class DoctorController extends  AppBaseController {
 
     @Autowired
     private IFsStoreScrmService fsStoreScrmService;
+    @Autowired
+    private DoctorSignTokenUtils doctorSignTokenUtils;
 
     @ApiOperation("登录")
     @PostMapping("/login")
@@ -255,17 +259,23 @@ public class DoctorController extends  AppBaseController {
     public R getDoctorInfo(HttpServletRequest request){
         try {
             FsDoctor doctor=doctorService.selectFsDoctorByDoctorId(Long.parseLong(getDoctorId()));
-            FsStoreScrm storeScrm = fsStoreScrmService.selectFsStoreByStoreId(doctor.getStoreId());
-//            FsHospital hospital=hospitalService.selectFsHospitalByHospitalId(doctor.getHospitalId());
-            FsDepartment department=departmentService.selectFsDepartmentByDeptId(doctor.getDeptId());
+            if (doctor == null) {
+                return R.error("药师信息不存在");
+            }
+            FsStoreScrm storeScrm = doctor.getStoreId() == null ? null
+                    : fsStoreScrmService.selectFsStoreByStoreId(doctor.getStoreId());
+            FsDepartment department = doctor.getDeptId() == null ? null
+                    : departmentService.selectFsDepartmentByDeptId(doctor.getDeptId());
 
             Map<String,Object> map=new HashMap<>();
             map.put("doctor",doctor);
-            map.put("storeName",storeScrm.getStoreName());
+            // 门店可能为空(如纯后台管理员账号),需做空判断避免 NPE
+            map.put("storeName", storeScrm == null ? "" : storeScrm.getStoreName());
             map.put("department",department);
             return R.ok(map);
         } catch (Exception e){
-            return R.error("操作异常");
+            log.error("获取药师信息异常, doctorId={}", getDoctorId(), e);
+            return R.error("操作异常:" + e.getMessage());
         }
     }
 
@@ -291,6 +301,60 @@ public class DoctorController extends  AppBaseController {
         return R.ok("修改成功");
     }
 
+    /**
+     * 生成药师签名上传二维码
+     * <p>
+     * 流程:
+     * 1. 从 sys_config 读取药师签名上传H5页面地址(configKey=his.doctorConfig)
+     * 2. 基于当前登录药师的 doctorId 和 account 生成 5 分钟有效期的定向签名 token
+     * 3. 将 token 拼接到 H5 页面 URL 的查询参数中
+     * 4. 生成二维码图片,以 base64 编码返回(避免依赖 OSS 上传)
+     * 5. 药师PC端展示二维码,用户用手机扫码后跳转至H5页面进行签名上传
+     * </p>
+     */
+    @Login
+    @ApiOperation("生成药师签名上传二维码")
+    @GetMapping("/getSignQrCode")
+    public R getSignQrCode(HttpServletRequest request) {
+        try {
+            String doctorIdStr = getDoctorId();
+            Long doctorId = Long.parseLong(doctorIdStr);
+            // 查询药师信息,获取账号
+            FsDoctor doctor = doctorService.selectFsDoctorByDoctorId(doctorId);
+            if (doctor == null) {
+                return R.error("药师信息不存在");
+            }
+            // 读取药师端配置
+            String configValue = configService.selectConfigByKey("his.doctorConfig");
+            if (StringUtils.isBlank(configValue)) {
+                return R.error("请联系管理员在总后台配置药师签名上传页面地址");
+            }
+            com.alibaba.fastjson.JSONObject configObj = com.alibaba.fastjson.JSON.parseObject(configValue);
+            String signPageUrl = configObj == null ? null : configObj.getString("signPageUrl");
+            if (StringUtils.isBlank(signPageUrl)) {
+                return R.error("药师签名上传页面地址未配置");
+            }
+            // 生成定向签名 token(5分钟有效期,仅用于签名上传接口)
+            String signToken = doctorSignTokenUtils.generateToken(doctorId, doctor.getAccount());
+            // 拼接 token 到 H5 页面 URL
+            String qrCodeContent;
+            String separator = signPageUrl.contains("?") ? "&" : "?";
+            qrCodeContent = signPageUrl + separator + "token=" + signToken;
+            // 生成二维码图片,返回 base64 编码(data URI 形式,前端可直接作为 img src)
+            java.io.ByteArrayOutputStream os = QRCodeUtils.create(qrCodeContent);
+            String base64 = java.util.Base64.getEncoder().encodeToString(os.toByteArray());
+            String qrCodeBase64 = "data:image/png;base64," + base64;
+            Map<String, Object> map = new HashMap<>();
+            map.put("qrCodeUrl", qrCodeBase64);
+            map.put("content", qrCodeContent);
+            map.put("expireSeconds", 300);
+            return R.ok(map);
+        } catch (Exception e) {
+            log.error("生成药师签名上传二维码异常, doctorId={}", getDoctorId(), e);
+            return R.error("生成二维码失败:" + e.getMessage());
+        }
+    }
+
     @Login
     @ApiOperation("修改密码")
     @PostMapping("/editPwd")
@@ -314,7 +378,8 @@ public class DoctorController extends  AppBaseController {
             String url=doctorService.getDoctorInquiryImg(Long.parseLong(getDoctorId()));
             return R.ok().put("url",url);
         } catch (Exception e){
-            return R.error("操作异常");
+            log.error("获取药师推广海报异常, doctorId={}", getDoctorId(), e);
+            return R.error("操作异常:" + e.getMessage());
         }
     }
 
@@ -327,7 +392,8 @@ public class DoctorController extends  AppBaseController {
             FsDoctor doctor=doctorService.selectFsDoctorByDoctorId(Long.parseLong(getDoctorId()));
             return R.ok().put("data",doctor);
         } catch (Exception e){
-            return R.error("操作异常");
+            log.error("获取药师详情异常, doctorId={}", getDoctorId(), e);
+            return R.error("操作异常:" + e.getMessage());
         }
     }
 

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

@@ -0,0 +1,102 @@
+package com.fs.app.controller;
+
+import com.fs.app.utils.DoctorSignTokenUtils;
+import com.fs.common.core.domain.R;
+import com.fs.his.domain.FsDoctor;
+import com.fs.his.service.IFsDoctorService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 药师签名上传H5端接口
+ * <p>
+ * 供药师扫码后访问的H5页面调用,所有接口均通过定向签名 token(5分钟有效期)进行身份验证,
+ * 不依赖常规登录态。token 由 PC 端 getSignQrCode 接口生成并写入二维码跳转URL。
+ * </p>
+ */
+@Slf4j
+@Api("药师签名上传H5端接口")
+@RestController
+@RequestMapping(value = "/app/doctor/sign")
+public class DoctorSignController {
+
+    @Autowired
+    private IFsDoctorService doctorService;
+
+    @Autowired
+    private DoctorSignTokenUtils doctorSignTokenUtils;
+
+    /**
+     * H5 页面查询当前药师信息(用于展示药师姓名)
+     *
+     * @param token 定向签名 token
+     */
+    @ApiOperation("查询药师信息(签名token验证)")
+    @GetMapping("/getInfo")
+    public R getInfo(@RequestParam("token") String token) {
+        Long doctorId = doctorSignTokenUtils.validateToken(token);
+        if (doctorId == null) {
+            return R.error(401, "token无效或已过期,请重新扫码");
+        }
+        FsDoctor doctor = doctorService.selectFsDoctorByDoctorId(doctorId);
+        if (doctor == null) {
+            return R.error("药师信息不存在");
+        }
+        // 已有签名时返回签名URL,便于前端提示
+        Map<String, Object> map = new HashMap<>(3);
+        map.put("doctorId", doctor.getDoctorId());
+        map.put("doctorName", doctor.getDoctorName());
+        map.put("hasSign", StringUtils.isNotBlank(doctor.getSignUrl()));
+        map.put("signUrl", doctor.getSignUrl());
+        return R.ok(map);
+    }
+
+    /**
+     * H5 页面保存签名图片URL
+     * <p>
+     * 流程:H5端 canvas 生成签名图片 → 调用 /app/common/uploadOSS 上传得到 url
+     * → 调用本接口将 url 保存到 fs_doctor.sign_url 字段
+     * </p>
+     *
+     * @param token   定向签名 token
+     * @param signUrl 签名图片OSS地址
+     */
+    @ApiOperation("保存药师签名(签名token验证)")
+    @PostMapping("/saveSign")
+    public R saveSign(@RequestParam("token") String token,
+                      @RequestParam("signUrl") String signUrl) {
+        // 1. 校验 token
+        Long doctorId = doctorSignTokenUtils.validateToken(token);
+        if (doctorId == null) {
+            return R.error(401, "token无效或已过期,请重新扫码");
+        }
+        // 2. 校验签名图片地址
+        if (StringUtils.isBlank(signUrl)) {
+            return R.error("签名图片不能为空");
+        }
+        // 3. 最小更新对象,仅更新 sign_url 字段,避免覆盖其他字段
+        FsDoctor doctor = new FsDoctor();
+        doctor.setDoctorId(doctorId);
+        doctor.setSignUrl(signUrl);
+        int rows = doctorService.updateFsDoctor(doctor);
+        if (rows <= 0) {
+            log.error("保存药师签名失败, doctorId={}, signUrl={}", doctorId, signUrl);
+            return R.error("保存失败");
+        }
+        // 4. 签名保存成功后立即作废 token,防止重复使用
+        doctorSignTokenUtils.invalidate(token);
+        log.info("保存药师签名成功, doctorId={}", doctorId);
+        return R.ok("保存成功");
+    }
+}

+ 149 - 0
fs-doctor-app/src/main/java/com/fs/app/utils/DoctorSignTokenUtils.java

@@ -0,0 +1,149 @@
+package com.fs.app.utils;
+
+import com.fs.common.core.redis.RedisCache;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 药师签名上传专用临时 Token 工具类
+ * <p>
+ * 用于药师端 PC 扫码后,H5 页面调用签名相关接口时的身份验证。Token 具有以下特性:
+ * 1. 5 分钟有效期,过期需重新扫码
+ * 2. 仅可用于签名上传相关接口(scope=doctor_sign)
+ * 3. 绑定当前登录药师的 doctorId 和 account
+ * 4. 双重校验:JWT 签名 + Redis 存在性,支持主动失效
+ * </p>
+ */
+@Component
+public class DoctorSignTokenUtils {
+
+    /** Token 有效期:5 分钟(秒) */
+    private static final int EXPIRE_SECONDS = 300;
+
+    /** Token 用途标识 */
+    private static final String SCOPE = "doctor_sign";
+
+    /** Redis Key 前缀 */
+    private static final String REDIS_KEY_PREFIX = "doctor_sign_token:";
+
+    /** JWT 自定义声明:用途标识 */
+    private static final String CLAIM_SCOPE = "scope";
+
+    /** JWT 自定义声明:药师账号 */
+    private static final String CLAIM_ACCOUNT = "account";
+
+    /** JWT 自定义声明:tokenId(与 Redis Key 对应) */
+    private static final String CLAIM_TOKEN_ID = "tokenId";
+
+    @Value("${fs.jwt.secret}")
+    private String secret;
+
+    @Resource
+    private RedisCache redisCache;
+
+    /**
+     * 生成药师签名上传临时 Token
+     *
+     * @param doctorId 药师ID
+     * @param account  药师账号
+     * @return JWT token
+     */
+    public String generateToken(Long doctorId, String account) {
+        Date nowDate = new Date();
+        Date expireDate = new Date(nowDate.getTime() + EXPIRE_SECONDS * 1000);
+        String tokenId = UUID.randomUUID().toString().replace("-", "");
+
+        Map<String, Object> claims = new HashMap<>(3);
+        claims.put(CLAIM_SCOPE, SCOPE);
+        claims.put(CLAIM_ACCOUNT, account);
+        claims.put(CLAIM_TOKEN_ID, tokenId);
+
+        String token = Jwts.builder()
+                .setHeaderParam("typ", "JWT")
+                .setSubject(doctorId.toString())
+                .addClaims(claims)
+                .setIssuedAt(nowDate)
+                .setExpiration(expireDate)
+                .signWith(SignatureAlgorithm.HS512, secret)
+                .compact();
+
+        // Redis 存储一份映射,便于主动失效和双重校验
+        redisCache.setCacheObject(REDIS_KEY_PREFIX + tokenId, doctorId.toString(),
+                EXPIRE_SECONDS, TimeUnit.SECONDS);
+
+        return token;
+    }
+
+    /**
+     * 校验 Token 并返回药师ID
+     *
+     * @param token JWT token
+     * @return 药师ID,校验失败返回 null
+     */
+    public Long validateToken(String token) {
+        if (token == null || token.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            Claims claims = Jwts.parser()
+                    .setSigningKey(secret)
+                    .parseClaimsJws(token)
+                    .getBody();
+
+            // 校验用途标识
+            Object scope = claims.get(CLAIM_SCOPE);
+            if (!SCOPE.equals(scope)) {
+                return null;
+            }
+
+            // 校验 subject(doctorId)
+            String subject = claims.getSubject();
+            if (subject == null || subject.isEmpty()) {
+                return null;
+            }
+
+            // 校验 Redis 中是否存在(支持主动失效)
+            Object tokenIdObj = claims.get(CLAIM_TOKEN_ID);
+            if (tokenIdObj == null) {
+                return null;
+            }
+            String redisValue = redisCache.getCacheObject(REDIS_KEY_PREFIX + tokenIdObj);
+            if (redisValue == null || !subject.equals(redisValue)) {
+                return null;
+            }
+
+            return Long.parseLong(subject);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 主动失效 Token(例如签名上传成功后立即作废,防止重复使用)
+     *
+     * @param token JWT token
+     */
+    public void invalidate(String token) {
+        try {
+            Claims claims = Jwts.parser()
+                    .setSigningKey(secret)
+                    .parseClaimsJws(token)
+                    .getBody();
+            Object tokenIdObj = claims.get(CLAIM_TOKEN_ID);
+            if (tokenIdObj != null) {
+                redisCache.deleteObject(REDIS_KEY_PREFIX + tokenIdObj);
+            }
+        } catch (Exception ignored) {
+        }
+    }
+}

+ 319 - 0
fs-doctor-app/src/main/resources/static/doctor-sign.html

@@ -0,0 +1,319 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+    <title>药师签名上传</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+        html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: #f5f7fa; color: #303133; }
+        .container { max-width: 600px; margin: 0 auto; padding: 20px 16px; min-height: 100%; display: flex; flex-direction: column; }
+        .header { text-align: center; padding: 20px 0 16px; }
+        .header h1 { font-size: 20px; font-weight: 500; color: #303133; }
+        .doctor-card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); text-align: center; }
+        .doctor-card .label { font-size: 13px; color: #909399; margin-bottom: 6px; }
+        .doctor-card .name { font-size: 22px; font-weight: 600; color: #303133; }
+        .doctor-card .avatar-tip { font-size: 12px; color: #C0C4CC; margin-top: 8px; }
+        .sign-area { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
+        .sign-area .title { font-size: 15px; color: #303133; margin-bottom: 12px; font-weight: 500; }
+        .canvas-wrapper { position: relative; border: 1px dashed #DCDFE6; border-radius: 6px; background: #FAFAFA; overflow: hidden; }
+        .canvas-wrapper canvas { display: block; width: 100%; touch-action: none; cursor: crosshair; }
+        .canvas-empty-tip { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #C0C4CC; font-size: 14px; pointer-events: none; }
+        .sign-preview { margin-top: 16px; text-align: center; }
+        .sign-preview img { max-width: 100%; max-height: 200px; border: 1px solid #EBEEF5; border-radius: 4px; }
+        .actions { display: flex; gap: 12px; margin-top: 16px; }
+        .btn { flex: 1; height: 44px; line-height: 44px; text-align: center; border-radius: 6px; font-size: 15px; border: none; cursor: pointer; transition: opacity 0.2s; }
+        .btn:active { opacity: 0.7; }
+        .btn-primary { background: #409EFF; color: #fff; }
+        .btn-success { background: #67C23A; color: #fff; }
+        .btn-default { background: #fff; color: #606266; border: 1px solid #DCDFE6; }
+        .btn[disabled] { background: #A0CFFF !important; color: #fff !important; cursor: not-allowed; }
+        .btn-default[disabled] { background: #F5F7FA !important; color: #C0C4CC !important; border-color: #E4E7ED !important; }
+        .toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.75); color: #fff; padding: 10px 16px; border-radius: 6px; font-size: 14px; z-index: 1000; max-width: 80%; text-align: center; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
+        .toast.show { opacity: 1; }
+        .loading-mask { position: fixed; inset: 0; background: rgba(255,255,255,0.7); display: none; align-items: center; justify-content: center; z-index: 999; }
+        .loading-mask.show { display: flex; }
+        .loading-spinner { width: 36px; height: 36px; border: 3px solid #DCDFE6; border-top-color: #409EFF; border-radius: 50%; animation: rotate 0.8s linear infinite; }
+        @keyframes rotate { to { transform: rotate(360deg); } }
+        .error-page { text-align: center; padding: 60px 20px; }
+        .error-page .icon { font-size: 48px; color: #F56C6C; margin-bottom: 16px; }
+        .error-page .msg { font-size: 15px; color: #606266; margin-bottom: 20px; line-height: 1.6; }
+        .empty-tip { color: #C0C4CC; font-size: 13px; text-align: center; padding: 20px; }
+    </style>
+    <!-- 依赖:Vue 2 与 axios CDN(需在内联脚本之前加载) -->
+    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/axios@0.21.4/dist/axios.min.js"></script>
+    <script>
+        // 配置 axios 默认请求头,与药师端 PC 保持一致(参考 ylrz_his_scrm_doctorUI/src/utils/request.js)
+        // X-Frontend-Type: doctor 用于后端 gateway 识别请求来源
+        axios.defaults.headers.common['X-Frontend-Type'] = 'doctor';
+    </script>
+</head>
+<body>
+    <div id="app">
+        <!-- 加载中 -->
+        <div v-if="loading" class="loading-mask show">
+            <div class="loading-spinner"></div>
+        </div>
+
+        <!-- 错误页 -->
+        <div v-else-if="loadError" class="error-page">
+            <div class="icon">!</div>
+            <div class="msg">{{ loadError }}</div>
+        </div>
+
+        <!-- 主页 -->
+        <div v-else class="container">
+            <div class="header">
+                <h1>药师签名上传</h1>
+            </div>
+
+            <!-- 药师信息 -->
+            <div class="doctor-card">
+                <div class="label">当前药师</div>
+                <div class="name">{{ doctorName }}</div>
+                <div class="avatar-tip" v-if="hasSign">已上传签名,可重新签名覆盖</div>
+                <div class="avatar-tip" v-else>请下方完成签名上传</div>
+            </div>
+
+            <!-- 签名区域 -->
+            <div class="sign-area">
+                <div class="title">手写签名</div>
+                <div class="canvas-wrapper" id="canvasWrapper">
+                    <canvas id="signCanvas"></canvas>
+                    <div class="canvas-empty-tip" v-if="!hasDrawn">请在此区域签名</div>
+                </div>
+                <div class="actions">
+                    <button class="btn btn-default" @click="clearCanvas" :disabled="!hasDrawn">清 除</button>
+                    <button class="btn btn-success" @click="handleSave" :disabled="!hasDrawn || saving">{{ saving ? '保存中...' : '保存签名' }}</button>
+                </div>
+            </div>
+
+            <!-- 已保存签名预览 -->
+            <div class="sign-area" v-if="savedSignUrl">
+                <div class="title">已保存签名</div>
+                <div class="sign-preview">
+                    <img :src="savedSignUrl" alt="已保存签名" />
+                </div>
+            </div>
+        </div>
+
+        <!-- Toast -->
+        <div class="toast" :class="{ show: toastVisible }">{{ toastMsg }}</div>
+
+        <!-- 保存 loading -->
+        <div class="loading-mask" :class="{ show: saving }">
+            <div class="loading-spinner"></div>
+        </div>
+    </div>
+
+    <script>
+        new Vue({
+            el: '#app',
+            data: {
+                token: '',
+                doctorName: '',
+                hasSign: false,
+                savedSignUrl: '',
+                loading: true,
+                loadError: '',
+                saving: false,
+                hasDrawn: false,
+                toastVisible: false,
+                toastMsg: '',
+                // canvas 相关
+                canvas: null,
+                ctx: null,
+                drawing: false,
+                lastX: 0,
+                lastY: 0
+            },
+            mounted: function() {
+                this.token = this.getQueryParam('token');
+                if (!this.token) {
+                    this.loading = false;
+                    this.loadError = '未检测到有效签名凭证,请重新扫码访问';
+                    return;
+                }
+                this.loadDoctorInfo();
+            },
+            methods: {
+                getQueryParam: function(name) {
+                    var search = window.location.search.substring(1);
+                    var pairs = search.split('&');
+                    for (var i = 0; i < pairs.length; i++) {
+                        var pair = pairs[i].split('=');
+                        if (decodeURIComponent(pair[0]) === name) {
+                            return decodeURIComponent(pair[1] || '');
+                        }
+                    }
+                    return '';
+                },
+                showToast: function(msg, duration) {
+                    var self = this;
+                    this.toastMsg = msg;
+                    this.toastVisible = true;
+                    setTimeout(function() { self.toastVisible = false; }, duration || 2000);
+                },
+                loadDoctorInfo: function() {
+                    var self = this;
+                    axios.get('/app/doctor/sign/getInfo', { params: { token: this.token } })
+                        .then(function(res) {
+                            // 后端 R.ok(map) 会把 map 的 key 提到顶层,所以 doctorName/hasSign/signUrl 是顶层字段
+                            if (res.data.code === 200 && res.data.doctorName !== undefined) {
+                                self.doctorName = res.data.doctorName || '未知药师';
+                                self.hasSign = !!res.data.hasSign;
+                                self.savedSignUrl = res.data.signUrl || '';
+                                self.loading = false;
+                                self.$nextTick(function() { self.initCanvas(); });
+                            } else {
+                                self.loading = false;
+                                self.loadError = res.data.msg || '获取药师信息失败';
+                            }
+                        })
+                        .catch(function() {
+                            self.loading = false;
+                            self.loadError = '网络异常,请稍后重试';
+                        });
+                },
+                initCanvas: function() {
+                    var wrapper = document.getElementById('canvasWrapper');
+                    this.canvas = document.getElementById('signCanvas');
+                    // 高DPI屏幕适配
+                    var dpr = window.devicePixelRatio || 1;
+                    var width = wrapper.clientWidth;
+                    var height = 200;
+                    this.canvas.width = width * dpr;
+                    this.canvas.height = height * dpr;
+                    this.canvas.style.width = width + 'px';
+                    this.canvas.style.height = height + 'px';
+                    this.ctx = this.canvas.getContext('2d');
+                    this.ctx.scale(dpr, dpr);
+                    this.ctx.lineWidth = 2;
+                    this.ctx.lineCap = 'round';
+                    this.ctx.lineJoin = 'round';
+                    this.ctx.strokeStyle = '#303133';
+
+                    // 触摸事件
+                    var self = this;
+                    this.canvas.addEventListener('touchstart', function(e) { self.startDraw(e); }, { passive: false });
+                    this.canvas.addEventListener('touchmove', function(e) { self.draw(e); }, { passive: false });
+                    this.canvas.addEventListener('touchend', function() { self.endDraw(); }, { passive: false });
+                    // 鼠标事件
+                    this.canvas.addEventListener('mousedown', function(e) { self.startDraw(e); });
+                    this.canvas.addEventListener('mousemove', function(e) { self.draw(e); });
+                    this.canvas.addEventListener('mouseup', function() { self.endDraw(); });
+                    this.canvas.addEventListener('mouseleave', function() { self.endDraw(); });
+                },
+                getPos: function(e) {
+                    var rect = this.canvas.getBoundingClientRect();
+                    var clientX, clientY;
+                    if (e.touches && e.touches.length > 0) {
+                        clientX = e.touches[0].clientX;
+                        clientY = e.touches[0].clientY;
+                    } else {
+                        clientX = e.clientX;
+                        clientY = e.clientY;
+                    }
+                    return {
+                        x: clientX - rect.left,
+                        y: clientY - rect.top
+                    };
+                },
+                startDraw: function(e) {
+                    e.preventDefault();
+                    this.drawing = true;
+                    var pos = this.getPos(e);
+                    this.lastX = pos.x;
+                    this.lastY = pos.y;
+                    // 单点也绘制(避免点击不画线)
+                    this.ctx.beginPath();
+                    this.ctx.arc(pos.x, pos.y, 1, 0, 2 * Math.PI);
+                    this.ctx.fillStyle = '#303133';
+                    this.ctx.fill();
+                    this.hasDrawn = true;
+                },
+                draw: function(e) {
+                    if (!this.drawing) return;
+                    e.preventDefault();
+                    var pos = this.getPos(e);
+                    this.ctx.beginPath();
+                    this.ctx.moveTo(this.lastX, this.lastY);
+                    this.ctx.lineTo(pos.x, pos.y);
+                    this.ctx.stroke();
+                    this.lastX = pos.x;
+                    this.lastY = pos.y;
+                },
+                endDraw: function() {
+                    this.drawing = false;
+                },
+                clearCanvas: function() {
+                    if (!this.ctx) return;
+                    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+                    this.hasDrawn = false;
+                },
+                handleSave: function() {
+                    var self = this;
+                    if (!this.hasDrawn) {
+                        this.showToast('请先完成签名');
+                        return;
+                    }
+                    this.saving = true;
+                    // 1. canvas 转 Blob
+                    this.canvas.toBlob(function(blob) {
+                        if (!blob) {
+                            self.saving = false;
+                            self.showToast('签名生成失败');
+                            return;
+                        }
+                        self.uploadSign(blob);
+                    }, 'image/png');
+                },
+                uploadSign: function(blob) {
+                    var self = this;
+                    var formData = new FormData();
+                    formData.append('file', blob, 'sign_' + Date.now() + '.png');
+                    axios.post('/app/common/uploadOSS', formData, {
+                        headers: { 'Content-Type': 'multipart/form-data' }
+                    }).then(function(res) {
+                        if (res.data.code === 200 && res.data.url) {
+                            self.saveSignUrl(res.data.url);
+                        } else {
+                            self.saving = false;
+                            self.showToast(res.data.msg || '签名上传失败');
+                        }
+                    }).catch(function() {
+                        self.saving = false;
+                        self.showToast('网络异常,上传失败');
+                    });
+                },
+                saveSignUrl: function(signUrl) {
+                    var self = this;
+                    var formData = new FormData();
+                    formData.append('token', this.token);
+                    formData.append('signUrl', signUrl);
+                    axios.post('/app/doctor/sign/saveSign', formData)
+                        .then(function(res) {
+                            self.saving = false;
+                            if (res.data.code === 200) {
+                                self.savedSignUrl = signUrl;
+                                self.hasSign = true;
+                                self.showToast('签名保存成功', 2000);
+                                // 保存成功后清空画布
+                                self.clearCanvas();
+                            } else if (res.data.code === 401) {
+                                self.showToast('凭证已过期,请重新扫码', 3000);
+                            } else {
+                                self.showToast(res.data.msg || '保存失败', 2000);
+                            }
+                        })
+                        .catch(function() {
+                            self.saving = false;
+                            self.showToast('网络异常,保存失败');
+                        });
+                }
+            }
+        });
+    </script>
+</body>
+</html>

+ 2 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -533,7 +533,8 @@ public class CompanyServiceImpl implements ICompanyService
                 orderMap.setOrderId(order.getId());
                 orderMap.setTuiMoneyStatus(1);
                 storeOrderMapper.updateFsStoreOrder(orderMap);
-                BigDecimal money = order.getPayMoney().add(order.getPayRemain());
+                //加入非空判断
+                BigDecimal money = order.getPayMoney().add(order.getPayRemain() == null ? BigDecimal.ZERO : order.getPayRemain());
                 company.setMoney(company.getMoney().add(money));
                 companyMapper.updateCompany(company);
                 CompanyMoneyLogs log=new CompanyMoneyLogs();

+ 42 - 10
fs-service/src/main/java/com/fs/his/domain/FsCoupon.java

@@ -66,6 +66,38 @@ public class FsCoupon extends BaseEntity
 
     private String cateIds;
 
+    /** 免单商品ID **/
+    private Long freeGoodsId;
+
+    /** 归属项目 **/
+    private Integer projectId;
+
+    /** 优惠券logo **/
+    private String logo;
+
+    public Long getFreeGoodsId() {
+        return freeGoodsId;
+    }
+
+    public void setFreeGoodsId(Long freeGoodsId) {
+        this.freeGoodsId = freeGoodsId;
+    }
+
+    public Integer getProjectId() {
+        return projectId;
+    }
+
+    public void setProjectId(Integer projectId) {
+        this.projectId = projectId;
+    }
+
+    public String getLogo() {
+        return logo;
+    }
+
+    public void setLogo(String logo) {
+        this.logo = logo;
+    }
 
     public String getCateIds() {
         return cateIds;
@@ -191,15 +223,15 @@ public class FsCoupon extends BaseEntity
     @Override
     public String toString() {
         return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
-            .append("couponId", getCouponId())
-            .append("title", getTitle())
-            .append("createTime", getCreateTime())
-            .append("limitTime", getLimitTime())
-            .append("price", getPrice())
-            .append("number", getNumber())
-            .append("couponType", getCouponType())
-            .append("minPrice", getMinPrice())
-            .append("remainNumber", getRemainNumber())
-            .toString();
+                .append("couponId", getCouponId())
+                .append("title", getTitle())
+                .append("createTime", getCreateTime())
+                .append("limitTime", getLimitTime())
+                .append("price", getPrice())
+                .append("number", getNumber())
+                .append("couponType", getCouponType())
+                .append("minPrice", getMinPrice())
+                .append("remainNumber", getRemainNumber())
+                .toString();
     }
 }

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

@@ -59,6 +59,10 @@ public class FsIntegralGoods extends BaseEntity
     @Excel(name = "库存")
     private Long stock;
 
+    /** 限购数量(0=不限购,>0表示每个用户限购件数) */
+    @Excel(name = "限购数量")
+    private Integer limitNum;
+
     /** 详情 */
     @Excel(name = "详情")
     private String descs;
@@ -70,4 +74,7 @@ public class FsIntegralGoods extends BaseEntity
      * 非数据库字段 仅用于订单信息存储购买数量
      */
     private Integer num;
+
+    /** 运费模板ID **/
+    private Long tempId;
 }

+ 4 - 0
fs-service/src/main/java/com/fs/his/domain/FsIntegralOrder.java

@@ -57,6 +57,10 @@ public class FsIntegralOrder extends BaseEntity
     @Excel(name = "支付积分")
     private String integral;
 
+    /** 已退回积分(幂等校验用,参考 fs_store_order_scrm.back_integral) */
+    @Excel(name = "已退回积分")
+    private BigDecimal backIntegral;
+
     /** 支付金额 */
     @Excel(name = "支付金额")
     private BigDecimal payMoney;

+ 74 - 26
fs-service/src/main/java/com/fs/his/domain/FsUserAddress.java

@@ -7,7 +7,7 @@ import org.apache.commons.lang3.builder.ToStringStyle;
 
 /**
  * 用户地址对象 fs_user_address
- * 
+ *
  * @author fs
  * @date 2023-07-03
  */
@@ -42,6 +42,10 @@ public class FsUserAddress extends BaseEntity
     @Excel(name = "收货人所在区")
     private String district;
 
+    /** 乡镇 */
+    @Excel(name = "乡镇")
+    private String township;
+
     /** 城市IDS */
     @Excel(name = "城市IDS")
     private String cityIds;
@@ -70,114 +74,158 @@ public class FsUserAddress extends BaseEntity
     @Excel(name = "是否删除")
     private Integer isDel;
 
-    public void setAddressId(Long addressId) 
+    /** 用户是否确认 0未确认 1已确认 **/
+    private Integer isConfirm;
+
+    /** 帮填销售ID **/
+    private Long helpCompanyUserId;
+
+    public Integer getIsConfirm() {
+        return isConfirm;
+    }
+
+    public void setIsConfirm(Integer isConfirm) {
+        this.isConfirm = isConfirm;
+    }
+
+    public Long getHelpCompanyUserId() {
+        return helpCompanyUserId;
+    }
+
+    public void setHelpCompanyUserId(Long helpCompanyUserId) {
+        this.helpCompanyUserId = helpCompanyUserId;
+    }
+
+    public void setAddressId(Long addressId)
     {
         this.addressId = addressId;
     }
 
-    public Long getAddressId() 
+    public Long getAddressId()
     {
         return addressId;
     }
-    public void setUserId(Long userId) 
+
+    public void setUserId(Long userId)
     {
         this.userId = userId;
     }
 
-    public Long getUserId() 
+    public Long getUserId()
     {
         return userId;
     }
-    public void setRealName(String realName) 
+
+    public void setRealName(String realName)
     {
         this.realName = realName;
     }
 
-    public String getRealName() 
+    public String getRealName()
     {
         return realName;
     }
-    public void setPhone(String phone) 
+
+    public void setPhone(String phone)
     {
         this.phone = phone;
     }
 
-    public String getPhone() 
+    public String getPhone()
     {
         return phone;
     }
-    public void setProvince(String province) 
+
+    public void setProvince(String province)
     {
         this.province = province;
     }
 
-    public String getProvince() 
+    public String getProvince()
     {
         return province;
     }
-    public void setCity(String city) 
+
+    public void setCity(String city)
     {
         this.city = city;
     }
 
-    public String getCity() 
+    public String getCity()
     {
         return city;
     }
-    public void setDistrict(String district) 
+
+    public void setDistrict(String district)
     {
         this.district = district;
     }
 
-    public String getDistrict() 
+    public String getDistrict()
     {
         return district;
     }
-    public void setCityIds(String cityIds) 
+
+    public void setTownship(String township)
+    {
+        this.township = township;
+    }
+
+    public String getTownship()
+    {
+        return township;
+    }
+
+    public void setCityIds(String cityIds)
     {
         this.cityIds = cityIds;
     }
 
-    public String getCityIds() 
+    public String getCityIds()
     {
         return cityIds;
     }
-    public void setDetail(String detail) 
+
+    public void setDetail(String detail)
     {
         this.detail = detail;
     }
 
-    public String getDetail() 
+    public String getDetail()
     {
         return detail;
     }
-    public void setPostCode(String postCode) 
+
+    public void setPostCode(String postCode)
     {
         this.postCode = postCode;
     }
 
-    public String getPostCode() 
+    public String getPostCode()
     {
         return postCode;
     }
-    public void setLongitude(String longitude) 
+
+    public void setLongitude(String longitude)
     {
         this.longitude = longitude;
     }
 
-    public String getLongitude() 
+    public String getLongitude()
     {
         return longitude;
     }
-    public void setLatitude(String latitude) 
+
+    public void setLatitude(String latitude)
     {
         this.latitude = latitude;
     }
 
-    public String getLatitude() 
+    public String getLatitude()
     {
         return latitude;
     }
+
     public void setIsDefault(Integer isDefault)
     {
         this.isDefault = isDefault;
@@ -187,6 +235,7 @@ public class FsUserAddress extends BaseEntity
     {
         return isDefault;
     }
+
     public void setIsDel(Integer isDel)
     {
         this.isDel = isDel;
@@ -197,5 +246,4 @@ public class FsUserAddress extends BaseEntity
         return isDel;
     }
 
-
 }

+ 44 - 0
fs-service/src/main/java/com/fs/his/dto/ComputeIntegralOrderMoneyDTO.java

@@ -0,0 +1,44 @@
+package com.fs.his.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class ComputeIntegralOrderMoneyDTO {
+
+    /**
+     * 总积分
+     */
+    private BigDecimal totalIntegral = BigDecimal.ZERO;
+
+    /**
+     * 支付积分
+     */
+    private BigDecimal integral = BigDecimal.ZERO;
+
+    /**
+     * 优惠积分
+     */
+    private BigDecimal discountIntegral = BigDecimal.ZERO;
+
+    /**
+     * 总金额
+     */
+    private BigDecimal totalCash = BigDecimal.ZERO;
+
+    /**
+     * 支付金额
+     */
+    private BigDecimal cash = BigDecimal.ZERO;
+
+    /**
+     * 优惠金额
+     */
+    private BigDecimal discountCash = BigDecimal.ZERO;
+
+    /**
+     * 运费
+     */
+    private BigDecimal deliveryMoney = BigDecimal.ZERO;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/his/mapper/FsCityMapper.java

@@ -4,6 +4,7 @@ import java.util.List;
 import com.fs.his.domain.FsCity;
 import com.fs.his.vo.CitysAreaVO;
 import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
 /**
@@ -73,4 +74,9 @@ public interface FsCityMapper
     @Select("SELECT city_id,city_name FROM fs_city where parent_id=0 ")
     List<CitysAreaVO> getCitysArea();
 
+    /**
+     * 根据城市名称和上级编码查询城市
+     */
+    @Select("select * from fs_city where city_name = #{cityName} and parent_id = #{parentId}")
+    FsCity selectByNameAndParentId(@Param("cityName") String cityName, @Param("parentId") String parentId);
 }

+ 24 - 0
fs-service/src/main/java/com/fs/his/mapper/FsIntegralOrderMapper.java

@@ -108,4 +108,28 @@ public interface FsIntegralOrderMapper
     FsIntegralOrderPVO selectFsIntegralOrderPVO(Long orderId);
 
     int cancelOrder(@Param("orderId") Long orderId);
+
+    /**
+     * 确认收货(status: 2 待收货 → 3 已完成)
+     * <p>带旧状态校验的乐观更新,避免并发场景下状态被覆盖。</p>
+     *
+     * @param orderId   订单ID
+     * @param oldStatus 期望的旧状态(2=待收货)
+     * @return 影响行数(0 表示状态不匹配,订单已被其他流程处理)
+     */
+    int finishOrder(@Param("orderId") Long orderId, @Param("oldStatus") Integer oldStatus);
+
+    /**
+     * 统计用户已购买指定积分商品的有效订单数(限购校验用)
+     * <p>排除已取消(status=5)和已删除(status=-1)的订单;通过 JSON 函数精确匹配 item_json 中的 goodsId。</p>
+     *
+     * @param userId  用户ID
+     * @param goodsId 积分商品ID
+     * @return 有效订单数
+     */
+    @Select("SELECT COUNT(1) FROM fs_integral_order " +
+            "WHERE user_id = #{userId} " +
+            "AND status NOT IN (5, -1) " +
+            "AND JSON_CONTAINS(JSON_EXTRACT(item_json, '$[*].goodsId'), CAST(#{goodsId} AS JSON))")
+    int selectUserPurchasedCount(@Param("userId") Long userId, @Param("goodsId") Long goodsId);
 }

+ 21 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -482,4 +482,25 @@ public interface FsUserMapper
     List<FsUser> selectFsUsersByPhoneLimitOne(@Param("phone") String phone, @Param("appid") String appid);
 
     List<FsUser> selectFsUserListByUserIds(@Param("userIds") List<Long> userIds);
+
+    /**
+     * 原子扣减用户积分(SQL 层保证并发安全,余额不足返回 0)
+     * <p>避免应用层"读-改-写"造成的并发超扣,参考 hisStore 版 FsUserScrmServiceImpl 同名方法。</p>
+     *
+     * @param uid      用户ID
+     * @param integral 扣减积分数量(正数)
+     * @return 影响行数;0 表示积分余额不足
+     */
+    @Update("update fs_user set integral = integral - #{integral} where user_id = #{uid} and integral >= #{integral}")
+    int decIntegral(@Param("uid") Long uid, @Param("integral") double integral);
+
+    /**
+     * 原子增加用户积分
+     *
+     * @param uid      用户ID
+     * @param integral 增加积分数量(正数)
+     * @return 影响行数
+     */
+    @Update("update fs_user set integral = integral + #{integral} where user_id = #{uid}")
+    int incIntegral(@Param("uid") Long uid, @Param("integral") double integral);
 }

+ 19 - 0
fs-service/src/main/java/com/fs/his/param/FsIntegralOrderComputeParam.java

@@ -0,0 +1,19 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class FsIntegralOrderComputeParam {
+
+    private Long userId;
+
+    private Long userCouponId;
+
+    private Long  userAddressId;
+
+    private Long goodsId;
+
+    private List<Long> cartIds;
+}

+ 1 - 0
fs-service/src/main/java/com/fs/his/param/FsIntegralOrderDoPayParam.java

@@ -10,4 +10,5 @@ public class FsIntegralOrderDoPayParam {
     private Long orderId;
 
     private Long userId;
+    private String appId;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/his/param/PayOrderParam.java

@@ -44,4 +44,9 @@ public class PayOrderParam {
      * 业务类型
      */
     private BusinessTypeEnum businessType;
+
+    /**
+     * 小程序id
+     * **/
+    private String appId;
 }

+ 40 - 0
fs-service/src/main/java/com/fs/his/service/IFsIntegralOrderService.java

@@ -102,7 +102,47 @@ public interface IFsIntegralOrderService
      */
     R payConfirm(String orderSn, String payCode, String tradeNo, String payType, int type,String bankTransactionId,String bankSerialNo);
 
+    /**
+     * 积分订单支付回调(易宝聚合支付SDK版)
+     * <p>由 PayScrmController.multiStoreYbAggPayNotify 路由调用,参数直接取自易宝回调明文,
+     * 不再主动调用 ybPayService.getOrder 查询订单(避免依赖旧版易宝接口)。</p>
+     * <p>幂等:通过 FsStorePayment.status==0 与 FsIntegralOrder.status==4 双重校验保证,
+     * 重复回调不会重复更新订单状态。</p>
+     *
+     * @param payCode        支付单号(业务方生成的支付流水号)
+     * @param uniqueOrderNo  易宝收款订单号(写入 FsStorePayment.tradeNo)
+     * @param channelTrxId   微信/支付宝订单号(写入 FsStorePayment.bankTransactionId)
+     * @param bankOrderId    银行订单号(写入 FsStorePayment.bankSerialNo)
+     * @return "success"=处理成功 / "FAIL"=处理失败需重试 / ""=异常回滚或幂等已处理(与商城回调返回风格一致)
+     */
+    String payConfirmByYop(String payCode, String uniqueOrderNo, String channelTrxId, String bankOrderId);
+
     AjaxResult export(FsIntegralOrder fsIntegralOrder);
 
     int cancelOrder(String orderCode);
+
+    /**
+     * 管理员强制退款(不校验是否发货)
+     * <p>流程:更新订单状态为已取消 → 还原库存 → 易宝原路退款 → 原子退回积分 + 独立事务写积分日志(logType=27)。</p>
+     * <p>幂等:通过 logType=27 存在性判断,重复退款不会重复退积分。</p>
+     * <p>事务隔离:资金退款在主事务,积分退回异常不回滚退款事务(记日志人工补偿)。</p>
+     *
+     * @param orderCode 订单编号
+     * @return 影响行数
+     */
+    int mandatoryRefunds(String orderCode);
+
+    /**
+     * 确认收货(status: 2 待收货 → 3 已完成)
+     * <p>带旧状态校验的乐观更新。</p>
+     *
+     * @param orderCode 订单编号
+     * @return 影响行数(0 表示状态不匹配)
+     */
+    int finishOrder(String orderCode);
+
+    /**
+     * 积分订单支付金额计算
+     */
+    R computeOrder(FsIntegralOrderComputeParam param);
 }

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

@@ -276,4 +276,24 @@ public interface IFsUserService
      * @return R
      * **/
     R batchAddUserAndSaleFriend(List<FsUser> userList, Long compayUserId, Long projectId, Long companyId);
+
+    /**
+     * 原子扣减用户积分(SQL 层 integral = integral - ?,并发安全)
+     * <p>积分余额不足时抛出 ServiceException,保证业务侧不会出现超扣。
+     * 参考 hisStore 版 FsUserScrmServiceImpl.decIntegral 实现。</p>
+     *
+     * @param uid      用户ID
+     * @param integral 扣减积分数量(正数)
+     * @return 影响行数
+     */
+    int decIntegral(Long uid, double integral);
+
+    /**
+     * 原子增加用户积分(SQL 层 integral = integral + ?,并发安全)
+     *
+     * @param uid      用户ID
+     * @param integral 增加积分数量(正数)
+     * @return 影响行数
+     */
+    int incIntegral(Long uid, double integral);
 }

+ 633 - 12
fs-service/src/main/java/com/fs/his/service/impl/FsIntegralOrderServiceImpl.java

@@ -23,6 +23,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.his.domain.*;
+import com.fs.his.dto.ComputeIntegralOrderMoneyDTO;
 import com.fs.his.enums.BusinessTypeEnum;
 import com.fs.his.enums.FsUserIntegralLogTypeEnum;
 import com.fs.his.enums.PaymentMethodEnum;
@@ -32,17 +33,28 @@ import com.fs.his.service.IFsIntegralCartService;
 import com.fs.his.service.IFsIntegralOrderService;
 import com.fs.his.service.IFsStorePaymentService;
 import com.fs.his.service.IFsUserIntegralLogsService;
+import com.fs.his.service.IFsUserService;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.his.vo.FsIntegralOrderListUVO;
 import com.fs.his.vo.FsIntegralOrderListVO;
 import com.fs.his.vo.FsIntegralOrderPVO;
 import com.fs.his.vo.FsStoreProductDeliverExcelVO;
+import com.fs.hisStore.domain.FsShippingTemplatesFreeScrm;
+import com.fs.hisStore.domain.FsShippingTemplatesRegionScrm;
+import com.fs.hisStore.domain.FsShippingTemplatesScrm;
+import com.fs.hisStore.enums.ShippingTempEnum;
+import com.fs.hisStore.mapper.FsShippingTemplatesFreeScrmMapper;
+import com.fs.hisStore.mapper.FsShippingTemplatesRegionScrmMapper;
+import com.fs.hisStore.mapper.FsShippingTemplatesScrmMapper;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.tzBankPay.doman.PayType;
 import com.fs.ybPay.domain.OrderResult;
 import com.fs.ybPay.dto.OrderQueryDTO;
+import com.fs.ybPay.dto.YopRefundRequestDTO;
+import com.fs.ybPay.dto.YopRefundResponseDTO;
 import com.fs.ybPay.service.IPayService;
+import com.fs.ybPay.service.IYopPayService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.collections.CollectionUtils;
 import org.springframework.beans.BeanUtils;
@@ -52,9 +64,11 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.interceptor.TransactionAspectSupport;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.LocalDateTime;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /**
  * 积分商品订单Service业务层处理
@@ -66,12 +80,30 @@ import java.util.concurrent.TimeUnit;
 @Service
 public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
 {
+    /**
+     * 限购缓存 Redis Key 前缀
+     * <p>完整格式:integral:purchase:count:{userId}:{goodsId},缓存用户已购某商品的有效订单数</p>
+     */
+    private static final String REDIS_KEY_INTEGRAL_PURCHASE_COUNT = "integral:purchase:count:";
+
+    /** 限购缓存过期时间(1小时),过期后回源数据库重建 */
+    private static final Integer PURCHASE_COUNT_CACHE_TTL = 1;
+
+    @Autowired
+    private FsCouponMapper fsCouponMapper;
+
+    @Autowired
+    private FsUserCouponMapper fsUserCouponMapper;
+
     @Autowired
     private FsIntegralOrderMapper fsIntegralOrderMapper;
 
     @Autowired
     private FsUserAddressMapper fsUserAddressMapper;
 
+    @Autowired
+    private FsCityMapper fsCityMapper;
+
     @Autowired
     private FsUserMapper fsUserMapper;
 
@@ -80,6 +112,15 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
     @Autowired
     private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
 
+    @Autowired
+    private FsShippingTemplatesScrmMapper fsShippingTemplatesScrmMapper;
+
+    @Autowired
+    private FsShippingTemplatesFreeScrmMapper fsShippingTemplatesFreeScrmMapper;
+
+    @Autowired
+    private FsShippingTemplatesRegionScrmMapper fsShippingTemplatesRegionScrmMapper;
+
     @Autowired
     private QwUserMapper qwUserMapper;
 
@@ -91,10 +132,25 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
     private RedisCache redisCache;
     @Autowired
     private IPayService ybPayService;
+    /**
+     * 易宝聚合支付SDK(退款用,替代旧版 ybPayService.refund + MD5签名方式)
+     */
+    @Autowired
+    private IYopPayService yopPayService;
     @Autowired
     private IFsStorePaymentService storePaymentService;
+    /**
+     * 支付明细Mapper(查询订单关联的支付记录,用于退款)
+     */
+    @Autowired
+    private FsStorePaymentMapper fsStorePaymentMapper;
     @Autowired
     private IFsIntegralCartService cartService;
+    /**
+     * 用户积分Service(提供原子扣减/增加积分能力,避免应用层"读-改-写"并发超扣)
+     */
+    @Autowired
+    private IFsUserService userService;
 
     /**
      * 查询积分商品订单
@@ -203,7 +259,7 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
     }
 
     @Override
-    @Transactional
+    @Transactional(rollbackFor = Exception.class)
     public R createOrder(FsIntegralOrderCreateParam param) {
 
         FsUser user=fsUserMapper.selectFsUserByUserId(param.getUserId());
@@ -225,6 +281,14 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
             return R.error("积分不足");
         }
 
+        // 限购校验:limitNum > 0 表示开启限购,校验用户已购数量是否已达上限
+        if (integralGoods.getLimitNum() != null && integralGoods.getLimitNum() > 0) {
+            int purchasedCount = getUserPurchasedCount(param.getUserId(), param.getGoodsId());
+            if (purchasedCount + 1 > integralGoods.getLimitNum()) {
+                return R.error("超过限购数量,每人限购" + integralGoods.getLimitNum() + "件");
+            }
+        }
+
         // 减库存
         if (fsIntegralGoodsMapper.subStock(integralGoods.getGoodsId(), 1) <= 0) {
             throw new CustomException("库存不足");
@@ -298,22 +362,25 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
 
         if(fsIntegralOrderMapper.insertFsIntegralOrder(order)>0){
             if (order.getPayType() != 2) {
-                //写入日志
-                FsUser userMap=new FsUser();
-                userMap.setUserId(user.getUserId());
-                userMap.setIntegral(user.getIntegral()-totalIntegral);
-                fsUserMapper.updateFsUser(userMap);
+                // 原子扣减用户积分(SQL 层 integral = integral - ? where integral >= ?,并发安全)
+                // 参考 ylrz 商城订单 FsStoreOrderScrmServiceImpl.decIntegral 规范实现
+                userService.decIntegral(order.getUserId(), totalIntegral.doubleValue());
+                // 重新查询扣减后余额,用于记录 balance
+                FsUser userAfter = fsUserMapper.selectFsUserByUserId(order.getUserId());
                 FsUserIntegralLogs logs = new FsUserIntegralLogs();
                 logs.setIntegral(-totalIntegral);
                 logs.setUserId(order.getUserId());
-                logs.setBalance(userMap.getIntegral());
+                logs.setBalance(userAfter.getIntegral());
                 logs.setLogType(5);
                 logs.setBusinessId(order.getOrderId().toString());
                 logs.setCreateTime(new Date());
-                int shard = ShardingUtil.shardOf(order.getUserId());
-                fsUserIntegralLogsMapper.insertFsUserIntegralLogs(logs, shard);
+                // 独立事务写入分表日志,与订单主事务解耦
+                userIntegralLogsService.insertLog(logs);
             }
 
+            // 失效限购缓存,下次查询时从数据库重建(保证数据一致性)
+            invalidatePurchaseCountCache(order.getUserId(), goodsItem.get(0).getGoodsId());
+
             // 积分支付
             if (order.getPayType() == 1) {
                 // 首次完成积分商城下单
@@ -337,6 +404,48 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         }
     }
 
+    /**
+     * 获取用户已购买指定积分商品的有效订单数(限购校验用)
+     * <p>缓存策略:优先查 Redis,未命中则回源数据库并回填缓存(1小时过期)。
+     * 缓存过期后会重新从数据库查询,保证用户取消订单后限购数量能及时更新。</p>
+     *
+     * @param userId  用户ID
+     * @param goodsId 积分商品ID
+     * @return 已购买有效订单数(非负)
+     */
+    private int getUserPurchasedCount(Long userId, Long goodsId) {
+        String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId + ":" + goodsId;
+        Integer count = redisCache.getCacheObject(redisKey);
+        if (count != null) {
+            return count;
+        }
+        // 回源数据库查询有效订单数(排除已取消和已删除)
+        count = fsIntegralOrderMapper.selectUserPurchasedCount(userId, goodsId);
+        if (count < 0) {
+            count = 0;
+        }
+        // 回填 Redis 缓存,1小时过期
+        redisCache.setCacheObject(redisKey, count, PURCHASE_COUNT_CACHE_TTL, TimeUnit.HOURS);
+        return count;
+    }
+
+    /**
+     * 失效用户购买指定商品的限购缓存
+     * <p>订单创建成功后调用,确保下次限购校验时从数据库重新加载最新数据。</p>
+     *
+     * @param userId  用户ID
+     * @param goodsId 积分商品ID
+     */
+    private void invalidatePurchaseCountCache(Long userId, Long goodsId) {
+        try {
+            String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId + ":" + goodsId;
+            redisCache.deleteObject(redisKey);
+        } catch (Exception e) {
+            // 缓存失效失败不影响主流程,下次限购校验时缓存过期后会自动重建
+            log.warn("失效限购缓存失败, userId={}, goodsId={}", userId, goodsId, e);
+        }
+    }
+
     /**
      * 创建购物车订单
      * @param param 参数
@@ -516,15 +625,14 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
                 || order.getPayMoney().compareTo(BigDecimal.ZERO) <= 0){
             return R.error("非法操作");
         }
-
-        PayOrderParam payOrderParam = buildPayOrderParam(paymentMethod, order);
+        PayOrderParam payOrderParam = buildPayOrderParam(paymentMethod, order,param);
         return storePaymentService.processPayment(payOrderParam);
     }
 
     /**
      * 构建参数
      */
-    private static PayOrderParam buildPayOrderParam(PaymentMethodEnum paymentMethod, FsIntegralOrder order) {
+    private static PayOrderParam buildPayOrderParam(PaymentMethodEnum paymentMethod, FsIntegralOrder order,FsIntegralOrderDoPayParam param) {
         PayOrderParam payOrderParam = new PayOrderParam();
         payOrderParam.setOrderId(order.getOrderId());
         payOrderParam.setOrderCode(order.getOrderCode());
@@ -534,6 +642,9 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         payOrderParam.setCompanyUserId(order.getCompanyUserId());
         payOrderParam.setPaymentMethod(paymentMethod);
         payOrderParam.setBusinessType(BusinessTypeEnum.INTEGRAL_ORDER);
+        if(StringUtils.isNotBlank(param.getAppId())){
+            payOrderParam.setAppId(param.getAppId());
+        }
         return payOrderParam;
     }
 
@@ -602,6 +713,91 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         return R.ok();
     }
 
+    /**
+     * 积分订单支付回调(易宝聚合支付SDK版)
+     * <p>由 PayScrmController.multiStoreYbAggPayNotify 路由调用,参数直接取自易宝回调明文。</p>
+     * <p>幂等设计:FsStorePayment.status==0 且 FsIntegralOrder.status==4 才执行更新,
+     * 重复回调不会重复处理。Controller 层已按 payCode 加 Redisson 分布式锁。</p>
+     *
+     * @return "success"=处理成功 / "FAIL"=处理失败需重试 / ""=异常回滚或幂等已处理
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public String payConfirmByYop(String payCode, String uniqueOrderNo, String channelTrxId, String bankOrderId) {
+        log.info("进入积分商城订单支付回调(Yop) payCode: {}, uniqueOrderNo: {}, channelTrxId: {}, bankOrderId: {}",
+                payCode, uniqueOrderNo, channelTrxId, bankOrderId);
+
+        if (StringUtils.isEmpty(payCode)) {
+            log.error("积分订单回调(Yop) payCode 为空");
+            return "";
+        }
+
+        // 1. 查询支付记录
+        FsStorePayment storePayment = storePaymentService.selectFsStorePaymentByPaymentCode(payCode);
+        if (Objects.isNull(storePayment)) {
+            log.error("积分订单回调(Yop) 支付单号不存在 payCode: {}", payCode);
+            return "";
+        }
+
+        // 2. 幂等校验:支付记录已处理则直接返回成功(易宝重试场景)
+        if (storePayment.getStatus() != null && storePayment.getStatus() == 1) {
+            log.info("积分订单回调(Yop) 支付记录已处理,幂等返回 success payCode: {}", payCode);
+            return "success";
+        }
+
+        // 3. 更新支付记录(status=1 已支付,回写易宝交易号)
+        FsStorePayment paymentUpdate = new FsStorePayment();
+        paymentUpdate.setPaymentId(storePayment.getPaymentId());
+        paymentUpdate.setStatus(1);
+        paymentUpdate.setPayTime(new Date());
+        paymentUpdate.setTradeNo(uniqueOrderNo);
+        paymentUpdate.setBankSerialNo(bankOrderId);
+        paymentUpdate.setBankTransactionId(channelTrxId);
+        // 当前 createYbPayment 仅支持微信小程序支付,回调 payTypeCode 固定为微信小程序
+        paymentUpdate.setPayTypeCode(PayType.WECHAT_MINI_PROGRAM_PAYMENT.name());
+        storePaymentService.updateFsStorePayment(paymentUpdate);
+        log.info("积分订单回调(Yop) 更新支付记录完成 paymentId: {}, tradeNo: {}", storePayment.getPaymentId(), uniqueOrderNo);
+
+        // 4. 查询积分订单
+        FsIntegralOrder order;
+        try {
+            order = fsIntegralOrderMapper.selectFsIntegralOrderByOrderId(Long.parseLong(storePayment.getBusinessId()));
+        } catch (NumberFormatException e) {
+            log.error("积分订单回调(Yop) businessId 非法 paymentId: {}, businessId: {}", storePayment.getPaymentId(), storePayment.getBusinessId());
+            throw new CustomException("支付记录关联订单ID非法");
+        }
+
+        if (Objects.isNull(order)) {
+            log.error("积分订单回调(Yop) 积分订单不存在 orderId: {}", storePayment.getBusinessId());
+            throw new CustomException("积分订单不存在");
+        }
+
+        // 5. 幂等校验:订单状态非待支付(4)则不重复更新(支付记录已更新,但订单可能已被其他流程处理)
+        if (order.getStatus() == null || order.getStatus() != 4) {
+            log.warn("积分订单回调(Yop) 订单状态非待支付,跳过订单更新 orderId: {}, status: {}", order.getOrderId(), order.getStatus());
+            return "success";
+        }
+
+        // 6. 更新积分订单:status=1 已支付,isPay=1
+        FsIntegralOrder orderUpdate = new FsIntegralOrder();
+        orderUpdate.setOrderId(order.getOrderId());
+        orderUpdate.setIsPay(1);
+        orderUpdate.setStatus(1);
+        orderUpdate.setPayTime(LocalDateTime.now());
+        fsIntegralOrderMapper.updateFsIntegralOrder(orderUpdate);
+        log.info("积分订单回调(Yop) 更新积分订单完成 orderId: {}, status: 4->1", order.getOrderId());
+
+        // 7. 首次完成积分商城下单钩子(与旧版 payConfirm 保持功能对齐,当前未启用)
+        // 未来开启"购物送积分"时取消注释即可,无需再找代码位置
+        FsUserAddIntegralTemplateParam integralParam = new FsUserAddIntegralTemplateParam();
+        integralParam.setUserId(order.getUserId());
+        integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_22.getValue());
+        integralParam.setBusinessId(order.getOrderId().toString());
+//        userIntegralLogsService.addIntegralTemplate(integralParam);
+
+        return "success";
+    }
+
     @Override
     public AjaxResult export(FsIntegralOrder fsIntegralOrder) {
         List<FsIntegralOrder> list = fsIntegralOrderMapper.selectFsIntegralOrderList(fsIntegralOrder);
@@ -654,6 +850,200 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         return i;
     }
 
+    /**
+     * 管理员强制退款(不校验是否发货)
+     * <p>流程:
+     * <ol>
+     *   <li>更新订单状态为已取消(status=-1)</li>
+     *   <li>还原商品库存</li>
+     *   <li>易宝原路退款(Yop SDK,退款处理中也算成功,等回调最终确认)</li>
+     *   <li>原子退回用户积分(incIntegral)+ 独立事务写积分日志(logType=27,REQUIRES_NEW)</li>
+     * </ol>
+     * </p>
+     * <p>幂等:通过 logType=27 存在性判断,重复退款不会重复退积分。</p>
+     * <p>事务隔离:资金退款在主事务,积分退回异常不回滚退款事务(记日志人工补偿,符合项目规范)。</p>
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int mandatoryRefunds(String orderCode) {
+        FsIntegralOrder order = fsIntegralOrderMapper.selectFsIntegralOrderByOrderCode(orderCode);
+        if (null == order) {
+            throw new ServiceException("订单不存在");
+        }
+
+        // 1. 修改订单状态为已取消
+        int rows = fsIntegralOrderMapper.cancelOrder(order.getOrderId());
+        if (rows <= 0) {
+            throw new ServiceException("订单状态更新失败,可能已被处理");
+        }
+
+        // 2. 还原库存
+        restoreStock(order);
+
+        // 3. 易宝原路退款(仅纯现金/积分+现金订单需要退资金)
+        refundPaymentIfNeeded(order);
+
+        // 4. 退回积分(独立事务,异常不回滚主事务)
+        refundIntegralSafely(order);
+
+        return rows;
+    }
+
+    /**
+     * 确认收货(status: 2 待收货 → 3 已完成)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int finishOrder(String orderCode) {
+        FsIntegralOrder order = fsIntegralOrderMapper.selectFsIntegralOrderByOrderCode(orderCode);
+        if (null == order) {
+            throw new ServiceException("订单不存在");
+        }
+        if (order.getStatus() == null || order.getStatus() != 2) {
+            throw new ServiceException("订单未发货或已完成,无法确认收货");
+        }
+        // 带旧状态校验的乐观更新,避免并发覆盖
+        int rows = fsIntegralOrderMapper.finishOrder(order.getOrderId(), order.getStatus());
+        if (rows <= 0) {
+            throw new ServiceException("订单状态已变更,请刷新后重试");
+        }
+        return rows;
+    }
+
+    /**
+     * 还原商品库存
+     * <p>兼容 item_json 为单对象(旧数据)和数组(新数据)两种格式。</p>
+     */
+    private void restoreStock(FsIntegralOrder order) {
+        String itemJson = order.getItemJson();
+        if (StringUtils.isEmpty(itemJson)) {
+            return;
+        }
+        if (itemJson.startsWith("[") && itemJson.endsWith("]")) {
+            List<FsIntegralGoods> goodsItem = JSONUtil.toBean(itemJson, new TypeReference<List<FsIntegralGoods>>(){}, true);
+            goodsItem.forEach(goods -> fsIntegralGoodsMapper.addStock(goods.getGoodsId(), Objects.isNull(goods.getNum()) ? 1 : goods.getNum()));
+        } else {
+            FsIntegralGoods integralGoods = JSONUtil.toBean(itemJson, FsIntegralGoods.class);
+            fsIntegralGoodsMapper.addStock(integralGoods.getGoodsId(), Objects.isNull(integralGoods.getNum()) ? 1 : integralGoods.getNum());
+        }
+    }
+
+    /**
+     * 易宝原路退款(仅当订单有支付记录时执行)
+     * <p>退款成功(status=SUCCESS)或处理中(status=PROCESSING)都更新支付记录,
+     * 退款失败抛异常回滚主事务(订单状态还原)。</p>
+     */
+    private void refundPaymentIfNeeded(FsIntegralOrder order) {
+        // 查询订单关联的支付记录(businessType=6 积分订单)
+        List<FsStorePayment> payments = fsStorePaymentMapper.selectFsStorePaymentByPay(
+                BusinessTypeEnum.INTEGRAL_ORDER.getCode(), order.getOrderId());
+        if (payments == null || payments.isEmpty()) {
+            // 纯积分订单无支付记录,跳过资金退款
+            log.info("积分订单强制退款:无支付记录,跳过资金退款 orderId: {}", order.getOrderId());
+            return;
+        }
+        if (payments.size() > 1) {
+            log.warn("积分订单强制退款:存在多笔支付记录,仅处理第一笔 orderId: {}, 支付记录数: {}",
+                    order.getOrderId(), payments.size());
+        }
+
+        FsStorePayment payment = payments.get(0);
+        // 幂等校验:支付记录已退款则跳过
+        if (payment.getStatus() != null && payment.getStatus() < 0) {
+            log.info("积分订单强制退款:支付记录已退款,跳过 paymentId: {}, status: {}", payment.getPaymentId(), payment.getStatus());
+            return;
+        }
+
+        // 构建易宝退款请求(对齐 ylrz 商城订单 FsStoreAfterSalesScrmServiceImpl 退款逻辑)
+        YopRefundRequestDTO yopRefundRequest = new YopRefundRequestDTO();
+        yopRefundRequest.setOrderId(BusinessTypeEnum.INTEGRAL_ORDER.getPrefix() + "-" + payment.getPayCode());
+        yopRefundRequest.setUniqueOrderNo(payment.getTradeNo());
+        yopRefundRequest.setRefundRequestId("refund-" + payment.getPayCode());
+        yopRefundRequest.setRefundAmount(payment.getPayMoney());
+        yopRefundRequest.setDescription("积分订单管理员强制退款");
+
+        YopRefundResponseDTO yopRefundResult = yopPayService.refund(yopRefundRequest);
+        log.info("易宝SDK退款返回结果: 积分订单orderId: {}, code: {}, status: {}",
+                order.getOrderId(), yopRefundResult.getCode(), yopRefundResult.getStatus());
+
+        if (!yopRefundResult.isAccepted()) {
+            throw new CustomException("易宝退款请求失败:" + yopRefundResult.getMessage());
+        }
+
+        // 更新支付记录(最小更新对象,避免覆盖其他字段)
+        FsStorePayment paymentUpdate = new FsStorePayment();
+        paymentUpdate.setPaymentId(payment.getPaymentId());
+        paymentUpdate.setRefundMoney(payment.getPayMoney());
+        if (yopRefundResult.isRefundSuccess()) {
+            // 退款立即成功 → 标记已退款
+            paymentUpdate.setStatus(-1);
+            paymentUpdate.setRefundTime(new Date());
+        } else if (yopRefundResult.isProcessing()) {
+            // 退款处理中 → 标记退款中(status=-2),等待退款回调最终确认
+            paymentUpdate.setStatus(-2);
+        } else {
+            // 其他状态(CANCEL/SUSPEND等)视为退款失败
+            throw new CustomException("易宝退款异常,状态:" + yopRefundResult.getStatus());
+        }
+        storePaymentService.updateFsStorePayment(paymentUpdate);
+    }
+
+    /**
+     * 退回用户积分(独立事务,异常不回滚主事务)
+     * <p>幂等:通过 logType=27(退款订单退回积分)存在性判断,已退过则跳过。</p>
+     * <p>事务隔离:userIntegralLogsService.insertLog 自带 REQUIRES_NEW,
+     * incIntegral 无事务注解(裸执行),异常时 try-catch 记日志,不抛出。</p>
+     */
+    private void refundIntegralSafely(FsIntegralOrder order) {
+        if (StringUtils.isEmpty(order.getIntegral())) {
+            return;
+        }
+        long refundIntegral;
+        try {
+            refundIntegral = Long.parseLong(order.getIntegral());
+        } catch (NumberFormatException e) {
+            log.error("积分订单退款:积分字段非法 orderId: {}, integral: {}", order.getOrderId(), order.getIntegral(), e);
+            return;
+        }
+        if (refundIntegral <= 0) {
+            return;
+        }
+
+        // 幂等校验:检查是否已存在 logType=27 的退款记录
+        String businessId = order.getOrderId().toString();
+        List<FsUserIntegralLogs> existLogs = userIntegralLogsService.selectByUserIdAndBusinessIdAll(order.getUserId(), businessId);
+        boolean hasRefundRecord = existLogs != null && existLogs.stream()
+                .anyMatch(l -> FsUserIntegralLogTypeEnum.TYPE_27.getValue().equals(l.getLogType()));
+        if (hasRefundRecord) {
+            log.info("积分订单退款:已存在logType=27退款记录,跳过退积分 orderId: {}", order.getOrderId());
+            return;
+        }
+
+        try {
+            // 原子加回积分(SQL 层 integral = integral + ?,并发安全,与下单时 decIntegral 原子扣减对称)
+            userService.incIntegral(order.getUserId(), refundIntegral);
+            // 重新查询加回后余额,用于记录 balance
+            FsUser userAfter = fsUserMapper.selectFsUserByUserId(order.getUserId());
+            FsUserIntegralLogs logs = new FsUserIntegralLogs();
+            logs.setIntegral(refundIntegral);
+            logs.setUserId(order.getUserId());
+            logs.setBalance(userAfter.getIntegral());
+            // logType=27 退款订单退回积分(区别于 logType=26 取消订单退回积分)
+            logs.setLogType(FsUserIntegralLogTypeEnum.TYPE_27.getValue());
+            logs.setBusinessId(businessId);
+            logs.setBusinessType(2);
+            logs.setStatus(0);
+            logs.setCreateTime(new Date());
+            // 独立事务写入分表日志(REQUIRES_NEW),与退款主事务解耦
+            userIntegralLogsService.insertLog(logs);
+            log.info("积分订单退款:退回积分成功 orderId: {}, refundIntegral: {}", order.getOrderId(), refundIntegral);
+        } catch (Exception e) {
+            // 积分退回失败不回滚退款主事务,记录错误日志供人工补偿
+            log.error("积分订单退款:退回积分异常,需人工补偿 orderId: {}, refundIntegral: {}",
+                    order.getOrderId(), refundIntegral, e);
+        }
+    }
+
     /**
      * 处理手机号脱敏
      */
@@ -713,4 +1103,235 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         BeanUtils.copyProperties(original, cloned);
         return cloned;
     }
+
+    /**
+     * 积分订单支付金额计算
+     */
+    @Override
+    public R computeOrder(FsIntegralOrderComputeParam param) {
+        // 查询用户,判断用户是否存在
+        FsUser fsUser = fsUserMapper.selectFsUserById(param.getUserId());
+        if (fsUser == null || fsUser.getIsDel() == 1) {
+            return R.error("用户不存在");
+        }
+
+        if (param.getGoodsId() == null && (param.getCartIds() == null || param.getCartIds().isEmpty())) {
+            return R.error("商品不能为空");
+        }
+
+        ComputeIntegralOrderMoneyDTO moneyDTO = new ComputeIntegralOrderMoneyDTO();
+        List<FsIntegralGoods> goodsItem = new ArrayList<>();
+
+        // 判断是购物车还是单商品
+        if (param.getGoodsId() != null) {
+            FsIntegralGoods fsIntegralGoods = fsIntegralGoodsMapper.selectFsIntegralGoodsByGoodsId(param.getGoodsId());
+            if (fsIntegralGoods == null) {
+                return R.error("积分商品不存在");
+            }
+
+            if (fsIntegralGoods.getStatus() == 0) {
+                return R.error("积分商品已下架");
+            }
+
+            fsIntegralGoods.setNum(1);
+            goodsItem.add(fsIntegralGoods);
+
+            moneyDTO.setTotalCash(fsIntegralGoods.getCash());
+            moneyDTO.setCash(fsIntegralGoods.getCash());
+            moneyDTO.setTotalIntegral(new BigDecimal(fsIntegralGoods.getIntegral()));
+            moneyDTO.setIntegral(new BigDecimal(fsIntegralGoods.getIntegral()));
+
+            // 优惠金额计算
+            if (param.getUserCouponId() != null) {
+                applyCoupon(param.getUserCouponId(), fsUser.getUserId(), fsIntegralGoods, moneyDTO);
+            }
+        } else {
+            Wrapper<FsIntegralCart> wrapper = Wrappers.<FsIntegralCart>lambdaQuery().eq(FsIntegralCart::getUserId, fsUser.getUserId())
+                    .in(FsIntegralCart::getId, param.getCartIds());
+            List<FsIntegralCart> carts = cartService.list(wrapper);
+            if (carts.isEmpty()) {
+                return R.error("购物车商品不存在");
+            }
+
+            for (FsIntegralCart cart : carts) {
+                FsIntegralGoods integralGoods = fsIntegralGoodsMapper.selectFsIntegralGoodsByGoodsId(cart.getGoodsId());
+                if (Objects.isNull(integralGoods)) {
+                    throw new CustomException("商品不存在");
+                }
+
+                // 计算所需积分和现金
+                BigDecimal totalIntegral = new BigDecimal(integralGoods.getIntegral()).multiply(new BigDecimal(cart.getCartNum()));
+                BigDecimal totalCash = integralGoods.getCash().multiply(new BigDecimal(cart.getCartNum()));
+
+                moneyDTO.setTotalIntegral(moneyDTO.getTotalIntegral().add(totalIntegral));
+                moneyDTO.setTotalCash(moneyDTO.getTotalCash().add(totalCash));
+
+                integralGoods.setNum(cart.getCartNum());
+                goodsItem.add(integralGoods);
+            }
+
+            moneyDTO.setIntegral(moneyDTO.getTotalIntegral());
+            moneyDTO.setCash(moneyDTO.getTotalCash());
+        }
+
+        // 运费计算
+        if (param.getUserAddressId() != null) {
+            FsUserAddress fsUserAddress = fsUserAddressMapper.selectFsUserAddressByAddressId(param.getUserAddressId());
+            if (fsUserAddress == null || fsUserAddress.getIsDel() == 1 || fsUserAddress.getIsConfirm() == 0
+                    || !fsUser.getUserId().equals(fsUserAddress.getUserId())) {
+                return R.error("收货地址不存在");
+            }
+            calcDeliveryMoney(moneyDTO, fsUserAddress, goodsItem);
+        }
+
+        // 返回扣减后的支付金额
+        return R.ok().put("data", moneyDTO);
+    }
+
+    /**
+     * 计算运费
+     */
+    private void calcDeliveryMoney(ComputeIntegralOrderMoneyDTO moneyDTO, FsUserAddress address, List<FsIntegralGoods> goodsItem) {
+        // 解析省市信息
+        FsCity province = fsCityMapper.selectByNameAndParentId(address.getProvince(), "0");
+        if (province == null) return;
+
+        FsCity city = fsCityMapper.selectByNameAndParentId(address.getCity(), province.getCityId());
+        if (city == null) return;
+
+        // 按运费模板分组(过滤无模板商品)
+        Map<Long, List<FsIntegralGoods>> templateGroupMap = goodsItem.stream()
+                .filter(goods -> goods.getTempId() != null)
+                .collect(Collectors.groupingBy(FsIntegralGoods::getTempId));
+
+        BigDecimal totalDeliveryMoney = BigDecimal.ZERO;
+
+        for (Map.Entry<Long, List<FsIntegralGoods>> entry : templateGroupMap.entrySet()) {
+            BigDecimal fee = calcTemplateFee(entry.getKey(), entry.getValue(), province, city);
+            totalDeliveryMoney = totalDeliveryMoney.add(fee);
+        }
+
+        // 更新 DTO
+        moneyDTO.setDeliveryMoney(totalDeliveryMoney);
+        moneyDTO.setCash(moneyDTO.getCash().add(totalDeliveryMoney));
+        moneyDTO.setTotalCash(moneyDTO.getTotalCash().add(totalDeliveryMoney));
+    }
+
+
+    /**
+     * 计算优惠
+     */
+    private void applyCoupon(Long userCouponId, Long userId, FsIntegralGoods integralGoods, ComputeIntegralOrderMoneyDTO moneyDTO) {
+        // 查询用户优惠券,判断是否有效,是否未使用
+        FsUserCoupon fsUserCoupon = fsUserCouponMapper.selectFsUserCouponById(userCouponId);
+        if (fsUserCoupon == null || !Objects.equals(fsUserCoupon.getUserId(), userId)) {
+            throw new CustomException("无效的优惠券");
+        }
+
+        if (fsUserCoupon.getStatus() == 1) {
+            throw new CustomException("优惠券已使用");
+        }
+
+        if (fsUserCoupon.getStatus() == 2 || (fsUserCoupon.getLimitTime() != null && fsUserCoupon.getLimitTime().before(new Date()))) {
+            throw new CustomException("优惠券已过期");
+        }
+
+        // 查看优惠券具体类型
+        FsCoupon fsCoupon = fsCouponMapper.selectFsCouponByCouponId(fsUserCoupon.getCouponId());
+        if (fsCoupon == null) {
+            throw new CustomException("无效的优惠券");
+        }
+
+        if (fsCoupon.getCouponType() != 7 || !fsCoupon.getFreeGoodsId().equals(integralGoods.getGoodsId())) {
+            throw new CustomException("无效的优惠券");
+        }
+
+        moneyDTO.setDiscountIntegral(moneyDTO.getIntegral());
+        moneyDTO.setIntegral(BigDecimal.ZERO);
+        moneyDTO.setDiscountCash(moneyDTO.getCash());
+        moneyDTO.setCash(BigDecimal.ZERO);
+    }
+
+    /**
+     * 计算单个运费模板的运费
+     */
+    private BigDecimal calcTemplateFee(Long tempId, List<FsIntegralGoods> goodsList, FsCity province, FsCity city) {
+        FsShippingTemplatesScrm template = fsShippingTemplatesScrmMapper.selectFsShippingTemplatesById(tempId);
+
+        // 仅处理按件计费模板
+        if (template == null || !ShippingTempEnum.TYPE_1.getValue().equals(template.getType())) {
+            return BigDecimal.ZERO;
+        }
+
+        // 汇总商品总件数与总金额
+        int totalNum = 0;
+        BigDecimal totalPrice = BigDecimal.ZERO;
+        for (FsIntegralGoods goods : goodsList) {
+            int num = goods.getNum() != null ? goods.getNum() : 0;
+            if (num <= 0) continue;
+            totalNum += num;
+            if (goods.getCash() != null) {
+                totalPrice = totalPrice.add(goods.getCash().multiply(BigDecimal.valueOf(num)));
+            }
+        }
+
+        if (totalNum <= 0) return BigDecimal.ZERO;
+
+        // 检查指定包邮
+        if (isFreeShipping(template, province, city, totalNum, totalPrice)) {
+            return BigDecimal.ZERO;
+        }
+
+        // 查询运费区域配置(优先精确匹配,降级全国)
+        FsShippingTemplatesRegionScrm region = fsShippingTemplatesRegionScrmMapper.selectByTempIdAndProvinceIdAndCityId(template.getId(), province.getCityId(), city.getCityId());
+        if (region == null) {
+            region = fsShippingTemplatesRegionScrmMapper.selectByTempIdAndProvinceIdAndCityId(template.getId(), "0", null);
+        }
+
+        return region != null ? calcShippingFee(region, totalNum) : BigDecimal.ZERO;
+    }
+
+    /**
+     * 判断是否满足包邮条件
+     */
+    private boolean isFreeShipping(FsShippingTemplatesScrm template, FsCity province, FsCity city, int totalNum, BigDecimal totalPrice) {
+        if (template.getAppoint() == null || template.getAppoint() != 1) {
+            return false;
+        }
+
+        FsShippingTemplatesFreeScrm freeRule = fsShippingTemplatesFreeScrmMapper
+                .selectByTempIdAndProvinceIdAndCityId(template.getId(), province.getCityId(), city.getCityId());
+        if (freeRule == null) return false;
+
+        // 件数满足包邮(0 表示不限件数)
+        boolean freeByNum = freeRule.getNumber() == null
+                || freeRule.getNumber().intValue() == 0
+                || totalNum >= freeRule.getNumber().intValue();
+
+        // 金额满足包邮(0 表示不限金额)
+        boolean freeByPrice = freeRule.getPrice() != null
+                && freeRule.getPrice().compareTo(BigDecimal.ZERO) > 0
+                && totalPrice.compareTo(freeRule.getPrice()) >= 0;
+
+        return freeByNum || freeByPrice;  // 满足任一条件即可包邮
+    }
+
+    /**
+     * 根据区域配置计算运费(按件计费)
+     */
+    private BigDecimal calcShippingFee(FsShippingTemplatesRegionScrm region, int totalNum) {
+        BigDecimal firstCount = region.getFirst();
+        BigDecimal firstPrice = region.getFirstPrice();
+        BigDecimal continueCount = region.getContinues();
+        BigDecimal continuePrice = region.getContinuePrice();
+
+        if (totalNum <= firstCount.intValue() || continueCount.compareTo(BigDecimal.ZERO) == 0) {
+            return firstPrice;
+        }
+
+        // 超出首件部分,向上取整计算续件费用
+        BigDecimal extra = BigDecimal.valueOf(totalNum).subtract(firstCount);
+        BigDecimal continueUnits = extra.divide(continueCount, 0, RoundingMode.CEILING);
+        return firstPrice.add(continueUnits.multiply(continuePrice));
+    }
 }

+ 38 - 21
fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java

@@ -67,7 +67,6 @@ import com.fs.huifuPay.sdk.opps.core.request.V2TradePaymentScanpayQueryRequest;
 import com.fs.huifuPay.sdk.opps.core.request.V2TradePaymentScanpayRefundRequest;
 import com.fs.huifuPay.sdk.opps.core.utils.HuiFuUtils;
 import com.fs.huifuPay.service.HuiFuService;
-import com.fs.pay.pay.dto.WxJspayDTO;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.oss.CloudStorageService;
@@ -75,11 +74,13 @@ import com.fs.system.oss.OSSFactory;
 import com.fs.system.service.ISysConfigService;
 import com.fs.tzBankPay.TzBankService.TzBankService;
 import com.fs.tzBankPay.doman.*;
-import com.fs.ybPay.domain.CreateWxOrderResult;
 import com.fs.ybPay.domain.OrderResult;
 import com.fs.ybPay.dto.OrderQueryDTO;
 import com.fs.ybPay.dto.RefundDTO;
+import com.fs.ybPay.dto.YopPayRequestDTO;
+import com.fs.ybPay.dto.YopPayResponseDTO;
 import com.fs.ybPay.service.IPayService;
+import com.fs.ybPay.service.IYopPayService;
 import com.github.binarywang.wxpay.bean.notify.SignatureHeader;
 import com.github.binarywang.wxpay.bean.notify.WxPayNotifyResponse;
 import com.github.binarywang.wxpay.bean.notify.WxPayTransferBatchesNotifyV3Result;
@@ -406,6 +407,12 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
 }
     @Autowired
     IPayService payService;
+    /**
+     * 易宝聚合支付SDK(新版,替代旧版 payService.createWxOrder + MD5签名方式)
+     * 与 StoreOrderScrmController.pay 的 YB 分支保持一致,避免旧版签名错误
+     */
+    @Autowired
+    private IYopPayService yopPayService;
     @Override
     public R refundFsStorePayment(Long paymentId) {
         FsStorePayment fsStorePayment = fsStorePaymentMapper.selectFsStorePaymentByPaymentId(paymentId);
@@ -1276,32 +1283,42 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     }
 
     /**
-     * 易宝支付
+     * 易宝支付(聚合支付SDK版)
+     * <p>对齐 StoreOrderScrmController.pay 的 YB 分支实现,替代旧版 payService.createWxOrder + 手动MD5签名方式,
+     * 避免旧版因签名参数与发送参数不一致导致的"验证签名错误"问题。</p>
      */
     private R createYbPayment(FsStorePayment storePayment, FsUser user, PayOrderParam payOrderParam, FsPayConfig payConfig) {
         PaymentMethodEnum paymentMethod = payOrderParam.getPaymentMethod();
-        if (paymentMethod == PaymentMethodEnum.ALIPAY || paymentMethod == PaymentMethodEnum.H5_ALIPAY
-                || paymentMethod == PaymentMethodEnum.H5_WECHAT) {
+        if (paymentMethod != PaymentMethodEnum.MINIAPP_WECHAT) {
+            // 当前仅支持微信小程序支付场景,其他支付方式暂不可用
             throw new CustomException("支付暂不可用!");
         }
 
-        WxJspayDTO payDto = new WxJspayDTO();
-        payDto.setPayMoney(storePayment.getPayMoney().toString());
-        payDto.setLowOrderId(payOrderParam.getBusinessType().getPrefix() + "-" + storePayment.getPayCode());
-        payDto.setBody(payOrderParam.getBusinessType().getDesc());
-        payDto.setIsMinipg("1");
-        payDto.setOpenId(getOpenIdForPaymentMethod(user, paymentMethod, payConfig));
-        payDto.setAttach("");
-        payDto.setStoreid("0");
-
-        CreateWxOrderResult wxOrder = payService.createWxOrder(payDto);
-        logger.info("易宝支付返回: {}", wxOrder);
-
-        if ("100".equals(wxOrder.getStatus())) {
-            updateStorePaymentTradeNo(storePayment.getPaymentId(), wxOrder.getUpOrderId());
-            return R.ok().put("isPay", 0).put("data", wxOrder).put("type", "yb");
+        // 构建易宝聚合支付SDK下单请求(参数对齐 StoreOrderScrmController.pay 的 YB 分支)
+        YopPayRequestDTO yopDto = new YopPayRequestDTO();
+        yopDto.setOrderId(payOrderParam.getBusinessType().getPrefix() + "-" + storePayment.getPayCode());
+        yopDto.setOrderAmount(storePayment.getPayMoney());
+        yopDto.setPayWay("MINI_PROGRAM");
+        yopDto.setChannel("WECHAT");
+        yopDto.setUserId(user.getUserId());
+        yopDto.setCompanyId(payOrderParam.getCompanyId());
+        yopDto.setCompanyUserId(payOrderParam.getCompanyUserId());
+        yopDto.setGoodsName(payOrderParam.getBusinessType().getDesc());
+
+        yopDto.setAppId(payOrderParam.getAppId());
+
+        yopDto.setUserIp(IpUtils.getIpAddr(ServletUtils.getRequest()).split(",")[0]);
+
+        YopPayResponseDTO yopResult = yopPayService.pay(yopDto);
+        logger.info("易宝聚合支付下单返回: {}", yopResult);
+
+        if (yopResult != null && yopResult.isSuccess()) {
+            // 下单成功,更新支付记录的易宝交易号
+            updateStorePaymentTradeNo(storePayment.getPaymentId(), yopResult.getUniqueOrderNo());
+            return R.ok().put("isPay", 0).put("data", yopResult).put("type", "yb");
         } else {
-            throw new CustomException("支付失败");
+            String errMsg = yopResult != null ? yopResult.getMessage() : "下单失败";
+            throw new CustomException("易宝下单失败:" + errMsg);
         }
     }
 

+ 37 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -1628,4 +1628,41 @@ public class FsUserServiceImpl implements IFsUserService {
         }
         return "微信用户";
     }
+
+    /**
+     * 原子扣减用户积分
+     * <p>SQL 层保证并发安全:integral = integral - #{integral} where integral >= #{integral},
+     * 返回 0 表示余额不足,业务侧抛出 ServiceException 由调用方事务回滚。
+     * 参考 hisStore 版 FsUserScrmServiceImpl.decIntegral 实现。</p>
+     *
+     * @param uid      用户ID
+     * @param integral 扣减积分数量(正数)
+     * @return 影响行数
+     */
+    @Override
+    public int decIntegral(Long uid, double integral) {
+        if (uid == null || integral <= 0) {
+            throw new ServiceException("扣减积分参数非法");
+        }
+        int rows = fsUserMapper.decIntegral(uid, integral);
+        if (rows == 0) {
+            throw new ServiceException("用户积分余额不足,无法抵扣");
+        }
+        return rows;
+    }
+
+    /**
+     * 原子增加用户积分
+     *
+     * @param uid      用户ID
+     * @param integral 增加积分数量(正数)
+     * @return 影响行数
+     */
+    @Override
+    public int incIntegral(Long uid, double integral) {
+        if (uid == null || integral <= 0) {
+            throw new ServiceException("增加积分参数非法");
+        }
+        return fsUserMapper.incIntegral(uid, integral);
+    }
 }

+ 5 - 0
fs-service/src/main/java/com/fs/his/vo/FsIntegralOrderListVO.java

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.Date;
 
 @Data
@@ -39,6 +40,10 @@ public class FsIntegralOrderListVO {
     @Excel(name = "支付积分")
     private String integral;
 
+    /** 支付金额(现金部分,纯积分订单为0) */
+    @Excel(name = "支付金额")
+    private BigDecimal payMoney;
+
     /** 1:待发货;2:待收货;3:已完成 */
     @Excel(name = "1:待发货;2:待收货;3:已完成")
     private String status;

+ 6 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesFreeScrmMapper.java

@@ -86,4 +86,10 @@ public interface FsShippingTemplatesFreeScrmMapper
             "</if>" +
             "</script>"})
     int selectCountByTemplateDTO(@Param("maps") TemplateDTO mapValue);
+
+    /**
+     * 查询模板免邮配置
+     */
+    @Select("select * from fs_shipping_templates_free where temp_id = #{tempId} and province_id = #{provinceId} and city_id = #{cityId} limit 1")
+    FsShippingTemplatesFreeScrm selectByTempIdAndProvinceIdAndCityId(@Param("tempId") Long tempId, @Param("provinceId") String provinceId, @Param("cityId") String cityId);
 }

+ 5 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesRegionScrmMapper.java

@@ -66,4 +66,9 @@ public interface FsShippingTemplatesRegionScrmMapper
     int deleteFsShippingTemplatesRegionByTempId(Long tempId);
     @Select("select * from fs_shipping_templates_region where find_in_set(temp_id,#{tempIds}) and find_in_set(city_id,#{cityIds})")
     List<FsShippingTemplatesRegionScrm> selectFsShippingTemplatesRegionListByTempIdsAndCityIds(@Param("tempIds") String tempIds, @Param("cityIds") String cityIds);
+
+    /**
+     * 查询模板运费配置
+     */
+    FsShippingTemplatesRegionScrm selectByTempIdAndProvinceIdAndCityId(@Param("tempId") Long tempId, @Param("provinceId") String provinceId, @Param("cityId") String cityId);
 }

+ 6 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsShippingTemplatesScrmMapper.java

@@ -2,6 +2,7 @@ package com.fs.hisStore.mapper;
 
 import java.util.List;
 import com.fs.hisStore.domain.FsShippingTemplatesScrm;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
 /**
@@ -61,4 +62,9 @@ public interface FsShippingTemplatesScrmMapper
     public int deleteFsShippingTemplatesByIds(Long[] id);
     @Select("select * from fs_shipping_templates where find_in_set(id,#{ids})")
     List<FsShippingTemplatesScrm> selectFsShippingTemplatesByIds(String ids);
+
+    /**
+     * 根据id集合查询模板列表
+     */
+    List<FsShippingTemplatesScrm> selectByIds(@Param("ids") List<Long> ids);
 }

+ 5 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsShippingTemplatesScrmService.java

@@ -65,4 +65,9 @@ public interface IFsShippingTemplatesScrmService
     R addOrEdit(FsShippingTemplatesAddEditParam fsShippingTemplates);
 
     List<FsShippingTemplatesScrm> selectFsShippingTemplatesByIds(String ids);
+
+    /**
+     * 根据id集合查询模板列表
+     */
+    List<FsShippingTemplatesScrm> selectByIds(List<Long> ids);
 }

+ 13 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsShippingTemplatesScrmServiceImpl.java

@@ -1,7 +1,9 @@
 package com.fs.hisStore.service.impl;
 
 import java.math.BigDecimal;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 
 import cn.hutool.core.bean.BeanUtil;
 import cn.hutool.core.util.IdUtil;
@@ -224,4 +226,15 @@ public class FsShippingTemplatesScrmServiceImpl implements IFsShippingTemplatesS
             }
         }
     }
+
+    /**
+     * 根据id集合查询模板列表
+     */
+    @Override
+    public List<FsShippingTemplatesScrm> selectByIds(List<Long> ids) {
+        if (Objects.isNull(ids) || ids.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return fsShippingTemplatesMapper.selectByIds(ids);
+    }
 }

+ 4 - 1
fs-service/src/main/java/com/fs/huifuPay/sdk/opps/core/utils/HuiFuUtils.java

@@ -243,7 +243,10 @@ public class HuiFuUtils {
 
                                         for (Map acctInfo : acctInfos) {
                                             String percentageDiv = acctInfo.get("percentage_div").toString();
-                                            BigDecimal divAmt = reMoney.multiply(BigDecimal.valueOf(Float.valueOf(percentageDiv)).multiply(BigDecimal.valueOf(0.01))).setScale(2, BigDecimal.ROUND_HALF_UP);
+                                            // 使用 BigDecimal 替代 Float,避免浮点精度丢失(百分比支持6位小数)
+                                            BigDecimal percentage = new BigDecimal(percentageDiv)
+                                                    .divide(BigDecimal.valueOf(100), 10, BigDecimal.ROUND_HALF_UP);
+                                            BigDecimal divAmt = reMoney.multiply(percentage).setScale(2, BigDecimal.ROUND_HALF_UP);
                                             if (divAmt.compareTo(BigDecimal.ZERO) <= 0) {
                                                 continue;
                                             }

+ 34 - 6
fs-service/src/main/java/com/fs/ybPay/service/impl/YopPayServiceImpl.java

@@ -468,6 +468,24 @@ public class YopPayServiceImpl implements IYopPayService {
         JSONArray divideDetailArray = new JSONArray();
         BigDecimal totalDivideAmount = BigDecimal.ZERO;
 
+        // 分账百分比配置之和(用于校验,6位小数精度,匹配前端配置精度)
+        BigDecimal totalPercentage = BigDecimal.ZERO;
+        for (int i = 0; i < jsonArray.size(); i++) {
+            JSONObject js = jsonArray.getJSONObject(i);
+            BigDecimal percentage = js.getBigDecimal("percentageDiv");
+            if (percentage == null) {
+                throw new RuntimeException("分账配置错误:第" + (i + 1) + "条分账信息缺少percentageDiv(分账比例)");
+            }
+            totalPercentage = totalPercentage.add(percentage);
+        }
+        // 百分比之和校验:允许 ±0.000001% 的浮点误差
+        if (totalPercentage.subtract(BigDecimal.valueOf(100)).abs().compareTo(new BigDecimal("0.000001")) > 0) {
+            throw new RuntimeException("分账比例配置错误:全部分账百分比之和为" + totalPercentage + "%,应为100.000000%,请检查分账配置");
+        }
+
+        // 计算各分账接收方金额,前 N-1 户使用四舍五入保留2位,最后一户用"基准金额 - 前 N-1 户之和"补差
+        // 保证:分账总额 = 基准金额(0累计误差),避免分账金额丢失或溢出
+        int lastIndex = jsonArray.size() - 1;
         for (int i = 0; i < jsonArray.size(); i++) {
             JSONObject js = jsonArray.getJSONObject(i);
             JSONObject divideDetail = new JSONObject();
@@ -477,24 +495,34 @@ public class YopPayServiceImpl implements IYopPayService {
                 throw new RuntimeException("分账配置错误:第" + (i + 1) + "条分账信息缺少acctId(分账编号)");
             }
             BigDecimal percentage = js.getBigDecimal("percentageDiv");
-            if (percentage == null) {
-                throw new RuntimeException("分账配置错误:第" + (i + 1) + "条分账信息缺少percentageDiv(分账比例)");
+            // 中间计算精度提升到10位,避免中间舍入误差累积;最终金额保留2位(支付通道要求)
+            BigDecimal percentageRate = percentage.divide(BigDecimal.valueOf(100), 10, RoundingMode.HALF_UP);
+            BigDecimal amount;
+            if (i == lastIndex) {
+                // 末位补差:最后一户 = 基准金额 - 前 N-1 户之和,保证总额精确等于基准金额
+                amount = divideBaseAmount.subtract(totalDivideAmount);
+                log.info("易宝分账末位补差:末位账户={}, 前N-1户合计={}, 末位金额={}", account, totalDivideAmount, amount);
+            } else {
+                amount = divideBaseAmount.multiply(percentageRate).setScale(2, RoundingMode.HALF_UP);
             }
-            percentage = percentage.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
-            BigDecimal amount = divideBaseAmount.multiply(percentage).setScale(2, RoundingMode.HALF_UP);
             totalDivideAmount = totalDivideAmount.add(amount);
 
             divideDetail.put("amount", amount.toPlainString());
             divideDetail.put("ledgerNo", account);
             divideDetail.put("ledgerType", "MERCHANT2MERCHANT");
-            divideDetail.put("divideDetailDesc", "分账uuid编码:" + orderId + ",分账原始金额:" + unSplitAmount + ",分账比例:" + percentage);
+            divideDetail.put("divideDetailDesc", "分账uuid编码:" + orderId + ",分账原始金额:" + unSplitAmount + ",分账比例:" + percentage + "%");
             divideDetailArray.add(divideDetail);
         }
 
-        // 校验分账总额不超过剩余可分账金额
+        // 校验分账总额不超过剩余可分账金额(末位补差后应完全等于基准金额)
         if (totalDivideAmount.compareTo(divideBaseAmount) > 0) {
             throw new RuntimeException("分账金额合计" + totalDivideAmount + "元,超过剩余可分账金额" + unSplitAmount + "元,请检查分账比例配置");
         }
+        if (totalDivideAmount.compareTo(divideBaseAmount) != 0) {
+            // 末位补差后仍有差异,记录告警日志(可能基准金额精度问题)
+            log.warn("易宝分账金额合计{}与基准金额{}存在差异,差异={}", totalDivideAmount, divideBaseAmount, divideBaseAmount.subtract(totalDivideAmount));
+        }
+        log.info("易宝分账金额校验通过:基准金额={}, 分账总额={}, 分账笔数={}", divideBaseAmount, totalDivideAmount, jsonArray.size());
 //        else {//剩余金额进入总账号
 //            BigDecimal remainingAmount = divideBaseAmount.subtract(totalDivideAmount);
 //            JSONObject divideDetail = new JSONObject();

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

@@ -67,8 +67,8 @@ watch:
   password3: v9xsKuqn_$d2y
 
 fs :
-  commonApi: http://10.206.0.15:7771
-  h5CommonApi: http://10.206.0.15:7771
+  commonApi: http://127.0.0.1:7771
+  h5CommonApi: http://127.0.0.1:7771
   jwt:
     # 加密秘钥
     secret: 7af48cb753c96945816b5fdaaa01e879

+ 2 - 2
fs-service/src/main/resources/application-dev-yjb.yml

@@ -31,14 +31,14 @@ spring:
             druid:
                 # 主库数据源
                 master:
-                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                     username: yjb
                     password: Ylrz_1q2w3e4r5t6y
                 # 从库数据源
                 slave:
                     # 从数据源开关/默认关闭
                     enabled: true
-                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                     username: yjb
                     password: Ylrz_1q2w3e4r5t6y
                 # 初始连接数

+ 170 - 0
fs-service/src/main/resources/application-druid-yjb-test.yml

@@ -0,0 +1,170 @@
+# 数据源配置
+spring:
+    profiles:
+        include: config-druid-yjb,common
+    # redis 配置
+    redis:
+        # 地址
+        host: 127.0.0.1
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: yjb
+                    password: Ylrz_1q2w3e4r5t6y
+                # 从库数据源
+                slave:
+                    # 从数据源开关/默认关闭
+                    enabled: true
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: yjb
+                    password: Ylrz_1q2w3e4r5t6y
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+        sop:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_sop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: yjb
+                    password: Ylrz_1q2w3e4r5t6y
+                read:
+                    # 从数据源开关/默认关闭
+                    enabled: true
+                    url: jdbc:mysql://nj-cdb-6306xy90.sql.tencentcdb.com:27077/fs_his_sop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: yjb
+                    password: Ylrz_1q2w3e4r5t6y
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+rocketmq:
+    name-server: rmq-1243b25nj.rocketmq.gz.public.tencenttdmq.com:8080 # RocketMQ NameServer 地址
+    producer:
+        group: my-producer-group
+        access-key: ak1243b25nj17d4b2dc1a03 # 替换为实际的 accessKey
+        secret-key: sk08a7ea1f9f4b0237 # 替换为实际的 secretKey
+    consumer:
+        group: test-group
+        access-key: ak1243b25nj17d4b2dc1a03 # 替换为实际的 accessKey
+        secret-key: sk08a7ea1f9f4b0237 # 替换为实际的 secretKey
+# token配置(公司Web后台 fs-company 使用,App端不走此配置)
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: feeb79c778c1274dd0e4a709cd948718
+    # 令牌有效期(分钟),Web后台会话;App端见 fs.jwt.expire
+    expireTime: 180
+# App端 JWT 配置(fs-company-app / fs-user-app 使用)
+fs:
+    jwt:
+        secret: 7af48cb753c96945816b5fdaaa01e879
+        # 7天,单位秒
+        expire: 604800
+        header: AppToken
+#是否为新商户,新商户不走mpOpenId
+isNewWxMerchant: false
+openIM:
+    secret: openIM123
+    userID: imAdmin
+    url: https://webim.bjyjbao.com/api
+#是否使用新im
+im:
+    type: OPENIM

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

@@ -16,13 +16,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="cash"    column="cash"    />
         <result property="sort"    column="sort"    />
         <result property="stock"    column="stock"    />
+        <result property="limitNum"    column="limit_num"    />
         <result property="descs"    column="descs"    />
         <result property="createTime"    column="create_time"    />
         <result property="barCode"    column="bar_code"    />
     </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 from fs_integral_goods
+        select goods_id, img_url, images,bar_code, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, limit_num, descs, create_time from fs_integral_goods
     </sql>
 
     <select id="selectFsIntegralGoodsList" parameterType="FsIntegralGoods" resultMap="FsIntegralGoodsResult">
@@ -56,6 +57,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="cash != null">cash,</if>
             <if test="sort != null">sort,</if>
             <if test="stock != null">stock,</if>
+            <if test="limitNum != null">limit_num,</if>
             <if test="descs != null">descs,</if>
             <if test="createTime != null">create_time,</if>
             <if test="barCode != null">bar_code,</if>
@@ -72,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="cash != null">#{cash},</if>
             <if test="sort != null">#{sort},</if>
             <if test="stock != null">#{stock},</if>
+            <if test="limitNum != null">#{limitNum},</if>
             <if test="descs != null">#{descs},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="barCode != null">#{barCode},</if>
@@ -91,6 +94,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="cash != null">cash = #{cash},</if>
             <if test="sort != null">sort = #{sort},</if>
             <if test="stock != null">stock = #{stock},</if>
+            <if test="limitNum != null">limit_num = #{limitNum},</if>
             <if test="descs != null">descs = #{descs},</if>
             <if test="createTime != null">create_time = #{createTime},</if>
             <if test="barCode != null">bar_code = #{barCode},</if>

+ 11 - 1
fs-service/src/main/resources/mapper/his/FsIntegralOrderMapper.xml

@@ -13,6 +13,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="userAddress"    column="user_address"    />
         <result property="itemJson"    column="item_json"    />
         <result property="integral"    column="integral"    />
+        <result property="backIntegral"    column="back_integral"    />
         <result property="payMoney"    column="pay_money"    />
         <result property="isPay"    column="is_pay"    />
         <result property="payTime"    column="pay_time"    />
@@ -28,7 +29,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectFsIntegralOrderVo">
-        select order_id, order_code, user_id,bar_code, user_name, user_phone, user_address, item_json, integral,pay_money,is_pay,pay_time,pay_type, status, delivery_code, delivery_name, delivery_sn, delivery_time, create_time,qw_user_id,company_user_id,company_id,remark from fs_integral_order
+        select order_id, order_code, user_id,bar_code, user_name, user_phone, user_address, item_json, integral, back_integral, pay_money,is_pay,pay_time,pay_type, status, delivery_code, delivery_name, delivery_sn, delivery_time, create_time,qw_user_id,company_user_id,company_id,remark from fs_integral_order
     </sql>
 
     <select id="selectFsIntegralOrderList" parameterType="FsIntegralOrder" resultMap="FsIntegralOrderResult">
@@ -66,6 +67,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="userAddress != null">user_address,</if>
             <if test="itemJson != null">item_json,</if>
             <if test="integral != null">integral,</if>
+            <if test="backIntegral != null">back_integral,</if>
             <if test="payMoney != null">pay_money,</if>
             <if test="isPay != null">is_pay,</if>
             <if test="payTime != null">pay_time,</if>
@@ -91,6 +93,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="userAddress != null">#{userAddress},</if>
             <if test="itemJson != null">#{itemJson},</if>
             <if test="integral != null">#{integral},</if>
+            <if test="backIntegral != null">#{backIntegral},</if>
             <if test="payMoney != null">#{payMoney},</if>
             <if test="isPay != null">#{isPay},</if>
             <if test="payTime != null">#{payTime},</if>
@@ -119,6 +122,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="userAddress != null">user_address = #{userAddress},</if>
             <if test="itemJson != null">item_json = #{itemJson},</if>
             <if test="integral != null">integral = #{integral},</if>
+            <if test="backIntegral != null">back_integral = #{backIntegral},</if>
             <if test="payMoney != null">pay_money = #{payMoney},</if>
             <if test="isPay != null">is_pay = #{isPay},</if>
             <if test="payTime != null">pay_time = #{payTime},</if>
@@ -149,4 +153,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         update fs_integral_order set status = -1
         where order_id = #{orderId}
     </update>
+    <update id="finishOrder">
+        UPDATE fs_integral_order
+        SET status = 3
+        WHERE order_id = #{orderId}
+          AND status = #{oldStatus}
+    </update>
 </mapper>

+ 2 - 1
fs-service/src/main/resources/mapper/his/FsUserAddressMapper.xml

@@ -21,10 +21,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="isDel"    column="is_del"    />
         <result property="createTime"    column="create_time"    />
         <result property="updateTime"    column="update_time"    />
+        <result property="isConfirm"    column="is_confirm"    />
     </resultMap>
 
     <sql id="selectFsUserAddressVo">
-        select address_id, user_id, real_name, phone, province, city, district, city_ids, detail, post_code, longitude, latitude, is_default, is_del, create_time, update_time from fs_user_address
+        select address_id, user_id, real_name, phone, province, city, district, city_ids, detail, post_code, longitude, latitude, is_default, is_del, create_time, update_time,is_confirm from fs_user_address
     </sql>
 
     <select id="selectFsUserAddressList" parameterType="FsUserAddress" resultMap="FsUserAddressResult">

+ 9 - 0
fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesRegionScrmMapper.xml

@@ -88,5 +88,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{id}
         </foreach>
     </delete>
+
+    <select id="selectByTempIdAndProvinceIdAndCityId" resultType="com.fs.hisStore.domain.FsShippingTemplatesRegionScrm">
+        select * from fs_shipping_templates_region
+        where temp_id = #{tempId} and province_id = #{provinceId}
+        <if test="cityId != null">
+            and city_id = #{cityId}
+        </if>
+        limit 1
+    </select>
     
 </mapper>

+ 8 - 1
fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesScrmMapper.xml

@@ -38,7 +38,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <include refid="selectFsShippingTemplatesVo"/>
         where id = #{id}
     </select>
-        
+
+    <select id="selectByIds" resultType="com.fs.hisStore.domain.FsShippingTemplatesScrm">
+        select * from fs_shipping_templates where id in
+        <foreach item="tempId" collection="ids" open="(" separator="," close=")">
+            #{tempId}
+        </foreach>
+    </select>
+
     <insert id="insertFsShippingTemplates" parameterType="FsShippingTemplatesScrm" useGeneratedKeys="true" keyProperty="id">
         insert into fs_shipping_templates
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 11 - 0
fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java

@@ -129,6 +129,14 @@ public class IntegralController extends  AppBaseController {
         return itemJson;
     }
 
+    @Login
+    @ApiOperation("计算订单")
+    @PostMapping("/compute")
+    public R compute(@Validated @RequestBody FsIntegralOrderComputeParam param){
+        param.setUserId(Long.parseLong(getUserId()));
+        return integralOrderService.computeOrder(param);
+    }
+
 
     @Login
     @ApiOperation("创建订单")
@@ -152,6 +160,9 @@ public class IntegralController extends  AppBaseController {
     @ApiOperation("支付")
     @PostMapping("/payment")
     public R payment(@Validated @RequestBody FsIntegralOrderDoPayParam param) {
+        if(StringUtils.isEmpty(param.getAppId())){
+            return R.error("支付APPID不能为空!");
+        }
         param.setUserId(Long.parseLong(getUserId()));
         return integralOrderService.payment(param, PaymentMethodEnum.MINIAPP_WECHAT);
     }

+ 25 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/PayScrmController.java

@@ -12,6 +12,7 @@ import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.erp.service.IErpOrderService;
 import com.fs.his.domain.FsPayConfig;
+import com.fs.his.service.IFsIntegralOrderService;
 import com.fs.hisStore.domain.FsStorePaymentScrm;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.hisStore.service.*;
@@ -99,6 +100,12 @@ public class PayScrmController {
     @Autowired
     private IYopPayService yopPayService;
 
+    /**
+     * 积分订单Service(用于易宝聚合支付回调路由到积分订单的支付确认)
+     */
+    @Autowired
+    private IFsIntegralOrderService integralOrderService;
+
     @ApiOperation("汇付第三方支付回调")
     @PostMapping(value = "/hfPayNotify")
     public String hfPayNotify(@RequestParam String resp_desc, @RequestParam String resp_code, @RequestParam String sign, @RequestParam String resp_data) throws Exception {
@@ -248,7 +255,7 @@ public class PayScrmController {
      * channelTrxId(微信/支付宝订单号)、bankOrderId(银行订单号)等
      * <p>
      * orderId 格式为 "业务前缀-payCode",如:store-1234567890
-     * 支持业务前缀:store(商城订单)、store_remain(尾款)、payment(支付单)等
+     * 支持业务前缀:store(商城订单)、store_remain(尾款)、integral(积分商城订单)等
      * 后续扩展APP微信/APP支付宝等支付方式,回调入口统一,只需在此路由即可
      */
     @ApiOperation("易宝聚合支付SDK回调")
@@ -357,6 +364,23 @@ public class PayScrmController {
                         logger.error("【易宝回调-尾款处理失败】store_remain, payCode={}, cost={}ms, result={}, uniqueOrderNo={} 返回FAIL触发易宝重试", payCode, costMs, result, uniqueOrderNo);
                         return "FAIL";
                     }
+                    case "integral": {
+                        // 积分商城订单支付成功 → 积分订单确认(对齐 StoreOrderScrmController.pay 的 YB 分支)
+                        long startMs = System.currentTimeMillis();
+                        String result = integralOrderService.payConfirmByYop(payCode, uniqueOrderNo, channelTrxId, bankOrderId);
+                        long costMs = System.currentTimeMillis() - startMs;
+                        if ("success".equals(result) || "SUCCESS".equals(result)) {
+                            logger.info("【易宝回调-积分订单处理成功】integral, payCode={}, cost={}ms, uniqueOrderNo={}", payCode, costMs, uniqueOrderNo);
+                            return "SUCCESS";
+                        }
+                        if (StringUtils.isBlank(result)) {
+                            logger.error("【易宝回调-积分订单返回空】integral, payCode={}, cost={}ms, uniqueOrderNo={} Service返回空(可能未找到支付记录/订单/异常回滚),事务已回滚但返回SUCCESS",
+                                    payCode, costMs, uniqueOrderNo);
+                            return "SUCCESS";
+                        }
+                        logger.error("【易宝回调-积分订单处理失败】integral, payCode={}, cost={}ms, result={}, uniqueOrderNo={} 返回FAIL触发易宝重试", payCode, costMs, result, uniqueOrderNo);
+                        return "FAIL";
+                    }
 //                    case "payment": {
 //                        String result = storePaymentService.payConfirmMultiStore(payCode, uniqueOrderNo, channelTrxId, bankOrderId);
 //                        if ("success".equals(result) || "SUCCESS".equals(result)) {

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

@@ -13,4 +13,5 @@ spring:
 #    active: druid-sxjz
 #    active: druid-qdtst
 #    active: druid-yzt
-    active: dev-yjb
+#    active: dev-yjb
+    active: druid-yjb-test #医健宝测试库