소스 검색

打印机

chenxiaofei 1 개월 전
부모
커밋
bd35741356

+ 15 - 2
iot-platform-manager/pom.xml

@@ -98,8 +98,21 @@
         <dependency>
             <groupId>com.alibaba</groupId>
             <artifactId>fastjson</artifactId>
-        </dependency>			<!-- Feign依赖管理(Spring Cloud 2023.0.1已包含,可省略) -->
-			<dependency>
+        </dependency>
+
+        <dependency>
+            <groupId>io.github.dv996coding</groupId>
+            <artifactId>xpclouds-opensdk</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.aliyun.oss</groupId>
+                    <artifactId>aliyun-sdk-oss</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency><!-- Feign依赖管理(Spring Cloud 2023.0.1已包含,可省略) -->
+
+
+        <dependency>
 				<groupId>org.springframework.cloud</groupId>
 				<artifactId>spring-cloud-starter-openfeign</artifactId>
 				<version>4.1.1</version> <!-- 与Spring Cloud 2023.0.1匹配 -->

+ 8 - 2
iot-platform-manager/src/main/java/com/platform/IotPlatformManagerApplication.java

@@ -1,10 +1,10 @@
 package com.platform;
 
 import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
 import org.springframework.cloud.openfeign.EnableFeignClients;
 import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
@@ -15,12 +15,18 @@ import org.springframework.scheduling.annotation.EnableScheduling;
  * @Version: 1.0
  */
 
-@SpringBootApplication
+
 @MapperScan("com.platform.mapper")
 @EnableScheduling
 @EnableAsync
 @EnableDiscoveryClient
 @EnableFeignClients
+@SpringBootApplication(
+        scanBasePackages = {
+                "com.platform",
+                "io.github.dv996coding"  // 加上
+        }
+)
 public class IotPlatformManagerApplication {
     public static void main(String[] args) {
         SpringApplication.run(IotPlatformManagerApplication.class, args);

+ 56 - 0
iot-platform-manager/src/main/java/com/platform/api/controller/XpCloudPrintController.java

@@ -0,0 +1,56 @@
+package com.platform.api.controller;
+
+import com.platform.api.request.XpPrintImageReqVo;
+import com.platform.api.request.XpPrintReceiptReqVo;
+import com.platform.result.BaseResult;
+import com.platform.service.XpCloudPrintService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 芯烨云打印控制器
+ *
+ * @author assistant
+ */
+@Validated
+@RestController
+@RequiredArgsConstructor
+@Tag(name = "芯烨云打印接口")
+@RequestMapping("/kwsPrinter/xp")
+public class XpCloudPrintController {
+
+    private final XpCloudPrintService xpCloudPrintService;
+
+    /**
+     * 图片打印接口
+     *
+     * @param reqVo 图片打印请求参数
+     * @return 打印任务号
+     */
+    @PostMapping("/printImage")
+    @Operation(summary = "调用芯烨SDK printImage 接口")
+    public BaseResult<String> printImage(@Valid @RequestBody XpPrintImageReqVo reqVo) {
+        return BaseResult.success(xpCloudPrintService.printImage(reqVo));
+    }
+
+    /**
+     * 小票订单打印接口
+     *
+     * <p>content示例:\n&lt;CB&gt;四川顺采建筑材料有限公司...&lt;/BOLD&gt;</p>
+     *
+     * @param reqVo 小票订单请求参数(包含公共参数与业务参数)
+     * @return 打印任务号
+     */
+    @PostMapping("/printReceipt")
+    @Operation(summary = "调用芯烨SDK print 接口打印小票订单")
+    public BaseResult<String> printReceipt(@Valid @RequestBody XpPrintReceiptReqVo reqVo) {
+        return BaseResult.success(xpCloudPrintService.printReceipt(reqVo));
+    }
+}

+ 34 - 0
iot-platform-manager/src/main/java/com/platform/api/request/XpPrintImageReqVo.java

@@ -0,0 +1,34 @@
+package com.platform.api.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+/**
+ * 芯烨云图片打印请求参数
+ *
+ * @author assistant
+ */
+@Data
+public class XpPrintImageReqVo {
+
+    /**
+     * 打印机终端编号(sn)
+     */
+    @NotBlank(message = "打印机终端编号不能为空")
+    private String sn;
+
+    /**
+     * 图片访问地址(http/https)
+     */
+    @NotBlank(message = "图片地址不能为空")
+    @Pattern(regexp = "^(http|https)://.*$", message = "图片地址必须为http或https")
+    private String imageUrl;
+
+    /**
+     * 打印份数,默认1份
+     */
+    @NotNull(message = "打印份数不能为空")
+    private Integer copies = 1;
+}

+ 226 - 0
iot-platform-manager/src/main/java/com/platform/api/request/XpPrintReceiptReqVo.java

@@ -0,0 +1,226 @@
+package com.platform.api.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import jakarta.validation.Valid;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 芯烨云小票订单打印请求参数
+ *
+ * @author cxf
+ */
+@Data
+@Schema(name = "XpPrintReceiptReqVo", description = "芯烨云小票订单打印请求参数")
+public class XpPrintReceiptReqVo implements Serializable {
+
+    /**
+     * 打印机编号
+     */
+    @NotBlank(message = "sn不能为空")
+    @Schema(description = "打印机编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "XP420B123")
+    private String sn;
+
+    /**
+     * 打印内容对象(后端按模板拼接为XML字符串)
+     */
+    @Valid
+    @NotNull(message = "content不能为空")
+    @Schema(description = "小票内容对象,后端按模板拼接为XML", requiredMode = Schema.RequiredMode.REQUIRED)
+    private Content content;
+
+    /**
+     * 是否透传
+     */
+    @NotNull(message = "direct不能为空")
+    @Schema(description = "是否透传,小批量建议true", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
+    private Boolean direct;
+
+    /**
+     * 打印份数,默认1
+     */
+    @Min(value = 1, message = "copies必须大于0")
+    @Schema(description = "打印份数,默认1", example = "1", defaultValue = "1")
+    private Integer copies = 1;
+
+    /**
+     * 打印模式:0/1
+     */
+    @Min(value = 0, message = "mode只能是0或1")
+    @Max(value = 1, message = "mode只能是0或1")
+    @Schema(description = "打印模式:0在线校验;1不校验在线直接入队", allowableValues = {"0", "1"}, example = "0")
+    private Integer mode;
+
+    /**
+     * 幂等因子,最大50字符
+     */
+    @Size(max = 50, message = "idempotent长度不能超过50")
+    @Schema(description = "幂等因子,5分钟内同值只处理一次,长度<=50", example = "receipt-20260507-0001")
+    private String idempotent;
+
+    /**
+     * 是否实时打印
+     */
+    @Schema(description = "是否实时打印,默认false", example = "false")
+    private Boolean realTime;
+
+    /**
+     * 是否支持原生指令
+     */
+    @Schema(description = "是否支持原生指令,默认false;true时content需为base64原生指令", example = "false")
+    private Boolean supportNativeInstruction;
+
+    /**
+     * 订单有效期,0<expiresIn<86400
+     */
+    @Min(value = 1, message = "expiresIn必须大于0")
+    @Max(value = 86399, message = "expiresIn必须小于86400")
+    @Schema(description = "订单有效期(秒),仅mode=1时生效,取值1~86399", example = "600")
+    private Integer expiresIn;
+
+    /**
+     * 小票模板内容对象
+     */
+    @Data
+    @Schema(name = "XpPrintReceiptContent", description = "小票模板内容对象")
+    public static class Content {
+        /**
+         * 公司名称
+         */
+        @NotBlank(message = "content.companyName不能为空")
+        @Schema(description = "公司名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "四川顺采建筑材料有限公司")
+        private String companyName;
+
+        /**
+         * 客户名称
+         */
+        @NotBlank(message = "content.customerName不能为空")
+        @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "峨眉山市朝宣矿业有限公司")
+        private String customerName;
+
+        /**
+         * 任务单号
+         */
+        @NotBlank(message = "content.taskNo不能为空")
+        @Schema(description = "任务单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "T910980632597012480")
+        private String taskNo;
+
+        /**
+         * 接单时间
+         */
+        @NotBlank(message = "content.acceptTime不能为空")
+        @Schema(description = "接单时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-07 19:51:48")
+        private String acceptTime;
+
+        /**
+         * 完成时间
+         */
+        @NotBlank(message = "content.finishTime不能为空")
+        @Schema(description = "完成时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-07 20:29:05")
+        private String finishTime;
+
+        /**
+         * 司磅员
+         */
+        @NotBlank(message = "content.weigher不能为空")
+        @Schema(description = "司磅员", requiredMode = Schema.RequiredMode.REQUIRED, example = "方丽")
+        private String weigher;
+
+        /**
+         * 司机姓名
+         */
+        @NotBlank(message = "content.driverName不能为空")
+        @Schema(description = "司机姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "胡俸元")
+        private String driverName;
+
+        /**
+         * 司机手机号
+         */
+        @NotBlank(message = "content.driverMobile不能为空")
+        @Schema(description = "司机手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13990674184")
+        private String driverMobile;
+
+        /**
+         * 司机身份证号
+         */
+        @NotBlank(message = "content.driverIdCard不能为空")
+        @Schema(description = "司机身份证号(可脱敏)", requiredMode = Schema.RequiredMode.REQUIRED, example = "51900****1718")
+        private String driverIdCard;
+
+        /**
+         * 物料名称
+         */
+        @NotBlank(message = "content.materialName不能为空")
+        @Schema(description = "物料名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "石灰石")
+        private String materialName;
+
+        /**
+         * 物料规格
+         */
+        @NotBlank(message = "content.materialSpec不能为空")
+        @Schema(description = "物料规格", requiredMode = Schema.RequiredMode.REQUIRED, example = "80mm以下")
+        private String materialSpec;
+
+        /**
+         * 任务重量
+         */
+        @NotBlank(message = "content.taskWeight不能为空")
+        @Schema(description = "任务重量", requiredMode = Schema.RequiredMode.REQUIRED, example = "27.3 吨")
+        private String taskWeight;
+
+        /**
+         * 重量汇总
+         */
+        @NotBlank(message = "content.weightSummary不能为空")
+        @Schema(description = "重量汇总(皮重/毛重/净重)", requiredMode = Schema.RequiredMode.REQUIRED, example = "12.54吨   毛重:36.83吨   净重:24.29吨")
+        private String weightSummary;
+
+        /**
+         * 车牌号
+         */
+        @NotBlank(message = "content.plateNo不能为空")
+        @Schema(description = "车牌号", requiredMode = Schema.RequiredMode.REQUIRED, example = "川LA8659")
+        private String plateNo;
+
+        /**
+         * 车辆轴数描述
+         */
+        @NotBlank(message = "content.vehicleAxleDesc不能为空")
+        @Schema(description = "车辆轴数描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "货车 四轴车")
+        private String vehicleAxleDesc;
+
+        /**
+         * 目的地
+         */
+        @NotBlank(message = "content.destination不能为空")
+        @Schema(description = "目的地", requiredMode = Schema.RequiredMode.REQUIRED, example = "四川省-乐山市-峨眉山市")
+        private String destination;
+
+        /**
+         * 打印时间
+         */
+        @NotBlank(message = "content.printTime不能为空")
+        @Schema(description = "打印时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-07 20:29:11")
+        private String printTime;
+
+        /**
+         * 打印次数描述
+         */
+        @NotBlank(message = "content.printTimesDesc不能为空")
+        @Schema(description = "打印次数描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "第 1 次打印")
+        private String printTimesDesc;
+
+        /**
+         * 页脚联次文案,例:客户联(1/2)
+         */
+        @NotBlank(message = "content.copyLabel不能为空")
+        @Schema(description = "页脚联次文案", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户联(1/2)")
+        private String copyLabel;
+    }
+}

+ 70 - 0
iot-platform-manager/src/main/java/com/platform/config/XpCloudProperties.java

@@ -0,0 +1,70 @@
+package com.platform.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 芯烨云打印SDK配置属性
+ *
+ * <p>配置前缀:xp.dev</p>
+ *
+ * @author assistant
+ */
+@Data
+@Slf4j
+@Component
+@ConfigurationProperties(prefix = "xp.dev")
+public class XpCloudProperties {
+
+    /**
+     * 开发者ID
+     */
+    private String user;
+
+    /**
+     * 开发者密钥
+     */
+    private String userKey;
+
+    /**
+     * 是否开启调试日志
+     */
+    private Boolean debug = Boolean.FALSE;
+
+    /**
+     * SDK接口域名
+     */
+    private String domain = "https://open-barcode.xpyun.net";
+
+    /**
+     * 启动时输出脱敏后的配置,便于排查环境问题
+     */
+    @PostConstruct
+    public void logConfigOnStartup() {
+        log.info("芯烨云SDK配置加载完成, domain={}, debug={}, user={}, userKey={}",
+                domain, debug, desensitize(user), desensitize(userKey));
+        if (StringUtils.isAnyBlank(user, userKey)) {
+            log.warn("检测到芯烨云配置未完整设置,请配置 XP_USER 与 XP_USER_KEY 环境变量");
+        }
+    }
+
+    /**
+     * 对敏感数据进行简单脱敏处理
+     *
+     * @param raw 原始字符串
+     * @return 脱敏后的字符串
+     */
+    private String desensitize(String raw) {
+        if (StringUtils.isBlank(raw)) {
+            return "";
+        }
+        if (raw.length() <= 6) {
+            return "***";
+        }
+        return raw.substring(0, 3) + "***" + raw.substring(raw.length() - 3);
+    }
+}

+ 181 - 0
iot-platform-manager/src/main/java/com/platform/service/XpCloudPrintService.java

@@ -0,0 +1,181 @@
+package com.platform.service;
+
+import com.alibaba.fastjson.JSON;
+import com.platform.api.request.XpPrintImageReqVo;
+import com.platform.api.request.XpPrintReceiptReqVo;
+import com.platform.config.XpCloudProperties;
+import com.platform.exception.IotException;
+import io.github.dv996coding.vo.ObjectRestResponse;
+import io.github.dv996coding.vo.PrintOrderRequest;
+import jakarta.annotation.Resource;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.UUID;
+
+/**
+ * 芯烨云打印服务
+ *
+ * @author assistant
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XpCloudPrintService {
+
+    private final XpCloudProperties xpCloudProperties;
+
+    /**
+     * 直接使用@Resource注入SDK服务
+     */
+    @Resource
+    private io.github.dv996coding.service.PrintService printService;
+
+    /**
+     * 调用SDK执行图片打印
+     *
+     * @param reqVo 业务请求参数
+     * @return 云端打印任务号
+     */
+    public String printImage(XpPrintImageReqVo reqVo) {
+        if (reqVo == null || StringUtils.isAnyBlank(reqVo.getSn(), reqVo.getImageUrl())) {
+            throw new IotException("打印参数不能为空");
+        }
+        if (reqVo.getCopies() == null || reqVo.getCopies() <= 0) {
+            throw new IotException("打印份数必须大于0");
+        }
+        try {
+            PrintOrderRequest request = new PrintOrderRequest(reqVo.getSn(), reqVo.getImageUrl());
+            request.setCopies(reqVo.getCopies());
+            request.setIdempotent(UUID.randomUUID().toString().replace("-", ""));
+            ObjectRestResponse<String> response = printService.printImage(request);
+            log.info("芯烨云图片打印完成, sn={}, code={}, msg={}, data={}",
+                    reqVo.getSn(), response.getCode(), response.getMsg(), response.getData());
+            if (response.getCode() == null || response.getCode() != 0) {
+                throw new IotException("调用芯烨云打印失败:" + response.getMsg());
+            }
+            return response.getData();
+        } catch (IotException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("调用芯烨云图片打印异常, sn={}", reqVo.getSn(), e);
+            throw new IotException("调用芯烨云图片打印异常:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 调用SDK执行小票订单打印
+     *
+     * @param reqVo 小票订单打印请求
+     * @return 云端打印任务号
+     */
+    public String printReceipt(XpPrintReceiptReqVo reqVo) {
+        log.info("芯烨云小票打印, 入参:{}", JSON.toJSONString(reqVo));
+        if (reqVo == null || reqVo.getContent() == null || StringUtils.isAnyBlank(reqVo.getSn())) {
+            throw new IotException("打印参数不能为空");
+        }
+        if (reqVo.getExpiresIn() != null && (reqVo.getMode() == null || reqVo.getMode() != 1)) {
+            throw new IotException("设置expiresIn时,mode必须为1");
+        }
+        try {
+            String xmlContent = buildReceiptXml(reqVo.getContent());
+            PrintOrderRequest request = new PrintOrderRequest(reqVo.getSn(), xmlContent);
+            fillCommonParams(request);
+            request.setDirect(reqVo.getDirect());
+            request.setCopies(reqVo.getCopies());
+            request.setMode(reqVo.getMode());
+            if (StringUtils.isNotBlank(reqVo.getIdempotent())) {
+                request.setIdempotent(reqVo.getIdempotent());
+            }
+            if (reqVo.getRealTime() != null) {
+                request.setRealTime(reqVo.getRealTime());
+            }
+            if (reqVo.getSupportNativeInstruction() != null) {
+                request.setSupportNativeInstruction(reqVo.getSupportNativeInstruction());
+            }
+            if (reqVo.getExpiresIn() != null) {
+                request.setExpiresIn(reqVo.getExpiresIn());
+            }
+            log.info("芯烨云小票打印,请求参数:{}", JSON.toJSONString(request));
+            ObjectRestResponse<String> response = printService.print(request);
+            log.info("芯烨云小票打印完成, sn={}, code={}, msg={}, data={}",
+                    reqVo.getSn(), response.getCode(), response.getMsg(), response.getData());
+            if (response.getCode() == null || response.getCode() != 0) {
+                throw new IotException("调用芯烨云小票打印失败:" + response.getMsg());
+            }
+            return response.getData();
+        } catch (Exception e) {
+            log.error("调用芯烨云小票打印异常, sn={}", reqVo.getSn(), e);
+            throw new IotException("调用芯烨云小票打印异常:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 按前端对象拼接小票XML内容
+     */
+    private String buildReceiptXml(XpPrintReceiptReqVo.Content content) {
+        String line = "------------------------";
+        return "\n<CB>" + content.getCompanyName()
+                + "\n<CB>" + line
+                + "\n<L><N>客户名称:<BOLD>" + content.getCustomerName() + "</BOLD>"
+                + "\n<L><N>任务编号:<BOLD>" + content.getTaskNo() + "</BOLD>"
+                + "\n<L><N>接单时间:" + content.getAcceptTime()
+                + "\n<L><N>完成时间:" + content.getFinishTime()
+                + "\n<L><N>计重人:<BOLD>" + content.getWeigher() + "</BOLD>"
+                + "\n\n**************司机信息**************"
+                + "\n姓名:" + content.getDriverName()
+                + "\n手机号:" + content.getDriverMobile()
+                + "\n身份证:" + content.getDriverIdCard()
+                + "\n\n**************任务信息**************"
+                + "\n物料:" + content.getMaterialName()
+                + "\n规格:" + content.getMaterialSpec()
+                + "\n任务量:" + content.getTaskWeight()
+                + "\n皮重:<BOLD>" + content.getWeightSummary() + "</BOLD>"
+                + "\n\n**************车辆信息**************"
+                + "\n车牌号:" + content.getPlateNo()
+                + "\n车辆轴数: " + content.getVehicleAxleDesc()
+                + "\n\n**************目的地**************"
+                + "\n目的地:" + content.getDestination()
+                + "\n\n**************打印信息**************"
+                + "\n<L><N>打印时间:" + content.getPrintTime()
+                + "\n<L><N>打印次数:" + content.getPrintTimesDesc()
+                + "\n\n<C><BOLD>" + content.getCopyLabel() + "</BOLD>";
+    }
+
+    /**
+     * 填充公共参数:user、timestamp、sign
+     */
+    private void fillCommonParams(PrintOrderRequest request) {
+        if (StringUtils.isAnyBlank(xpCloudProperties.getUser(), xpCloudProperties.getUserKey())) {
+            throw new IotException("芯烨配置不完整,请配置xp.dev.user和xp.dev.user-key");
+        }
+        String user = xpCloudProperties.getUser();
+        String timestamp = String.valueOf(Instant.now().getEpochSecond());
+        String sign = sha1Hex(user + xpCloudProperties.getUserKey() + timestamp);
+        request.setUser(user);
+        request.setTimestamp(timestamp);
+        request.setSign(sign);
+    }
+
+    /**
+     * 计算SHA1(小写16进制)
+     */
+    private String sha1Hex(String source) {
+        try {
+            MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
+            byte[] digestBytes = messageDigest.digest(source.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digestBytes.length * 2);
+            for (byte digestByte : digestBytes) {
+                hex.append(String.format("%02x", digestByte));
+            }
+            return hex.toString();
+        } catch (Exception e) {
+            throw new IotException("SHA1计算失败:" + e.getMessage());
+        }
+    }
+}

+ 2 - 0
iot-platform-manager/src/main/resources/bootstrap.yml

@@ -12,3 +12,5 @@ spring:
 logging:
   level:
     root: info
+  config: classpath:logback-opensdk.xml
+

+ 97 - 0
iot-platform-manager/src/main/resources/logback-opensdk.xml

@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration scan="true" scanPeriod="60 seconds">
+    <!-- 日志上下文名称,便于多服务统一检索。 -->
+    <contextName>sckw-ng-weighbridge</contextName>
+
+    <!-- 从 Spring 环境读取服务名;未读取到时使用默认应用名。 -->
+    <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="sckw-ng-weighbridge"/>
+
+    <!-- 支持通过 JVM 参数 -DLOG_PATH=/data/logs 覆盖日志根目录。 -->
+    <property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
+    <property name="APP_LOG_PATH" value="${LOG_PATH}/${APP_NAME}"/>
+
+    <!--
+        打印内容:
+        时间 | 级别 | 进程ID | 线程名 | traceId | logger类名:行号 | 业务日志内容 | 异常堆栈
+    -->
+    <property name="LOG_PATTERN"
+              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} [%thread] [%X{traceId:-}] %logger{36}:%line - %msg%n%ex"/>
+    <property name="CONSOLE_PATTERN"
+              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} [%thread] [%X{traceId:-}] %logger{36}:%line - %msg%n%ex"/>
+
+    <!-- 控制台日志:本地启动、容器标准输出采集使用。 -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>${CONSOLE_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 全量业务日志:INFO 及以上级别。 -->
+    <appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${APP_LOG_PATH}/application.log</file>
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${APP_LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>100MB</maxFileSize>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>10GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <!-- 错误日志:ERROR 级别单独归档,便于告警与排查。 -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${APP_LOG_PATH}/error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${APP_LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>50MB</maxFileSize>
+            <maxHistory>60</maxHistory>
+            <totalSizeCap>5GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <!-- OpenSDK 专用日志:记录芯烨云 OpenSDK 调用链路与返回结果。 -->
+    <appender name="OPENSDK_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${APP_LOG_PATH}/opensdk.log</file>
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${APP_LOG_PATH}/opensdk.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>50MB</maxFileSize>
+            <maxHistory>60</maxHistory>
+            <totalSizeCap>5GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <!-- 降低常见框架噪音,保留业务问题排查所需级别。 -->
+    <logger name="org.springframework" level="INFO"/>
+    <logger name="org.apache.catalina" level="INFO"/>
+    <logger name="com.alibaba.nacos" level="WARN"/>
+    <logger name="com.zaxxer.hikari" level="INFO"/>
+
+    <!-- 项目业务日志。 -->
+    <logger name="com.platform" level="INFO"/>
+
+    <!-- OpenSDK 依赖包日志:同时输出到专用文件和根日志。 -->
+    <logger name="io.github.dv996coding" level="INFO" additivity="true">
+        <appender-ref ref="OPENSDK_FILE"/>
+    </logger>
+
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="APP_FILE"/>
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+</configuration>

+ 68 - 0
iot-platform-manager/src/test/java/com/platform/config/LogbackOpensdkConfigTest.java

@@ -0,0 +1,68 @@
+package com.platform.config;
+
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * logback-opensdk.xml 配置校验测试。
+ */
+class LogbackOpensdkConfigTest {
+
+    /**
+     * 校验日志配置文件可被 XML 解析,并包含应用日志、错误日志、OpenSDK 专用日志输出器。
+     *
+     * @throws Exception XML 解析异常时由 JUnit 直接标记失败
+     */
+    @Test
+    void shouldContainRequiredAppendersAndOpenSdkLogger() throws Exception {
+        try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("logback-opensdk.xml")) {
+            assertNotNull(inputStream, "logback-opensdk.xml 必须存在于 classpath");
+
+            Document document = DocumentBuilderFactory.newInstance()
+                    .newDocumentBuilder()
+                    .parse(inputStream);
+
+            Set<String> appenderNames = collectAttributeValues(document.getElementsByTagName("appender"), "name");
+            assertTrue(appenderNames.containsAll(Arrays.asList("CONSOLE", "APP_FILE", "ERROR_FILE", "OPENSDK_FILE")),
+                    "必须配置控制台、应用、错误、OpenSDK 日志输出器");
+
+            Set<String> loggerNames = collectAttributeValues(document.getElementsByTagName("logger"), "name");
+            assertTrue(loggerNames.contains("io.github.dv996coding"), "必须配置 OpenSDK 包路径专用日志");
+            assertTrue(loggerNames.contains("com.platform"), "必须配置业务包路径日志");
+
+            Set<String> patterns = IntStream.range(0, document.getElementsByTagName("pattern").getLength())
+                    .mapToObj(index -> document.getElementsByTagName("pattern").item(index).getTextContent())
+                    .collect(Collectors.toSet());
+            assertTrue(patterns.stream().noneMatch(pattern -> pattern.contains("%clr")),
+                    "自定义 Logback 配置不能使用未注册的 Spring Boot 彩色转换符 %clr");
+        }
+    }
+
+    /**
+     * 提取指定 XML 节点列表中的属性值。
+     *
+     * @param nodeList XML 节点列表
+     * @param attributeName 属性名称
+     * @return 非空属性值集合
+     */
+    private Set<String> collectAttributeValues(NodeList nodeList, String attributeName) {
+        return IntStream.range(0, nodeList.getLength())
+                .mapToObj(nodeList::item)
+                .map(node -> node.getAttributes().getNamedItem(attributeName))
+                .filter(attribute -> attribute != null && attribute.getNodeValue() != null)
+                .map(attribute -> attribute.getNodeValue().trim())
+                .filter(value -> !value.isEmpty())
+                .collect(Collectors.toSet());
+    }
+}

+ 6 - 0
pom.xml

@@ -162,6 +162,12 @@
 				<artifactId>fastjson</artifactId>
 				<version>2.0.38</version>
 			</dependency>
+
+			<dependency>
+				<groupId>io.github.dv996coding</groupId>
+				<artifactId>xpclouds-opensdk</artifactId>
+				<version>1.0.6</version>
+			</dependency>
 		</dependencies>
 	</dependencyManagement>