Bläddra i källkod

交易市场平台(供应商)

wwh 1 månad sedan
förälder
incheckning
580c5e2ee2

+ 58 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/controller/ScreenLoginController.java

@@ -0,0 +1,58 @@
1
+package com.ruoyi.web.modules.screen.controller;
2
+
3
+import org.springframework.beans.factory.annotation.Autowired;
4
+import org.springframework.web.bind.annotation.GetMapping;
5
+import org.springframework.web.bind.annotation.PostMapping;
6
+import org.springframework.web.bind.annotation.RequestBody;
7
+import org.springframework.web.bind.annotation.RequestHeader;
8
+import org.springframework.web.bind.annotation.RequestMapping;
9
+import org.springframework.web.bind.annotation.RestController;
10
+import com.ruoyi.common.annotation.Anonymous;
11
+import com.ruoyi.common.constant.Constants;
12
+import com.ruoyi.common.core.domain.AjaxResult;
13
+import com.ruoyi.common.utils.StringUtils;
14
+import com.ruoyi.web.modules.screen.domain.dto.ScreenLoginBody;
15
+import com.ruoyi.web.modules.screen.support.ScreenLoginService;
16
+
17
+/**
18
+ * 大屏免密登录(RSA 加密 UUID 换若依 Token,供 ruoyi-screen 自动登录)
19
+ */
20
+@Anonymous
21
+@RestController
22
+@RequestMapping("/bigScreen")
23
+public class ScreenLoginController
24
+{
25
+    public static final String HEADER_SCREEN_TOKEN = "X-Screen-Token";
26
+
27
+    @Autowired
28
+    private ScreenLoginService screenLoginService;
29
+
30
+    /**
31
+     * 获取 RSA 公钥,前端生成 UUID 后使用 JSEncrypt 加密再调用 {@link #login}。
32
+     */
33
+    @GetMapping("/publicKey")
34
+    public AjaxResult rsaPublicKey()
35
+    {
36
+        return AjaxResult.success(screenLoginService.getRsaPublicKey());
37
+    }
38
+
39
+    /**
40
+     * 免密登录:优先读取请求头 {@value #HEADER_SCREEN_TOKEN},其次 Body.token。
41
+     * 成功响应与 {@code POST /login} 一致,含 {@code token} 字段。
42
+     */
43
+    @PostMapping("/login")
44
+    public AjaxResult login(
45
+            @RequestHeader(value = HEADER_SCREEN_TOKEN, required = false) String headerToken,
46
+            @RequestBody(required = false) ScreenLoginBody body)
47
+    {
48
+        String encryptedToken = headerToken;
49
+        if (StringUtils.isEmpty(encryptedToken) && body != null)
50
+        {
51
+            encryptedToken = body.getToken();
52
+        }
53
+        String token = screenLoginService.login(encryptedToken);
54
+        AjaxResult ajax = AjaxResult.success();
55
+        ajax.put(Constants.TOKEN, token);
56
+        return ajax;
57
+    }
58
+}

+ 20 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/dto/ScreenLoginBody.java

@@ -0,0 +1,20 @@
1
+package com.ruoyi.web.modules.screen.domain.dto;
2
+
3
+/**
4
+ * 大屏免密登录请求体(加密 Token 亦可走请求头 X-Screen-Token)
5
+ */
6
+public class ScreenLoginBody
7
+{
8
+    /** RSA 加密后的 UUID(Base64) */
9
+    private String token;
10
+
11
+    public String getToken()
12
+    {
13
+        return token;
14
+    }
15
+
16
+    public void setToken(String token)
17
+    {
18
+        this.token = token;
19
+    }
20
+}

+ 28 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/vo/ScreenLoginRsaPublicKeyVo.java

@@ -0,0 +1,28 @@
1
+package com.ruoyi.web.modules.screen.domain.vo;
2
+
3
+/**
4
+ * 大屏免密登录 RSA 公钥(供前端 JSEncrypt 加密 UUID)
5
+ */
6
+public class ScreenLoginRsaPublicKeyVo
7
+{
8
+    private String publicKey;
9
+
10
+    public ScreenLoginRsaPublicKeyVo()
11
+    {
12
+    }
13
+
14
+    public ScreenLoginRsaPublicKeyVo(String publicKey)
15
+    {
16
+        this.publicKey = publicKey;
17
+    }
18
+
19
+    public String getPublicKey()
20
+    {
21
+        return publicKey;
22
+    }
23
+
24
+    public void setPublicKey(String publicKey)
25
+    {
26
+        this.publicKey = publicKey;
27
+    }
28
+}

+ 10 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/ScreenLoginConfig.java

@@ -0,0 +1,10 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
4
+import org.springframework.context.annotation.Configuration;
5
+
6
+@Configuration
7
+@EnableConfigurationProperties(ScreenLoginProperties.class)
8
+public class ScreenLoginConfig
9
+{
10
+}

+ 62 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/ScreenLoginProperties.java

@@ -0,0 +1,62 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import org.springframework.boot.context.properties.ConfigurationProperties;
4
+
5
+/**
6
+ * 大屏免密登录配置 {@code bigscreen.login.*}
7
+ */
8
+@ConfigurationProperties(prefix = "bigscreen.login")
9
+public class ScreenLoginProperties
10
+{
11
+    /** 是否启用大屏免密登录 */
12
+    private boolean enabled = false;
13
+
14
+    /** RSA 公钥(Base64,与 JSEncrypt 兼容) */
15
+    private String rsaPublicKey = "";
16
+
17
+    /** RSA 私钥(Base64,服务端解密用,勿暴露给前端) */
18
+    private String rsaPrivateKey = "";
19
+
20
+    /** 专用大屏账号(须预置 sys_user,建议仅 bigScreen:* 查询权限) */
21
+    private String username = "screen_viewer";
22
+
23
+    public boolean isEnabled()
24
+    {
25
+        return enabled;
26
+    }
27
+
28
+    public void setEnabled(boolean enabled)
29
+    {
30
+        this.enabled = enabled;
31
+    }
32
+
33
+    public String getRsaPublicKey()
34
+    {
35
+        return rsaPublicKey;
36
+    }
37
+
38
+    public void setRsaPublicKey(String rsaPublicKey)
39
+    {
40
+        this.rsaPublicKey = rsaPublicKey;
41
+    }
42
+
43
+    public String getRsaPrivateKey()
44
+    {
45
+        return rsaPrivateKey;
46
+    }
47
+
48
+    public void setRsaPrivateKey(String rsaPrivateKey)
49
+    {
50
+        this.rsaPrivateKey = rsaPrivateKey;
51
+    }
52
+
53
+    public String getUsername()
54
+    {
55
+        return username;
56
+    }
57
+
58
+    public void setUsername(String username)
59
+    {
60
+        this.username = username;
61
+    }
62
+}

+ 96 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/ScreenLoginRsaSupport.java

@@ -0,0 +1,96 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.security.KeyFactory;
5
+import java.security.PrivateKey;
6
+import java.security.PublicKey;
7
+import java.security.spec.PKCS8EncodedKeySpec;
8
+import java.security.spec.X509EncodedKeySpec;
9
+import java.util.Base64;
10
+import java.util.UUID;
11
+import javax.crypto.Cipher;
12
+import com.ruoyi.common.utils.StringUtils;
13
+
14
+/**
15
+ * 大屏免密登录 RSA(PKCS#1):前端 JSEncrypt 公钥加密 UUID,服务端私钥解密校验。
16
+ */
17
+public final class ScreenLoginRsaSupport
18
+{
19
+    private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding";
20
+
21
+    private ScreenLoginRsaSupport()
22
+    {
23
+    }
24
+
25
+    public static String encrypt(String plainText, String publicKeyBase64)
26
+    {
27
+        if (StringUtils.isEmpty(plainText) || StringUtils.isEmpty(publicKeyBase64))
28
+        {
29
+            throw new IllegalArgumentException("plain/publicKey required");
30
+        }
31
+        try
32
+        {
33
+            Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
34
+            cipher.init(Cipher.ENCRYPT_MODE, loadPublicKey(publicKeyBase64));
35
+            byte[] cipherBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
36
+            return Base64.getEncoder().encodeToString(cipherBytes);
37
+        }
38
+        catch (Exception ex)
39
+        {
40
+            throw new IllegalStateException("RSA 加密失败", ex);
41
+        }
42
+    }
43
+
44
+    public static String decrypt(String cipherTextBase64, String privateKeyBase64)
45
+    {
46
+        if (StringUtils.isEmpty(cipherTextBase64) || StringUtils.isEmpty(privateKeyBase64))
47
+        {
48
+            return null;
49
+        }
50
+        try
51
+        {
52
+            Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
53
+            cipher.init(Cipher.DECRYPT_MODE, loadPrivateKey(privateKeyBase64));
54
+            byte[] plainBytes = cipher.doFinal(Base64.getDecoder().decode(cipherTextBase64.trim()));
55
+            return new String(plainBytes, StandardCharsets.UTF_8);
56
+        }
57
+        catch (Exception ex)
58
+        {
59
+            return null;
60
+        }
61
+    }
62
+
63
+    static boolean isValidUuid(String text)
64
+    {
65
+        if (StringUtils.isEmpty(text))
66
+        {
67
+            return false;
68
+        }
69
+        try
70
+        {
71
+            UUID.fromString(text.trim());
72
+            return true;
73
+        }
74
+        catch (IllegalArgumentException ex)
75
+        {
76
+            return false;
77
+        }
78
+    }
79
+
80
+    static PublicKey loadPublicKey(String publicKeyBase64) throws Exception
81
+    {
82
+        byte[] keyBytes = Base64.getDecoder().decode(normalizeBase64(publicKeyBase64));
83
+        return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
84
+    }
85
+
86
+    static PrivateKey loadPrivateKey(String privateKeyBase64) throws Exception
87
+    {
88
+        byte[] keyBytes = Base64.getDecoder().decode(normalizeBase64(privateKeyBase64));
89
+        return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
90
+    }
91
+
92
+    private static String normalizeBase64(String key)
93
+    {
94
+        return key.replaceAll("\\s+", "");
95
+    }
96
+}

+ 116 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/ScreenLoginService.java

@@ -0,0 +1,116 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import org.springframework.beans.factory.annotation.Autowired;
4
+import org.springframework.stereotype.Service;
5
+import com.ruoyi.common.constant.Constants;
6
+import com.ruoyi.common.core.domain.entity.SysUser;
7
+import com.ruoyi.common.core.domain.model.LoginUser;
8
+import com.ruoyi.common.enums.UserStatus;
9
+import com.ruoyi.common.exception.ServiceException;
10
+import com.ruoyi.common.utils.DateUtils;
11
+import com.ruoyi.common.utils.MessageUtils;
12
+import com.ruoyi.common.utils.StringUtils;
13
+import com.ruoyi.common.utils.ip.IpUtils;
14
+import com.ruoyi.framework.manager.AsyncManager;
15
+import com.ruoyi.framework.manager.factory.AsyncFactory;
16
+import com.ruoyi.framework.web.service.TokenService;
17
+import com.ruoyi.framework.web.service.UserDetailsServiceImpl;
18
+import com.ruoyi.system.service.ISysUserService;
19
+import com.ruoyi.web.modules.screen.domain.vo.ScreenLoginRsaPublicKeyVo;
20
+
21
+/**
22
+ * 大屏免密登录:校验 RSA 加密 UUID 后为专用账号签发若依 JWT。
23
+ */
24
+@Service
25
+public class ScreenLoginService
26
+{
27
+    private static final String AUTH_FAIL_MSG = "大屏登录认证失败";
28
+
29
+    @Autowired
30
+    private ScreenLoginProperties properties;
31
+
32
+    @Autowired
33
+    private ISysUserService userService;
34
+
35
+    @Autowired
36
+    private UserDetailsServiceImpl userDetailsService;
37
+
38
+    @Autowired
39
+    private TokenService tokenService;
40
+
41
+    public ScreenLoginRsaPublicKeyVo getRsaPublicKey()
42
+    {
43
+        assertLoginReady();
44
+        return new ScreenLoginRsaPublicKeyVo(properties.getRsaPublicKey());
45
+    }
46
+
47
+    public String login(String encryptedToken)
48
+    {
49
+        assertLoginReady();
50
+        validateEncryptedToken(encryptedToken);
51
+        SysUser user = loadScreenUser();
52
+        LoginUser loginUser = (LoginUser) userDetailsService.createLoginUser(user);
53
+        userService.updateLoginInfo(user.getUserId(), IpUtils.getIpAddr(), DateUtils.getNowDate());
54
+        AsyncManager.me().execute(AsyncFactory.recordLogininfor(user.getUserName(), Constants.LOGIN_SUCCESS,
55
+                MessageUtils.message("user.login.success")));
56
+        return tokenService.createToken(loginUser);
57
+    }
58
+
59
+    private void assertLoginReady()
60
+    {
61
+        if (!properties.isEnabled())
62
+        {
63
+            throw new ServiceException("大屏免密登录未启用");
64
+        }
65
+        if (StringUtils.isEmpty(properties.getRsaPublicKey()) || StringUtils.isEmpty(properties.getRsaPrivateKey()))
66
+        {
67
+            throw new ServiceException("大屏免密登录未配置 RSA 密钥");
68
+        }
69
+    }
70
+
71
+    private void validateEncryptedToken(String encryptedToken)
72
+    {
73
+        if (StringUtils.isEmpty(encryptedToken))
74
+        {
75
+            recordAuthFail();
76
+            throw new ServiceException(AUTH_FAIL_MSG);
77
+        }
78
+        String plain = ScreenLoginRsaSupport.decrypt(encryptedToken, properties.getRsaPrivateKey());
79
+        if (!ScreenLoginRsaSupport.isValidUuid(plain))
80
+        {
81
+            recordAuthFail();
82
+            throw new ServiceException(AUTH_FAIL_MSG);
83
+        }
84
+    }
85
+
86
+    private void recordAuthFail()
87
+    {
88
+        AsyncManager.me().execute(AsyncFactory.recordLogininfor(properties.getUsername(), Constants.LOGIN_FAIL,
89
+                AUTH_FAIL_MSG));
90
+    }
91
+
92
+    private SysUser loadScreenUser()
93
+    {
94
+        String username = properties.getUsername();
95
+        if (StringUtils.isEmpty(username))
96
+        {
97
+            throw new ServiceException("大屏免密登录未配置专用账号");
98
+        }
99
+        SysUser user = userService.selectUserByUserName(username.trim());
100
+        if (StringUtils.isNull(user))
101
+        {
102
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL,
103
+                    MessageUtils.message("user.not.exists")));
104
+            throw new ServiceException("大屏专用账号不存在");
105
+        }
106
+        if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
107
+        {
108
+            throw new ServiceException(MessageUtils.message("user.password.delete"));
109
+        }
110
+        if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
111
+        {
112
+            throw new ServiceException(MessageUtils.message("user.blocked"));
113
+        }
114
+        return user;
115
+    }
116
+}

+ 6 - 0
baqing-admin/src/main/resources/application.yml

@@ -34,6 +34,12 @@ ruoyi:
34 34
 
35 35
 # 大屏交易销售(产地行情默认市场)
36 36
 bigscreen:
37
+  # 大屏免密登录(GET /bigScreen/publicKey + POST /bigScreen/login;默认关闭)
38
+  login:
39
+    enabled: true
40
+    rsa-public-key: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==
41
+    rsa-private-key: MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKNPuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gAkM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWowcSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99EcvDQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthhYhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3UP8iWi1Qw0Y=
42
+    username: screenviewer
37 43
   trade-sales:
38 44
     default-market-name: 巴青牦牛交易市场
39 45
   mall-stats:

+ 74 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/controller/ScreenLoginControllerApiTest.java

@@ -0,0 +1,74 @@
1
+package com.ruoyi.web.modules.screen.controller;
2
+
3
+import static org.mockito.ArgumentMatchers.eq;
4
+import static org.mockito.Mockito.when;
5
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
6
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
7
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
8
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
9
+
10
+import org.junit.jupiter.api.DisplayName;
11
+import org.junit.jupiter.api.Test;
12
+import org.junit.jupiter.api.extension.ExtendWith;
13
+import org.mockito.InjectMocks;
14
+import org.mockito.Mock;
15
+import org.mockito.junit.jupiter.MockitoExtension;
16
+import org.springframework.http.MediaType;
17
+import org.springframework.test.web.servlet.MockMvc;
18
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
19
+import com.ruoyi.web.modules.screen.domain.vo.ScreenLoginRsaPublicKeyVo;
20
+import com.ruoyi.web.modules.screen.support.ScreenLoginService;
21
+
22
+@ExtendWith(MockitoExtension.class)
23
+@DisplayName("ScreenLoginController")
24
+class ScreenLoginControllerApiTest
25
+{
26
+
27
+    @InjectMocks
28
+    private ScreenLoginController controller;
29
+
30
+    @Mock
31
+    private ScreenLoginService screenLoginService;
32
+
33
+    @Test
34
+    @DisplayName("获取 RSA 公钥")
35
+    void rsaPublicKey() throws Exception
36
+    {
37
+        when(screenLoginService.getRsaPublicKey())
38
+                .thenReturn(new ScreenLoginRsaPublicKeyVo("pub-key-1"));
39
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
40
+
41
+        mockMvc.perform(get("/bigScreen/publicKey"))
42
+                .andExpect(status().isOk())
43
+                .andExpect(jsonPath("$.code").value(200))
44
+                .andExpect(jsonPath("$.data.publicKey").value("pub-key-1"));
45
+    }
46
+
47
+    @Test
48
+    @DisplayName("请求头 Token 登录")
49
+    void loginByHeader() throws Exception
50
+    {
51
+        when(screenLoginService.login(eq("cipher-1"))).thenReturn("jwt-abc");
52
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
53
+
54
+        mockMvc.perform(post("/bigScreen/login")
55
+                .header(ScreenLoginController.HEADER_SCREEN_TOKEN, "cipher-1"))
56
+                .andExpect(status().isOk())
57
+                .andExpect(jsonPath("$.code").value(200))
58
+                .andExpect(jsonPath("$.token").value("jwt-abc"));
59
+    }
60
+
61
+    @Test
62
+    @DisplayName("Body Token 登录")
63
+    void loginByBody() throws Exception
64
+    {
65
+        when(screenLoginService.login(eq("cipher-2"))).thenReturn("jwt-def");
66
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
67
+
68
+        mockMvc.perform(post("/bigScreen/login")
69
+                .contentType(MediaType.APPLICATION_JSON)
70
+                .content("{\"token\":\"cipher-2\"}"))
71
+                .andExpect(status().isOk())
72
+                .andExpect(jsonPath("$.token").value("jwt-def"));
73
+    }
74
+}

+ 52 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/support/ScreenLoginRsaSupportTest.java

@@ -0,0 +1,52 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import static org.junit.jupiter.api.Assertions.assertEquals;
4
+import static org.junit.jupiter.api.Assertions.assertFalse;
5
+import static org.junit.jupiter.api.Assertions.assertNotNull;
6
+import static org.junit.jupiter.api.Assertions.assertTrue;
7
+
8
+import java.util.UUID;
9
+import org.junit.jupiter.api.DisplayName;
10
+import org.junit.jupiter.api.Test;
11
+
12
+@DisplayName("ScreenLoginRsaSupport")
13
+class ScreenLoginRsaSupportTest
14
+{
15
+    private static final String PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH"
16
+            + "nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==";
17
+    private static final String PRIVATE_KEY = "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY"
18
+            + "7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN"
19
+            + "PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA"
20
+            + "kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow"
21
+            + "cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv"
22
+            + "DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh"
23
+            + "YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3"
24
+            + "UP8iWi1Qw0Y=";
25
+
26
+    @Test
27
+    @DisplayName("加解密往返")
28
+    void encryptDecrypt_roundTrip()
29
+    {
30
+        String uuid = UUID.randomUUID().toString();
31
+        String cipher = ScreenLoginRsaSupport.encrypt(uuid, PUBLIC_KEY);
32
+        assertNotNull(cipher);
33
+        assertEquals(uuid, ScreenLoginRsaSupport.decrypt(cipher, PRIVATE_KEY));
34
+    }
35
+
36
+    @Test
37
+    @DisplayName("UUID 格式校验")
38
+    void isValidUuid()
39
+    {
40
+        assertTrue(ScreenLoginRsaSupport.isValidUuid("550e8400-e29b-41d4-a716-446655440000"));
41
+        assertFalse(ScreenLoginRsaSupport.isValidUuid("not-a-uuid"));
42
+        assertFalse(ScreenLoginRsaSupport.isValidUuid(null));
43
+    }
44
+
45
+    @Test
46
+    @DisplayName("解密后非 UUID")
47
+    void decrypt_notUuid()
48
+    {
49
+        String cipher = ScreenLoginRsaSupport.encrypt("hello", PUBLIC_KEY);
50
+        assertFalse(ScreenLoginRsaSupport.isValidUuid(ScreenLoginRsaSupport.decrypt(cipher, PRIVATE_KEY)));
51
+    }
52
+}

+ 47 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/support/ScreenLoginServiceTest.java

@@ -0,0 +1,47 @@
1
+package com.ruoyi.web.modules.screen.support;
2
+
3
+import static org.junit.jupiter.api.Assertions.assertEquals;
4
+import static org.junit.jupiter.api.Assertions.assertThrows;
5
+import static org.mockito.Mockito.when;
6
+
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+import com.ruoyi.common.exception.ServiceException;
14
+import com.ruoyi.web.modules.screen.domain.vo.ScreenLoginRsaPublicKeyVo;
15
+
16
+@ExtendWith(MockitoExtension.class)
17
+@DisplayName("ScreenLoginService")
18
+class ScreenLoginServiceTest
19
+{
20
+    private static final String PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH"
21
+            + "nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==";
22
+
23
+    @InjectMocks
24
+    private ScreenLoginService screenLoginService;
25
+
26
+    @Mock
27
+    private ScreenLoginProperties properties;
28
+
29
+    @Test
30
+    @DisplayName("未启用")
31
+    void disabled()
32
+    {
33
+        when(properties.isEnabled()).thenReturn(false);
34
+        assertThrows(ServiceException.class, () -> screenLoginService.login("any"));
35
+    }
36
+
37
+    @Test
38
+    @DisplayName("获取 RSA 公钥")
39
+    void getRsaPublicKey()
40
+    {
41
+        when(properties.isEnabled()).thenReturn(true);
42
+        when(properties.getRsaPublicKey()).thenReturn(PUBLIC_KEY);
43
+        when(properties.getRsaPrivateKey()).thenReturn("private-key");
44
+        ScreenLoginRsaPublicKeyVo vo = screenLoginService.getRsaPublicKey();
45
+        assertEquals(PUBLIC_KEY, vo.getPublicKey());
46
+    }
47
+}

+ 194 - 0
doc/大屏/免密登录/大屏免密登录技术方案.md

@@ -0,0 +1,194 @@
1
+# 大屏 — 免密登录 — 技术方案
2
+
3
+> 供 `ruoyi-screen` 等大屏前端**自动登录**:无需账号密码与验证码,通过 RSA 加密 UUID 换取若依 JWT,再访问各 `bigScreen:*` 看板接口。
4
+
5
+---
6
+
7
+## 1. 技术架构
8
+
9
+| 项 | 说明 |
10
+| --- | --- |
11
+| **后端** | RuoYi **v3.9.2**(springboot2):JDK 8、Spring MVC |
12
+| **代码包** | `com.ruoyi.web.modules.screen`(`ScreenLoginController` / `ScreenLoginService` / `ScreenLoginRsaSupport` / `ScreenLoginProperties`) |
13
+| **鉴权** | 登录接口 `@Anonymous` 免鉴权;登录成功后与普通若依接口一致,请求头 `Authorization: Bearer <token>` |
14
+| **专用账号** | `sys_user` 预置用户(默认 `screen_viewer`),建议仅授予 `bigScreen:*:query` |
15
+| **加密** | `RSA/ECB/PKCS1Padding`,与若依后台 `jsencrypt` 密钥格式兼容 |
16
+| **客户端 IP** | **不校验**白名单/黑名单;仅写入登录日志 |
17
+
18
+**流程**
19
+
20
+```mermaid
21
+sequenceDiagram
22
+    participant FE as ruoyi-screen
23
+    participant API as baqing-admin
24
+    FE->>API: GET /bigScreen/publicKey
25
+    API-->>FE: data.publicKey
26
+    FE->>FE: 生成 UUID,JSEncrypt 公钥加密
27
+    FE->>API: POST /bigScreen/login(X-Screen-Token 或 body.token)
28
+    API->>API: 私钥解密,校验 UUID 格式
29
+    API-->>FE: token(JWT)
30
+    FE->>API: GET /bigScreen/home/dashboard(Authorization)
31
+```
32
+
33
+---
34
+
35
+## 2. 配置
36
+
37
+### 2.1 `application.yml`
38
+
39
+```yaml
40
+bigscreen:
41
+  login:
42
+    enabled: false          # 生产按需 true
43
+    rsa-public-key: ""      # Base64 公钥(可含换行,服务端会去除空白)
44
+    rsa-private-key: ""     # Base64 私钥(仅服务端,勿下发前端)
45
+    username: screen_viewer # 专用 sys_user 登录名
46
+```
47
+
48
+| 项 | 说明 |
49
+| --- | --- |
50
+| `enabled` | `false` 时公钥接口与登录接口均返回「大屏免密登录未启用」 |
51
+| `rsa-public-key` / `rsa-private-key` | 须成对;开发环境可与 `ruoyi-ui/src/utils/jsencrypt.js` 中示例密钥一致 |
52
+| `username` | 不存在、已删除或停用时登录失败 |
53
+
54
+**密钥生成**:可使用 [在线 RSA 密钥对工具](http://web.chacuo.net/netrsakeypair)(JSEncrypt 格式);生产建议 **2048 位**独立密钥对。
55
+
56
+---
57
+
58
+## 3. 接口
59
+
60
+**Base Path**:`/bigScreen`(含 `server.servlet.context-path`、网关前缀须补齐)。
61
+
62
+| 序号 | 说明 | 方法 | 路径 | 鉴权 |
63
+| --- | --- | --- | --- | --- |
64
+| 3.1 | 获取 RSA 公钥 | GET | `/bigScreen/publicKey` | 无(`@Anonymous`) |
65
+| 3.2 | 免密登录换 Token | POST | `/bigScreen/login` | 无(`@Anonymous`) |
66
+
67
+### 3.1 获取 RSA 公钥
68
+
69
+**请求**:无 Body。
70
+
71
+**成功响应**(`AjaxResult`):
72
+
73
+```json
74
+{
75
+  "code": 200,
76
+  "msg": "操作成功",
77
+  "data": {
78
+    "publicKey": "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH..."
79
+  }
80
+}
81
+```
82
+
83
+| 字段 | 类型 | 说明 |
84
+| --- | --- | --- |
85
+| `data.publicKey` | string | Base64 RSA 公钥,供 JSEncrypt `setPublicKey` |
86
+
87
+**失败**:未启用或未配置密钥时 `ServiceException`,统一错误提示。
88
+
89
+### 3.2 免密登录
90
+
91
+**认证载荷**(二选一,优先请求头):
92
+
93
+| 方式 | 说明 |
94
+| --- | --- |
95
+| 请求头 | `X-Screen-Token: <RSA 密文 Base64>` |
96
+| Body | `{ "token": "<RSA 密文 Base64>" }` |
97
+
98
+**前端步骤**:
99
+
100
+1. 调用 **§3.1** 获取 `publicKey`
101
+2. 生成标准 UUID(如 `crypto.randomUUID()`)
102
+3. 使用 JSEncrypt 以公钥加密 UUID 字符串(明文 36 字符)
103
+4. 将 JSEncrypt 返回的 Base64 密文作为 `token` 提交
104
+
105
+**成功响应**(与 `POST /login` 一致):
106
+
107
+```json
108
+{
109
+  "code": 200,
110
+  "msg": "操作成功",
111
+  "token": "eyJhbGciOiJIUzI1NiJ9..."
112
+}
113
+```
114
+
115
+后续看板请求:`Authorization: Bearer <token>`。
116
+
117
+**失败场景**
118
+
119
+| 场景 | 提示(示例) |
120
+| --- | --- |
121
+| 未启用 | 大屏免密登录未启用 |
122
+| 未配置密钥 | 大屏免密登录未配置 RSA 密钥 |
123
+| 密文为空 / 解密失败 / 明文非 UUID | 大屏登录认证失败 |
124
+| 专用账号不存在 | 大屏专用账号不存在 |
125
+| 账号删除 / 停用 | 若依标准 `user.password.delete` / `user.blocked` |
126
+
127
+---
128
+
129
+## 4. 实现要点
130
+
131
+| 类 | 职责 |
132
+| --- | --- |
133
+| `ScreenLoginController` | 路由、`X-Screen-Token` / Body 取值 |
134
+| `ScreenLoginService` | 启用与密钥校验、解密验 UUID、加载专用用户、签发 JWT |
135
+| `ScreenLoginRsaSupport` | RSA 加解密、`UUID.fromString` 格式校验 |
136
+| `ScreenLoginProperties` | 绑定 `bigscreen.login.*` |
137
+| `ScreenLoginConfig` | `@EnableConfigurationProperties` |
138
+
139
+**UUID 校验**:解密明文经 `UUID.fromString` 解析,须为标准 8-4-4-4-12 十六进制格式(大小写均可)。
140
+
141
+**安全说明**:
142
+
143
+- 每次登录应生成**新 UUID** 再加密,避免重放固定密文(服务端当前仅校验格式,不存 nonce)。
144
+- 私钥仅存服务端配置,禁止写入前端或版本库(生产用环境变量/密钥管理)。
145
+- 大屏看板接口仍依赖 JWT + `bigScreen:*:query` 权限,与免密登录入口分离。
146
+
147
+---
148
+
149
+## 5. 前端示例(JSEncrypt)
150
+
151
+```javascript
152
+import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
153
+
154
+async function screenAutoLogin(apiBase) {
155
+  const keyRes = await fetch(`${apiBase}/bigScreen/publicKey`).then(r => r.json())
156
+  const uuid = crypto.randomUUID()
157
+  const encryptor = new JSEncrypt()
158
+  encryptor.setPublicKey(keyRes.data.publicKey)
159
+  const cipher = encryptor.encrypt(uuid)
160
+  if (!cipher) throw new Error('RSA encrypt failed')
161
+
162
+  const loginRes = await fetch(`${apiBase}/bigScreen/login`, {
163
+    method: 'POST',
164
+    headers: { 'Content-Type': 'application/json' },
165
+    body: JSON.stringify({ token: cipher })
166
+  }).then(r => r.json())
167
+
168
+  return loginRes.token
169
+}
170
+```
171
+
172
+---
173
+
174
+## 6. 交付清单
175
+
176
+- [x] `ScreenLoginController`:`GET /bigScreen/publicKey`、`POST /bigScreen/login`
177
+- [x] `ScreenLoginService` + `ScreenLoginRsaSupport` + `ScreenLoginProperties`
178
+- [x] `application.yml` → `bigscreen.login.*`
179
+- [x] 单元测试:`ScreenLoginRsaSupportTest`、`ScreenLoginServiceTest`、`ScreenLoginControllerApiTest`
180
+- [ ] `ruoyi-screen` 对接自动登录(按本方案调用公钥 + 登录接口)
181
+
182
+---
183
+
184
+## 7. 与各看板模块关系
185
+
186
+| 模块 | 看板 Base Path | 登录后权限示例 |
187
+| --- | --- | --- |
188
+| 首页 | `/bigScreen/home` | `bigScreen:home:query` |
189
+| 交易销售 | `/bigScreen/tradeSales` | `bigScreen:tradeSales:query` |
190
+| 疫病风险 | `/bigScreen/epidemicRisk` | `bigScreen:epidemicRisk:query` |
191
+| 畜牧资源 | `/bigScreen/livestockResource` | `bigScreen:livestockResource:query` |
192
+| 共同富裕 | `/bigScreen/commonProsperity` | `bigScreen:commonProsperity:query` |
193
+
194
+免密登录**不替代**上述权限校验;专用账号须在若依中配置对应菜单权限。

+ 67 - 0
doc/大屏/免密登录/大屏免密登录测试用例.md

@@ -0,0 +1,67 @@
1
+# 大屏 — 免密登录 — 测试用例
2
+
3
+> **接口 Base Path**:`/bigScreen`(含 `context-path`、网关前缀须补齐)。登录相关接口 **免鉴权**;登录成功后看板接口鉴权同若依 `Authorization` Token。
4
+
5
+---
6
+
7
+## 1. 测试范围
8
+
9
+| 类型 | 覆盖 |
10
+| --- | --- |
11
+| 单元测试 | RSA 加解密往返、UUID 格式校验、Service 启用/公钥、Controller 路由 |
12
+| 接口测试 | 公钥获取、Header/Body 登录、未启用、错误密文 |
13
+| UI 测试 | `ruoyi-screen` 自动登录(待前端对接) |
14
+
15
+**代码引用**:`ScreenLoginRsaSupportTest`、`ScreenLoginServiceTest`、`ScreenLoginControllerApiTest`。
16
+
17
+---
18
+
19
+## 2. 单元测试
20
+
21
+| 编号 | 模块 | 场景 | 工具 | 前置 | 步骤 | 预期 |
22
+| --- | --- | --- | --- | --- | --- | --- |
23
+| DP-ML-UT-001 | RSA | 加解密往返 | JUnit5 | 配置密钥对 | `encrypt(uuid)` → `decrypt` | 明文等于原 UUID |
24
+| DP-ML-UT-002 | RSA | UUID 合法 | JUnit5 | — | `isValidUuid(标准 UUID)` | true |
25
+| DP-ML-UT-003 | RSA | UUID 非法 | JUnit5 | — | `isValidUuid("hello")` / null | false |
26
+| DP-ML-UT-004 | RSA | 解密非 UUID 明文 | JUnit5 | 加密 `"hello"` | 解密后 `isValidUuid` | false |
27
+| DP-ML-UT-005 | Service | 未启用 | JUnit5 | `enabled=false` | `login(any)` | `ServiceException` |
28
+| DP-ML-UT-006 | Service | 获取公钥 | JUnit5 | `enabled=true` 且密钥已配 | `getRsaPublicKey()` | 返回配置公钥 |
29
+
30
+---
31
+
32
+## 3. 接口测试
33
+
34
+| 编号 | 场景 | 工具 | 前置 | 请求 | 预期 |
35
+| --- | --- | --- | --- | --- | --- |
36
+| DP-ML-API-001 | 获取公钥 | Postman/curl | `enabled=true` | `GET /bigScreen/publicKey` | `code=200`;`data.publicKey` 非空 |
37
+| DP-ML-API-002 | 未启用 | Postman | `enabled=false` | `GET /bigScreen/publicKey` | 失败;提示未启用 |
38
+| DP-ML-API-003 | Header 登录 | Postman | 已启用;有效密文 | `POST /bigScreen/login`,头 `X-Screen-Token` | `code=200`;含 `token` |
39
+| DP-ML-API-004 | Body 登录 | Postman | 已启用;有效密文 | `POST /bigScreen/login`,`{"token":"..."}` | `code=200`;含 `token` |
40
+| DP-ML-API-005 | 密文为空 | Postman | 已启用 | `POST /bigScreen/login` 无 token | 失败;大屏登录认证失败 |
41
+| DP-ML-API-006 | 密文非法 | Postman | 已启用 | `token=not-valid-cipher` | 失败;大屏登录认证失败 |
42
+| DP-ML-API-007 | 专用账号不存在 | Postman | `username` 指向不存在用户 | 有效密文登录 | 失败;大屏专用账号不存在 |
43
+| DP-ML-API-008 | 登录后访问看板 | Postman | 登录成功 | `GET /bigScreen/home/dashboard?statYear=2026`,带 `Authorization` | 有权限则 200;无权限 403 |
44
+
45
+**生成有效密文(联调)**:
46
+
47
+1. `GET /bigScreen/publicKey` 取 `publicKey`
48
+2. 前端 JSEncrypt 或脚本用公钥加密 `crypto.randomUUID()`
49
+3. 将密文填入 **DP-ML-API-003/004**
50
+
51
+---
52
+
53
+## 4. 安全与约束
54
+
55
+| 编号 | 场景 | 预期 |
56
+| --- | --- | --- |
57
+| DP-ML-SEC-001 | 私钥不出现在公钥接口响应 | `data` 仅含 `publicKey` |
58
+| DP-ML-SEC-002 | 登录接口不校验客户端 IP | 任意 IP 可调用(仍须有效密文) |
59
+| DP-ML-SEC-003 | 看板接口未带 JWT | `GET .../dashboard` 返回 401 |
60
+
61
+---
62
+
63
+## 5. 修订记录
64
+
65
+| 版本 | 说明 |
66
+| --- | --- |
67
+| 1.0 | 初版:RSA 公钥 + UUID 加密登录;路径 `GET /bigScreen/publicKey`、`POST /bigScreen/login` |

+ 1 - 1
doc/大屏/共同富裕/大屏共同富裕技术方案.md

@@ -100,7 +100,7 @@
100 100
 
101 101
 **Base Path**:`/bigScreen/commonProsperity`
102 102
 
103
-> 大屏若部署为**免登录展示**,在 `SecurityConfig` 对白名单路径放行,**不在** Controller 使用 `@Anonymous` 写死;生产环境默认仍走若依 Token + 上表权限
103
+> 大屏若部署为**免登录展示**,可调用 **《大屏免密登录技术方案》**(`doc/大屏/免密登录/大屏免密登录技术方案.md`)中 `POST /bigScreen/login` 换取 JWT;看板接口仍走 Token + 权限,**不在**各看板 Controller 使用 `@Anonymous` 写死
104 104
 
105 105
 | # | 说明 | Method | URI | 权限 |
106 106
 | --- | --- | --- | --- | --- |