cl
5 天以前 01ab61191b93956954b463ab4416fda6b5f960ee
token 改到redis
5个文件已添加
13个文件已修改
503 ■■■■ 已修改文件
rsf-admin/src/i18n/en.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/zh.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/histories/asnOrderLog/AsnOrderLogCloudWmsResendButton.jsx 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/histories/asnOrderLog/AsnOrderLogListBase.jsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/OpenApi.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/config/OpenApiTokenRedisConfig.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/controller/AuthController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/security/filter/AppIdAuthenticationFilter.java 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/security/utils/TokenUtils.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/service/TokenService.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/java/com/vincent/rsf/openApi/service/impl/TokenServiceImpl.java 97 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/resources/application-dev.yml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/resources/application-prod.yml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-open-api/src/main/resources/application.yml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/AsnOrderLogController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/CloudWmsFeedbackResendService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsFeedbackResendServiceImpl.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/asn_order_log_cloud_wms_resend_permission.sql 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/en.js
@@ -852,6 +852,8 @@
                arrTime: "arrTime",
                rleStatus: "rleStatus",
                ntyStatus: "ntyStatus",
                cloudWmsResend: "Resend cloud WMS feedback",
                cloudWmsResendConfirm: "Re-queue in/out completion feedback to cloud WMS for this order?",
            },
            asnOrderItemLog: {
                asnId: "asnId",
rsf-admin/src/i18n/zh.js
@@ -919,6 +919,8 @@
                arrTime: "预计到达时间",
                rleStatus: "释放状态",
                ntyStatus: "质检上报状态",
                cloudWmsResend: "云仓反馈重发",
                cloudWmsResendConfirm: "确认将该历史单的云仓入出库反馈重新加入发送队列?",
            },
            asnOrderItemLog: {
                orderId: "主单标识",
rsf-admin/src/page/histories/asnOrderLog/AsnOrderLogCloudWmsResendButton.jsx
New file
@@ -0,0 +1,44 @@
import { useState } from "react";
import { useRecordContext, useTranslate } from "react-admin";
import { Button } from "@mui/material";
import request from "@/utils/request";
/** 出入库历史单:云仓 ERP 反馈重发(权限 manager:asnOrderLog:cloudWmsResend) */
export default function AsnOrderLogCloudWmsResendButton() {
    const record = useRecordContext();
    const translate = useTranslate();
    const [loading, setLoading] = useState(false);
    if (!record?.id) {
        return null;
    }
    const onClick = async () => {
        const ok = window.confirm(
            translate("resources.asnOrderLog.cloudWmsResendConfirm", {
                _: "确认将该历史单的云仓入出库反馈重新加入发送队列?",
            })
        );
        if (!ok) return;
        setLoading(true);
        try {
            const res = await request.post(`/asnOrderLog/cloudWms/resendFeedback/${record.id}`);
            const { code, msg, data } = res.data || {};
            if (code === 200) {
                window.alert(msg || "OK");
            } else {
                window.alert(msg || "Error");
            }
        } catch (e) {
            window.alert(e?.message || String(e));
        } finally {
            setLoading(false);
        }
    };
    return (
        <Button size="small" variant="outlined" disabled={loading} onClick={onClick}>
            {translate("resources.asnOrderLog.cloudWmsResend", { _: "云仓反馈重发" })}
        </Button>
    );
}
rsf-admin/src/page/histories/asnOrderLog/AsnOrderLogListBase.jsx
@@ -23,6 +23,7 @@
import { Box } from '@mui/material';
import PageDrawer from "../../components/PageDrawer";
import AsnOrderLogCreate from "./AsnOrderLogCreate";
import AsnOrderLogCloudWmsResendButton from "./AsnOrderLogCloudWmsResendButton";
import { styled } from '@mui/material/styles';
const StyledDatagrid = styled(DatagridConfigurable)(({ theme }) => ({
@@ -111,6 +112,7 @@
                    <TextField source="memo" label="common.field.memo" sortable={false} />
                    <WrapperField cellClassName="opt" label="common.field.opt">
                        <ShowButton label="toolbar.detail" />
                        <AsnOrderLogCloudWmsResendButton />
                    </WrapperField>
                </StyledDatagrid>
            </List>
rsf-open-api/src/main/java/com/vincent/rsf/openApi/OpenApi.java
@@ -2,12 +2,17 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class, UserDetailsServiceAutoConfiguration.class })
@SpringBootApplication(exclude = {
        SecurityAutoConfiguration.class,
        UserDetailsServiceAutoConfiguration.class,
        RedisReactiveAutoConfiguration.class
})
@EnableFeignClients(basePackages = "com.vincent.rsf.openApi.feign")
@EnableScheduling
public class OpenApi {
rsf-open-api/src/main/java/com/vincent/rsf/openApi/config/OpenApiTokenRedisConfig.java
New file
@@ -0,0 +1,46 @@
package com.vincent.rsf.openApi.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
/**
 * 业务 Redis({@code redis.index})与默认 {@code redisTemplate};与电视机侧 {@code tv-monitor.redis.database} 分离
 */
@Configuration
public class OpenApiTokenRedisConfig {
    @Value("${redis.host:127.0.0.1}")
    private String host;
    @Value("${redis.port:6379}")
    private int port;
    @Value("${redis.password:}")
    private String password;
    @Value("${redis.index:0}")
    private int database;
    @Bean(name = "openApiTokenRedisConnectionFactory")
    public LettuceConnectionFactory openApiTokenRedisConnectionFactory() {
        RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(host, port);
        cfg.setDatabase(database);
        if (password != null && !password.isEmpty()) {
            cfg.setPassword(password);
        }
        return new LettuceConnectionFactory(cfg);
    }
    /**
     * 存在多个 ConnectionFactory 时 Boot 不会装配默认 redisTemplate
     */
    @Bean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(
            @Qualifier("openApiTokenRedisConnectionFactory") LettuceConnectionFactory openApiTokenRedisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(openApiTokenRedisConnectionFactory);
        return template;
    }
}
rsf-open-api/src/main/java/com/vincent/rsf/openApi/controller/AuthController.java
@@ -1,11 +1,9 @@
package com.vincent.rsf.openApi.controller;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.openApi.entity.constant.Constants;
import com.vincent.rsf.openApi.entity.dto.CommonResponse;
import com.vincent.rsf.openApi.entity.params.GetTokenParam;
import com.vincent.rsf.openApi.security.service.AppAuthService;
import com.vincent.rsf.openApi.security.utils.TokenUtils;
import com.vincent.rsf.openApi.service.TokenService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
@@ -18,7 +16,7 @@
public class AuthController {
    @Autowired
    private AppAuthService appAuthService;
    private TokenService tokenService;
    @ApiOperation("获取App认证Token")
    @PostMapping("/getToken")
@@ -28,10 +26,10 @@
        if (Cools.isEmpty(appId, appSecret)) {
            return CommonResponse.error("AppId和AppSecret不能为空");
        }
        if (!appAuthService.validateApp(appId, appSecret)) {
        String token = tokenService.issueToken(appId, appSecret);
        if (token == null) {
            return CommonResponse.error("AppId或AppSecret无效");
        }
        String token = Constants.TOKEN_PREFIX + TokenUtils.generateToken(appId, appSecret);
        return CommonResponse.ok().setMsg("获取Token成功").setData(token);
    }
}
rsf-open-api/src/main/java/com/vincent/rsf/openApi/security/filter/AppIdAuthenticationFilter.java
@@ -1,8 +1,10 @@
package com.vincent.rsf.openApi.security.filter;
import com.vincent.rsf.openApi.entity.app.App;
import com.vincent.rsf.openApi.entity.constant.Constants;
import com.vincent.rsf.openApi.security.service.AppAuthService;
import com.vincent.rsf.openApi.security.utils.TokenUtils;
import com.vincent.rsf.openApi.service.TokenService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@@ -27,6 +29,8 @@
    @Resource
    private AppAuthService appAuthService;
    @Resource
    private TokenService tokenService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -41,21 +45,19 @@
        String authHeader = request.getHeader(Constants.HEADER_AUTHORIZATION);
        if (authHeader != null) {
            String token = TokenUtils.extractTokenFromHeader(authHeader);
            if (token != null && TokenUtils.validateTokenTime(token)) {
                String tokenAppId = TokenUtils.getAppIdFromToken(token);
                String tokenAppSecret = TokenUtils.getSecretFromToken(token);
                if (!StringUtils.hasText(tokenAppId) || !StringUtils.hasText(tokenAppSecret)
                        || !appAuthService.validateApp(tokenAppId, tokenAppSecret)) {
                    log.warn("Token验证失败");
                    sendErrorResponse(response, Constants.UNAUTHENTICATED_CODE, "认证失败,请提供有效的Token");
                    return;
                }
                request.setAttribute(Constants.REQUEST_ATTR_APP_ID, tokenAppId);
            } else {
            String tokenAppId = token != null ? tokenService.getAppIdIfValid(token) : null;
            if (!StringUtils.hasText(tokenAppId)) {
                log.warn("Token验证失败或缺失");
                sendErrorResponse(response, Constants.UNAUTHENTICATED_CODE, "认证失败,请提供有效的Token");
                return;
            }
            App app = appAuthService.getAppInfo(tokenAppId);
            if (app == null || app.getEnable() == null || app.getEnable() != 1) {
                log.warn("Token对应应用无效或已禁用");
                sendErrorResponse(response, Constants.UNAUTHENTICATED_CODE, "认证失败,请提供有效的Token");
                return;
            }
            request.setAttribute(Constants.REQUEST_ATTR_APP_ID, tokenAppId);
        } else {
            log.warn("缺少Token认证信息");
            sendErrorResponse(response, Constants.UNAUTHENTICATED_CODE, "认证失败,请提供有效的Token");
rsf-open-api/src/main/java/com/vincent/rsf/openApi/security/utils/TokenUtils.java
@@ -1,81 +1,13 @@
package com.vincent.rsf.openApi.security.utils;
import com.vincent.rsf.openApi.entity.constant.Constants;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
 * JWT Token 工具类
 * Authorization 头解析;JWT 签发与校验在 {@link com.vincent.rsf.openApi.service.impl.TokenServiceImpl}
 */
public class TokenUtils {
    private static final Logger log = LoggerFactory.getLogger(TokenUtils.class);
public final class TokenUtils {
    private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private static final long TOKEN_EXPIRATION = 60 * 60 * 1000L;
    public static String generateToken(Map<String, Object> claims) {
        long now = System.currentTimeMillis();
        Date expiration = new Date(now + TOKEN_EXPIRATION);
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(expiration)
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256)
                .compact();
    }
    public static String generateToken(String appId, String appSecret) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("appId", appId);
        claims.put("appSecret", appSecret);
        claims.put("created", System.currentTimeMillis());
        return generateToken(claims);
    }
    public static Claims parseToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(SECRET_KEY)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException e) {
            log.error("解析Token失败: {}", e.getMessage());
            return null;
        }
    }
    public static boolean validateTokenTime(String token) {
        try {
            Claims claims = parseToken(token);
            if (claims == null) {
                return false;
            }
            Date expiration = claims.getExpiration();
            return expiration != null && expiration.after(new Date());
        } catch (JwtException e) {
            log.error("验证Token失败: {}", e.getMessage());
            return false;
        }
    }
    public static String getAppIdFromToken(String token) {
        Claims claims = parseToken(token);
        return claims != null ? (String) claims.get("appId") : null;
    }
    public static String getSecretFromToken(String token) {
        Claims claims = parseToken(token);
        return claims != null ? (String) claims.get("appSecret") : null;
    private TokenUtils() {
    }
    public static String extractTokenFromHeader(String authHeader) {
rsf-open-api/src/main/java/com/vincent/rsf/openApi/service/TokenService.java
@@ -1,7 +1,7 @@
package com.vincent.rsf.openApi.service;
/**
 * 对接协议 8.1 Token 签发与校验
 * 对接协议 8.1 Token 签发与校验(JWT,密钥 open-api.jwt.secret)
 */
public interface TokenService {
@@ -17,4 +17,9 @@
     * 校验 token 是否有效
     */
    boolean validateToken(String token);
    /**
     * 校验 token 并返回绑定的 appId(不含 Bearer 前缀的 token 串)
     */
    String getAppIdIfValid(String rawToken);
}
rsf-open-api/src/main/java/com/vincent/rsf/openApi/service/impl/TokenServiceImpl.java
@@ -1,26 +1,65 @@
package com.vincent.rsf.openApi.service.impl;
import com.vincent.rsf.openApi.entity.constant.Constants;
import com.vincent.rsf.openApi.security.service.AppAuthService;
import com.vincent.rsf.openApi.service.TokenService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 对接 Token:JWT(密钥来自配置,重启后同一密钥仍可校验未过期 token;claims 仅含 appId)
 */
@Slf4j
@Service
public class TokenServiceImpl implements TokenService {
    private static final long EXPIRE_MS = 60 * 60 * 1000L;
    private static final String CLAIM_APP_ID = "appId";
    private final AppAuthService appAuthService;
    private final SecretKey secretKey;
    private final long expireMs;
    @Autowired
    private AppAuthService appAuthService;
    public TokenServiceImpl(
            AppAuthService appAuthService,
            @Value("${open-api.jwt.secret:}") String jwtSecret,
            @Value("${open-api.jwt.expire-seconds:3600}") long expireSeconds) {
        this.appAuthService = appAuthService;
        if (!StringUtils.hasText(jwtSecret)) {
            throw new IllegalStateException("请配置 open-api.jwt.secret(HS256,建议至少 32 字符或使用环境变量覆盖)");
        }
        this.secretKey = hmacSha256Key(jwtSecret.trim());
        this.expireMs = expireSeconds * 1000L;
    }
    private final Map<String, Long> tokenExpire = new ConcurrentHashMap<>();
    /** 短字符串用 SHA-256 派生 32 字节以满足 HS256 */
    private static SecretKey hmacSha256Key(String secret) {
        byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
        if (bytes.length < 32) {
            try {
                MessageDigest md = MessageDigest.getInstance("SHA-256");
                bytes = md.digest(bytes);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
        return Keys.hmacShaKeyFor(bytes);
    }
    @Override
    public String issueToken(String appId, String appSecret) {
@@ -30,27 +69,43 @@
        if (!appAuthService.validateApp(appId, appSecret)) {
            return null;
        }
        String token = UUID.randomUUID().toString().replace("-", "");
        tokenExpire.put(token, System.currentTimeMillis() + EXPIRE_MS);
        evictExpired();
        return token;
        long now = System.currentTimeMillis();
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_APP_ID, appId);
        String compact = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(now))
                .setExpiration(new Date(now + expireMs))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
        return Constants.TOKEN_PREFIX + compact;
    }
    @Override
    public boolean validateToken(String token) {
        if (!StringUtils.hasText(token)) {
            return false;
        }
        Long expire = tokenExpire.get(token);
        if (expire == null || System.currentTimeMillis() > expire) {
            tokenExpire.remove(token);
            return false;
        }
        return true;
        return getAppIdIfValid(token) != null;
    }
    private void evictExpired() {
        long now = System.currentTimeMillis();
        tokenExpire.entrySet().removeIf(e -> e.getValue() < now);
    @Override
    public String getAppIdIfValid(String rawToken) {
        if (!StringUtils.hasText(rawToken)) {
            return null;
        }
        try {
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(rawToken)
                    .getBody();
            Date exp = claims.getExpiration();
            if (exp == null || exp.before(new Date())) {
                return null;
            }
            Object appId = claims.get(CLAIM_APP_ID);
            return appId != null ? appId.toString() : null;
        } catch (JwtException e) {
            log.debug("JWT 校验失败: {}", e.getMessage());
            return null;
        }
    }
}
rsf-open-api/src/main/resources/application-dev.yml
@@ -54,6 +54,11 @@
  timeout: 5000
  index: 15
open-api:
  jwt:
    secret: "rsf-open-api-jwt-dev-key-change-in-prod-min-32chars"
    expire-seconds: 3600
# 电视机 Redis:与 zy-monitor-admin 使用同一 database(默认 0),否则读不到报警
tv-monitor:
  redis:
rsf-open-api/src/main/resources/application-prod.yml
@@ -55,6 +55,11 @@
  database: 3
  index: 3
open-api:
  jwt:
    secret: ${OPEN_API_JWT_SECRET:REPLACE-WITH-STRONG-SECRET-AT-LEAST-32-CHARS}
    expire-seconds: 3600
# 电视机 Redis:与 zy-monitor-admin 使用同一 database(默认 0),否则读不到报警
tv-monitor:
  redis:
rsf-open-api/src/main/resources/application.yml
@@ -1,6 +1,10 @@
spring:
  profiles:
    active: dev
  data:
    redis:
      repositories:
        enabled: false
mybatis-plus:
  mapper-locations: classpath:mapper/*/*.xml
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/AsnOrderLogController.java
@@ -13,6 +13,7 @@
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.manager.entity.AsnOrderLog;
import com.vincent.rsf.server.manager.service.AsnOrderLogService;
import com.vincent.rsf.server.manager.service.CloudWmsFeedbackResendService;
import com.vincent.rsf.server.system.controller.BaseController;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
@@ -27,6 +28,16 @@
    @Autowired
    private AsnOrderLogService asnOrderLogService;
    @Autowired
    private CloudWmsFeedbackResendService cloudWmsFeedbackResendService;
    @ApiOperation("云仓入出库反馈重发(写入上报待办队列)")
    @OperationLog("CloudWMS feedback resend")
    @PreAuthorize("hasAuthority('manager:asnOrderLog:cloudWmsResend')")
    @PostMapping("/asnOrderLog/cloudWms/resendFeedback/{id}")
    public R resendCloudWmsFeedback(@PathVariable Long id) {
        return cloudWmsFeedbackResendService.resendInOutFeedbackByAsnOrderLogId(id);
    }
    @PreAuthorize("hasAuthority('manager:asnOrderLog:list')")
    @PostMapping("/asnOrderLog/page")
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/CloudWmsFeedbackResendService.java
New file
@@ -0,0 +1,12 @@
package com.vincent.rsf.server.manager.service;
import com.vincent.rsf.framework.common.R;
/** 按出入库历史单重发云仓入出库反馈(写入上报待办队列) */
public interface CloudWmsFeedbackResendService {
    /**
     * @param asnOrderLogId man_asn_order_log / AsnOrderLog 主键
     */
    R resendInOutFeedbackByAsnOrderLogId(Long asnOrderLogId);
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsFeedbackResendServiceImpl.java
New file
@@ -0,0 +1,121 @@
package com.vincent.rsf.server.manager.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.manager.entity.AsnOrderLog;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
import com.vincent.rsf.server.manager.service.AsnOrderLogService;
import com.vincent.rsf.server.manager.service.CloudWmsFeedbackResendService;
import com.vincent.rsf.server.manager.service.CloudWmsNotifyLogService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
/**
 * 从历史云仓上报记录中取「最新一条」按明细维度去重后,复制请求体写入待发送队列。
 */
@Slf4j
@Service
public class CloudWmsFeedbackResendServiceImpl implements CloudWmsFeedbackResendService {
    private static final int MAX_SCAN = 500;
    @Autowired
    private AsnOrderLogService asnOrderLogService;
    @Autowired
    private CloudWmsNotifyLogService cloudWmsNotifyLogService;
    @Autowired
    private ObjectMapper objectMapper;
    @Override
    public R resendInOutFeedbackByAsnOrderLogId(Long asnOrderLogId) {
        if (asnOrderLogId == null) {
            return R.error("参数不能为空");
        }
        AsnOrderLog orderLog = asnOrderLogService.getById(asnOrderLogId);
        if (orderLog == null) {
            return R.error("历史单据不存在");
        }
        String code = orderLog.getCode();
        if (StringUtils.isBlank(code)) {
            return R.error("单据号为空,无法重发");
        }
        String reportType = cloudWmsNotifyLogService.getReportTypeInOutResult();
        LambdaQueryWrapper<CloudWmsNotifyLog> qw = new LambdaQueryWrapper<CloudWmsNotifyLog>()
                .eq(CloudWmsNotifyLog::getReportType, reportType)
                .and(w -> w.like(CloudWmsNotifyLog::getRequestBody, "\"orderNo\":\"" + code + "\"")
                        .or()
                        .like(CloudWmsNotifyLog::getBizRef, "orderNo=" + code))
                .orderByDesc(CloudWmsNotifyLog::getId)
                .last("LIMIT " + MAX_SCAN);
        List<CloudWmsNotifyLog> candidates = cloudWmsNotifyLogService.list(qw);
        Map<String, CloudWmsNotifyLog> latestByLine = new LinkedHashMap<>();
        for (CloudWmsNotifyLog row : candidates) {
            if (StringUtils.isBlank(row.getRequestBody())) {
                continue;
            }
            InOutResultReportParam p;
            try {
                p = objectMapper.readValue(row.getRequestBody(), InOutResultReportParam.class);
            } catch (Exception e) {
                continue;
            }
            if (p == null || !code.equals(p.getOrderNo())) {
                continue;
            }
            if (!matchOrderType(orderLog.getType(), p.getInbound())) {
                continue;
            }
            String sig = lineSignature(p);
            latestByLine.putIfAbsent(sig, row);
        }
        if (latestByLine.isEmpty()) {
            return R.error("未找到该单号对应的云仓入出库上报记录,无法重发");
        }
        Date now = new Date();
        int n = 0;
        for (CloudWmsNotifyLog src : latestByLine.values()) {
            CloudWmsNotifyLog copy = new CloudWmsNotifyLog()
                    .setReportType(reportType)
                    .setRequestBody(src.getRequestBody())
                    .setNotifyStatus(cloudWmsNotifyLogService.getNotifyStatusPending())
                    .setRetryCount(0)
                    .setBizRef("manualResend,asnOrderLogId=" + asnOrderLogId + ",fromNotifyLogId=" + src.getId() + ",orderNo=" + code)
                    .setCreateTime(now)
                    .setUpdateTime(now);
            cloudWmsNotifyLogService.fillFromConfig(copy);
            if (cloudWmsNotifyLogService.save(copy)) {
                n++;
            }
        }
        return R.ok("已加入云仓重发队列 " + n + " 条,将由定时任务发送").add(n);
    }
    private static boolean matchOrderType(String asnType, Boolean inbound) {
        if (StringUtils.isBlank(asnType)) {
            return true;
        }
        boolean wantIn = "in".equalsIgnoreCase(asnType.trim());
        if (inbound == null) {
            return true;
        }
        return wantIn == inbound;
    }
    private static String lineSignature(InOutResultReportParam p) {
        return String.join("|",
                String.valueOf(p.getOrderNo()),
                String.valueOf(p.getLineId()),
                String.valueOf(p.getMatNr()),
                String.valueOf(p.getLocId()),
                String.valueOf(p.getWareHouseId()),
                String.valueOf(p.getInbound()),
                String.valueOf(p.getQty()),
                String.valueOf(p.getBatch()));
    }
}
version/db/asn_order_log_cloud_wms_resend_permission.sql
New file
@@ -0,0 +1,30 @@
-- 出入库历史单:云仓 ERP 反馈重发按钮权限(需在角色中勾选后才可使用)
-- 执行一次即可;若提示重复 authority 可忽略或先删除同 authority 记录
INSERT INTO `sys_menu` (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
SELECT 'CloudWMS feedback resend',
       m.id,
       NULL,
       CONCAT(IFNULL(m.path, ''), ',cloudWmsResend'),
       NULL,
       NULL,
       NULL,
       NULL,
       NULL,
       1,
       'manager:asnOrderLog:cloudWmsResend',
       NULL,
       98,
       NULL,
       m.tenant_id,
       1,
       0,
       NULL,
       NULL,
       NULL,
       NULL,
       '入库/出库历史单:云仓反馈重发'
FROM `sys_menu` m
WHERE m.`authority` = 'manager:asnOrderLog:list'
  AND m.`type` = 1
  AND NOT EXISTS (SELECT 1 FROM `sys_menu` x WHERE x.`authority` = 'manager:asnOrderLog:cloudWmsResend')
LIMIT 1;