yeg
2026-09-28 1111991a9c3675f53b64d16379727b2aa6149997
厂商库口令改为优先读本地凭据文件,并在启动时自检

原来是"口令只认环境变量与启动参数",于是部署时多了一个"要记得做"的动作,忘了之后
虽然会在实例行留下错误,但仓库里没有任何东西提示这个变量必须存在。用户要求:开发期用
IDE 起服务不想为此加 VM 参数,线上部署也不能靠人记得加。

- 口令取值顺序改为「本地凭据文件 → 同名环境变量 → 同名启动参数」:文件是进程工作目录下的
config/vendor-credentials.properties(不入库、不进 git),仓库只提交
config/vendor-credentials.properties.example 模板。三者都缺时装配失败,错误信息同时
点名文件路径与键名。账号与只读边界不变(仍是服务端只读账号 + 语句白名单)。
- 启动自检:启动恢复实例时装配失败不再只写日志,而是按发现失败的同一路径落到该实例行,
使"忘了放凭据"在启动后立刻是适配器列表里可见的一行,而不是靠人翻日志。
- 同步 ruyi-admin 侧测试:凭据取值顺序、空白值视为未配置、启动失败落到实例行。
1个文件已添加
6个文件已修改
166 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
config/vendor-credentials.properties.example 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -15,3 +15,6 @@
/logs/
/.mvn-review-repository/
/ruoyi-admin/logs/
# 本地凭据文件:只提交模板,真实口令文件不入库
/config/vendor-credentials.properties
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
@@ -196,13 +196,25 @@
            }
            catch (RuntimeException exception)
            {
                // 单行配置有问题(例如缺少口令环境变量)不得拦住后端启动:
                // 记下原因,由发现轮询按周期重试并把失败持久化到这一行。
                // 单行配置有问题(例如没放凭据文件、口令键名写错)不得拦住后端启动:
                // 既写日志,也按发现失败的同一路径落到这一行,使"忘了配"在启动后立刻是列表里可见的一行。
                log.warn("设备适配器实例装配失败 adapterCode={}:{}", adapter.getAdapterCode(), exception.getMessage());
                mapper.updateDiscoveryFailure(adapter.getAdapterId(), startupFailureCode(exception),
                        exception.getMessage());
            }
        }
    }
    /**
     * 装配失败写进实例行的错误码:适配器自己给出分类(例如凭据缺失、连接失败)时用它,
     * 否则记为配置问题——启动阶段的装配失败基本都是配置造成的。
     */
    private static String startupFailureCode(RuntimeException exception)
    {
        return exception instanceof DeviceAdapterException deviceFailure && !deviceFailure.failureCode().isBlank()
                ? deviceFailure.failureCode() : "INVALID_CONFIG";
    }
    /** 轮询达到周期的已启用适配器,并持久化失败状态后继续调度。 */
    @Override
    public void pollDueAdapters()
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
@@ -59,7 +59,7 @@
public final class NewareMysqlAdapter implements DeviceAdapter, TestDataObservationAdapter
{
    public static final String ADAPTER_TYPE = "NEWARE_MYSQL";
    /** 连接口令的默认环境变量名;也可由实例配置的 passwordEnv 指定。 */
    /** 连接口令的默认取值名:本地凭据文件的键、同名环境变量或同名启动参数,见 {@link NewareMysqlAdapterFactory#credential}。 */
    public static final String DEFAULT_PASSWORD_ENV = "BATTERY_VENDOR_DB_PASSWORD";
    /** 厂商实测写库周期;它同时是持久化周期,展示节奏不随之加快。 */
    public static final long SAMPLE_INTERVAL_MS = 60000L;
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java
@@ -1,8 +1,14 @@
package com.battery.device.neware;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.function.Function;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.DeviceAdapterException;
import com.battery.device.api.model.AdapterCapabilities;
@@ -12,8 +18,9 @@
/**
 * 按存储形态 {@code NEWARE_MYSQL} 构造直连厂商库的只读适配器实例。
 *
 * <p>连接口令只从环境变量或启动参数读取(变量名由实例配置的 {@code passwordEnv} 指定,
 * 缺省为 {@link NewareMysqlAdapter#DEFAULT_PASSWORD_ENV}),不进数据库也不进仓库。
 * <p>连接口令不进数据库、不进仓库,也不打包进 jar,取值顺序见 {@link #credential}:
 * 本地凭据文件 → 同名环境变量 → 同名 JVM 启动参数。三者都没有时装配失败,
 * 错误信息同时点名文件路径与变量名,使"忘了放口令"是可见的失败而不是沉默的缺失。
 * 账号固定为现场取得的服务端只读账号,全权限账号不进入配置。
 */
public final class NewareMysqlAdapterFactory implements DeviceAdapterFactory
@@ -25,6 +32,8 @@
    private static final String READ_ONLY_USER = "visitor";
    /** 现场已知的全权限账号;写进交付配置就等于把误写风险带进现场。 */
    private static final List<String> FULL_PRIVILEGE_USERS = List.of("btsserver", "root");
    /** 凭据文件相对进程工作目录定位,使开发(仓库根)与交付(应用目录)用同一个位置。 */
    static final Path CREDENTIALS_FILE = Path.of("config", "vendor-credentials.properties");
    @Override
    public String adapterType()
@@ -37,8 +46,10 @@
    {
        String database = option(config.sourceConfig(), DATABASE_OPTION, DEFAULT_DATABASE);
        String user = readOnlyUser(config.sourceConfig());
        String password = credential(option(config.sourceConfig(), PASSWORD_ENV_OPTION,
                NewareMysqlAdapter.DEFAULT_PASSWORD_ENV));
        String key = option(config.sourceConfig(), PASSWORD_ENV_OPTION,
                NewareMysqlAdapter.DEFAULT_PASSWORD_ENV);
        String password = credential(key, CREDENTIALS_FILE.toAbsolutePath(), System.getenv(),
                System::getProperty);
        SingleConnectionVendorDataSource source = new SingleConnectionVendorDataSource(
                jdbcUrl(config.host(), config.port(), database), user, password);
        return NewareMysqlAdapter.create(config, source);
@@ -58,16 +69,39 @@
        return user;
    }
    /** 只读账号口令;启动参数优先于环境变量,便于本机排查而不必改环境。 */
    static String credential(String variableName)
    /**
     * 按「本地凭据文件 → 环境变量 → 启动参数」取值;键名由实例配置的 {@code passwordEnv} 指定。
     * 文件优先,是为了让部署只做"复制模板填一行"这一件事。
     */
    static String credential(String key, Path credentialsFile, Map<String, String> environment,
            Function<String, String> startupParameters)
    {
        if (variableName == null || variableName.isBlank())
            throw new DeviceAdapterException("未配置厂商库口令的取值变量名");
        String value = System.getenv(variableName);
        if (value == null || value.isBlank()) value = System.getProperty(variableName);
        if (value == null || value.isBlank())
            throw new DeviceAdapterException("缺少厂商库连接口令:请设置环境变量 " + variableName);
        return value;
        if (key == null || key.isBlank()) throw new DeviceAdapterException("未配置厂商库口令的取值名");
        String fromFile = credentialFromFile(key, credentialsFile);
        if (fromFile != null) return fromFile;
        String fromEnvironment = environment == null ? null : environment.get(key);
        if (fromEnvironment != null && !fromEnvironment.isBlank()) return fromEnvironment.trim();
        String fromParameter = startupParameters == null ? null : startupParameters.apply(key);
        if (fromParameter != null && !fromParameter.isBlank()) return fromParameter.trim();
        throw new DeviceAdapterException("缺少厂商库连接口令:" + credentialsFile
                + " 中没有键 " + key + ",环境变量与启动参数 -D" + key + " 也都没有");
    }
    /** 凭据文件缺失不是错误:交付形态可能只用环境变量,所以这里只负责"读到了就返回"。 */
    private static String credentialFromFile(String key, Path credentialsFile)
    {
        if (credentialsFile == null || !Files.isRegularFile(credentialsFile)) return null;
        Properties properties = new Properties();
        try (InputStream input = Files.newInputStream(credentialsFile))
        {
            properties.load(input);
        }
        catch (IOException exception)
        {
            throw new DeviceAdapterException("厂商库凭据文件无法读取:" + credentialsFile, exception);
        }
        String value = properties.getProperty(key);
        return value == null || value.isBlank() ? null : value.trim();
    }
    /**
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
@@ -220,4 +220,64 @@
            assertTrue(failure.getMessage().contains(account));
        }
    }
    /**
     * 口令取值顺序:本地凭据文件 → 环境变量 → 启动参数;
     * 三者都没有时失败信息同时点名文件路径与键名,使"忘了放口令"是可见的失败。
     */
    @Test
    void credentialPrefersTheLocalFileThenEnvironmentThenStartupParameter() throws Exception
    {
        java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials");
        java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
        String key = "BATTERY_VENDOR_DB_PASSWORD";
        try
        {
            // 文件、环境变量、启动参数都在时取文件。
            java.nio.file.Files.writeString(file, key + "=from-file\n");
            assertEquals("from-file", NewareMysqlAdapterFactory.credential(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
            // 没有文件时退到环境变量。
            java.nio.file.Files.delete(file);
            assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
            // 前两者都没有时退到启动参数。
            assertEquals("from-parameter", NewareMysqlAdapterFactory.credential(key, file,
                    Map.of(), name -> "from-parameter"));
            // 三者都没有:失败信息里同时出现文件路径与键名。
            var failure = org.junit.jupiter.api.Assertions.assertThrows(
                    com.battery.device.api.DeviceAdapterException.class,
                    () -> NewareMysqlAdapterFactory.credential(key, file, Map.of(), name -> null));
            assertTrue(failure.getMessage().contains(file.toString()));
            assertTrue(failure.getMessage().contains(key));
        }
        finally
        {
            java.nio.file.Files.deleteIfExists(file);
            java.nio.file.Files.deleteIfExists(directory);
        }
    }
    /** 凭据文件里的空白值不算配置过:否则一个空模板会把失败推到连接阶段。 */
    @Test
    void blankCredentialInTheFileIsTreatedAsMissing() throws Exception
    {
        java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials-blank");
        java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
        String key = "BATTERY_VENDOR_DB_PASSWORD";
        try
        {
            java.nio.file.Files.writeString(file, key + "=\n");
            assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, Map.of(key, "from-env"),
                    name -> null));
        }
        finally
        {
            java.nio.file.Files.deleteIfExists(file);
            java.nio.file.Files.deleteIfExists(directory);
        }
    }
}
config/vendor-credentials.properties.example
New file
@@ -0,0 +1,17 @@
# 厂商库连接口令 · 模板
#
# 用法:把本文件复制成同目录下的 vendor-credentials.properties,填好口令即可。
#   开发期(IDE 起服务):<仓库根>/config/vendor-credentials.properties
#   交付后:<应用目录>/config/vendor-credentials.properties(进程工作目录必须是应用目录)
#
# 说明:
#   - vendor-credentials.properties 已被 .gitignore 忽略,不入库、不进数据库、不打包进 jar;
#     口令的取值顺序是「本文件 → 同名环境变量 → 同名 JVM 启动参数 -D…」,三种都没有时
#     装配会失败,错误信息会同时点名本文件的路径与该键名。
#   - 键名必须与适配器实例配置里的 passwordEnv 一致(默认 BATTERY_VENDOR_DB_PASSWORD)。
#   - 账号固定使用现场取得的服务端只读账号(visitor),不要在这里放全权限账号。
#
# 未放置本文件时后端仍会正常启动:该来源实例会被标记为离线,并把
# 「缺少厂商库连接口令」写进适配器列表的最近一次连接结果里。
BATTERY_VENDOR_DB_PASSWORD=
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java
@@ -21,6 +21,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
@@ -110,7 +111,7 @@
        verify(adapterMapper, never()).updateDeviceAdapterStatus(any(), any(), any());
    }
    /** 单行配置有问题不得拦住后端启动:记下原因,由发现轮询按周期重试并持久化失败。 */
    /** 单行配置有问题不得拦住后端启动:写日志并把失败落到该行,使"忘了放凭据"在启动后立刻可见。 */
    @Test
    void startupRestoreSurvivesOneBrokenAdapterRow()
    {
@@ -126,12 +127,15 @@
        vendor.setStatus("0");
        vendor.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}");
        when(adapterMapper.selectEnabledDeviceAdapters()).thenReturn(List.of(vendor));
        org.mockito.Mockito.doThrow(new DeviceAdapterException("缺少厂商库连接口令:请设置环境变量 X"))
        org.mockito.Mockito.doThrow(new DeviceAdapterException("缺少厂商库连接口令:config/vendor-credentials.properties 中没有键 X"))
                .when(registry).synchronize(any());
        service.synchronizeEnabledAdapters();
        verify(registry).synchronize(any());
        ArgumentCaptor<String> message = ArgumentCaptor.forClass(String.class);
        verify(adapterMapper).updateDiscoveryFailure(eq(9L), any(), message.capture());
        assertTrue(message.getValue().contains("缺少厂商库连接口令"));
    }
    @Test