diff --git a/src/main/java/com/lion/lionwebsite/Controller/PublicController.java b/src/main/java/com/lion/lionwebsite/Controller/PublicController.java index fd2b722..502b813 100644 --- a/src/main/java/com/lion/lionwebsite/Controller/PublicController.java +++ b/src/main/java/com/lion/lionwebsite/Controller/PublicController.java @@ -7,7 +7,9 @@ import com.lion.lionwebsite.Service.QueryService; import com.lion.lionwebsite.Service.RemoteService; import com.lion.lionwebsite.Service.SubService; import com.lion.lionwebsite.Service.UserService; +import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.Response; +import tools.jackson.databind.node.ObjectNode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -60,13 +62,16 @@ public class PublicController { public String validate(String AuthCode){ Response response = Response.generateResponse(); User user = publicService.getUserId(AuthCode); - String isAvailable = remoteService.isDead() ? "false": "true"; + // 用 ObjectMapper 组装内层 JSON:手工 String.format 拼用户名时, + // 名字里带引号或反斜杠会直接产出非法 JSON,前端 JSON.parse 随即失败。 + // 这里仍以「JSON 文本」形式放进 data(历史契约,前端按字符串再解析一次)。 + ObjectNode identity = CustomUtil.objectMapper.createObjectNode(); + identity.put("userId", user.getId()); + identity.put("username", user.getUsername()); + identity.put("isAvailable", !remoteService.isDead()); // 管理员标记随登录一起下发,前端据此决定是否显示下载人信息与筛选。 - String isAdmin = user.getId() == UserService.ADMIN_USER_ID ? "true" : "false"; - response.success(String.format("{\"userId\": %d, " + - "\"username\": \"%s\", " + - "\"isAvailable\": %s, " + - "\"isAdmin\": %s}", user.getId(), user.getUsername(), isAvailable, isAdmin)); + identity.put("isAdmin", user.getId() == UserService.ADMIN_USER_ID); + response.success(identity.toString()); return response.toJSONString(); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java b/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java index df6255b..3130885 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java @@ -5,6 +5,7 @@ import com.lion.lionwebsite.Domain.ImageKeyCache; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Select; @Mapper @@ -21,4 +22,13 @@ public interface ImageCacheMapper { @Select("select * from gidToKey where gid=#{gid}") GidToKey selectKeyByGid(String gid); + /** 已缓存的页 key 行数,用于判断索引是否完整(半截缓存需要重建)。 */ + @Select("select count(*) from ImageKeyCache where gid=#{gid}") + int countImageKeyCacheByGid(String gid); + + @Delete("delete from ImageKeyCache where gid=#{gid}") + void deleteImageKeyCacheByGid(String gid); + + @Delete("delete from gidToKey where gid=#{gid}") + void deleteGidToKey(String gid); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java index 7a65210..0ee524f 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java @@ -82,6 +82,10 @@ public interface SubMapper { @Select("select min(id) from sub_update_record where user=#{user}") Integer selectMinUpdateRecordId(String user); + /** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */ + @Update("update sub_update_record set location=#{location} where id=(select max(id) from sub_update_record where user=#{user})") + void updateLatestSubUpdateRecordLocation(@Param("user") String user, @Param("location") String location); + @Delete("delete from sub_update_record where id=#{id}") void deleteSubUpdateRecordById(int id); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java index 16dae8f..2d90c45 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java @@ -20,7 +20,14 @@ public interface UserMapper { @Select("select AuthCode from User") String[] selectAllAuthCode(); - @Select("select AuthCode from User") + /** + * 仅返回仍启用的授权码。 + * + *
此前这条 SQL 与 {@link #selectAllAuthCode()} 完全相同(都没有 isEnable 条件),
+ * 于是「停用用户」后刷新授权码集合依旧把该码放行,isEnable 形同虚设。
+ * isEnable 为空按 DDL 默认值 true 处理,避免历史行被锁死。
+ */
+ @Select("select AuthCode from User where isEnable is null or isEnable = 1")
String[] selectEnableAuthCode();
@Select("select * from User")
diff --git a/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java b/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java
index 24f7364..81660d5 100644
--- a/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java
+++ b/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java
@@ -7,11 +7,15 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
-import java.util.Calendar;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
@WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"})
@Slf4j
public class AdaptorFilter implements Filter {
+
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
+
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
@@ -22,14 +26,15 @@ public class AdaptorFilter implements Filter {
if(UserAgent == null)
return;
- String AuthCode = request.getParameter("AuthCode") == null ? "null" : request.getParameter("AuthCode");
+ // 这里是全站请求日志,AuthCode 是真正的凭据;只记录是否携带,绝不落明文。
+ boolean hasAuthCode = request.getParameter("AuthCode") != null;
String ServletPath = request.getServletPath();
String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr(): request.getHeader("X-Forwarded-For");
- Calendar calendar = Calendar.getInstance();
- String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND));
+ String now = LocalTime.now().format(TIME_FORMATTER);
//日志
- log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath, AuthCode, UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
+ log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath,
+ hasAuthCode ? "present" : "absent", UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
//如果是验证,则直接跳转
if(ServletPath.equals("/validate"))
@@ -37,7 +42,7 @@ public class AdaptorFilter implements Filter {
//如果不是,则根据UA判断是否跳转
else if ((UserAgent.contains("Android") || UserAgent.contains("iPhone")))
- if (ServletPath.equals("/personal/") && AuthCode.equals("alone"))
+ if (ServletPath.equals("/personal/") && "alone".equals(request.getParameter("AuthCode")))
response.sendRedirect("/mobile?AuthCode=alone");
else
response.sendRedirect("/mobile");
diff --git a/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java b/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java
index 9c321f3..073e838 100644
--- a/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java
+++ b/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java
@@ -16,7 +16,9 @@ public class TaskHandlerInterceptor implements HandlerInterceptor {
final UserMapper userMapper;
- String[] AuthCodes;
+ // 由 PostConstruct 加载后仅被读(发布式更新)与被测试线程读取;
+ // volatile 保证 refresh 后其它线程立即看到新数组,避免停用的授权码短暂仍可用。
+ volatile String[] AuthCodes;
@PostConstruct
void init() {
diff --git a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java
index 98b329d..bbece46 100644
--- a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java
+++ b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java
@@ -11,8 +11,6 @@ import com.lion.lionwebsite.Util.ImageFileCache;
import java.nio.file.Path;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.lion.lionwebsite.Util.Response;
-import tools.jackson.core.JacksonException;
-import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -186,7 +184,7 @@ public class GalleryManageService {
try {
gallery = GalleryUtil.parse(link, false, null);
if (gallery != null)
- response.success(new ObjectMapper().valueToTree(gallery).toString());
+ response.success(objectMapper.valueToTree(gallery).toString());
else
response.failure("查询失败");
} catch (Exception e) {
@@ -195,7 +193,7 @@ public class GalleryManageService {
}
else
- response.success(new ObjectMapper().valueToTree(gallery).toString());
+ response.success(objectMapper.valueToTree(gallery).toString());
return response.toJSONString();
}
@@ -234,30 +232,20 @@ public class GalleryManageService {
}
ArrayList 单线程执行器天然有序,投递一个空任务并等它跑完,即说明此前提交的
+ * 任务都已处理完,从而让「上报后落库」的断言保持确定性。
+ */
+ void awaitStatusApplied() {
+ try {
+ statusApplyExecutor.submit(() -> { }).get(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.debug("等待状态处理完成失败", e);
+ }
+ }
+
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean();
@@ -266,8 +295,8 @@ public class RemoteService {
if (monitor != null) monitor.interrupt();
failPendingRequests();
if (channel != null) channel.close();
+ statusApplyExecutor.shutdownNow();
subscriptionSyncExecutor.shutdownNow();
- downloadThread.shutdownNow();
networkGroup.shutdownGracefully();
eventLoopGroup.shutdownGracefully();
}
@@ -407,36 +436,15 @@ public class RemoteService {
//下载状态
if(msg instanceof DownloadStatusMessage dsm){
GalleryTask[] galleryTasks = dsm.getGalleryTasks();
- for (GalleryTask galleryTask : galleryTasks) {
- Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
- if (gallery == null) {
- log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}",
- galleryTask.getGid(), galleryTask.getName());
- continue;
+ // 落库与通知可能较慢(含 Telegram 推送),交给单线程顺序执行,
+ // 不再占用 Netty IO 线程;异常在此兜住,避免打进 IO 线程。
+ statusApplyExecutor.execute(() -> {
+ try {
+ applyReportedStatus(galleryTasks);
+ } catch (Exception e) {
+ log.warn("处理节点上报的任务状态失败", e);
}
- gallery.setProceeding(galleryTask.getProceeding());
-
- if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
- gallery.setName(galleryTask.getName());
-
- if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
- boolean justCompleted = !"下载完成".equals(gallery.getStatus());
- gallery.setStatus("下载完成");
- if (justCompleted)
- pushService.downloadComplete(gallery);
- }
- else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
- gallery.setStatus("压缩中");
- else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE)
- gallery.setStatus("等待压缩");
- else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING)
- gallery.setStatus("下载中");
-
- log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
- galleryMapper.updateGallery(gallery);
- completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus());
- }
- webSocketService.updateTaskProcessing(galleryTasks);
+ });
}
else if(msg instanceof ResponseMessage rsm) {
Promise 非 final:测试可替换为受控执行器,避免在单测里触发真实外呼。
+ */
+ Executor locationExecutor = newFixedLocationExecutor();
+
+ private static ExecutorService newFixedLocationExecutor() {
+ return Executors.newFixedThreadPool(2, r -> {
+ Thread thread = new Thread(r, "sub-location-resolver");
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
Response response = Response.generateResponse();
@@ -241,17 +260,39 @@ public class SubService {
}
private void recordUpdate(String user, String ip, String ua) {
- String location;
+ // 先落一条 unknown 记录并立即返回,归属地随后在后台补齐;
+ // 这样取订阅的延迟不再取决于第三方定位站点的响应时间。
+ subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), "unknown"));
+ if (subMapper.selectUpdateRecordCount(user) > 10)
+ subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
+ locationExecutor.execute(() -> enrichLocation(user, ip));
+ }
+
+ /** 后台补齐最近一条记录的归属地;任何失败都只记日志,不影响已落库的记录。 */
+ private void enrichLocation(String user, String ip) {
+ try {
+ String location = resolveLocation(ip);
+ if (location == null || location.isBlank() || "unknown".equals(location))
+ return;
+ subMapper.updateLatestSubUpdateRecordLocation(user, location);
+ } catch (Exception e) {
+ log.debug("补齐订阅更新记录归属地失败 user={}", user, e);
+ }
+ }
+
+ /**
+ * 查询 IP 归属地。第三方站点结构变化或网络异常都只降级为 unknown,
+ * 绝不让定位失败影响订阅分发本身。
+ */
+ static String resolveLocation(String ip) {
try {
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
- location = tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() : (tds.isEmpty() ? "unknown" : tds.get(1).text());
+ return tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim()
+ : (tds.isEmpty() ? "unknown" : tds.get(1).text());
} catch (Exception e) {
- location = "unknown";
+ return "unknown";
}
- subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), location));
- if (subMapper.selectUpdateRecordCount(user) > 10)
- subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
}
public String deleteSubBind(String user) {
diff --git a/src/main/java/com/lion/lionwebsite/Service/UserService.java b/src/main/java/com/lion/lionwebsite/Service/UserService.java
index bed1cde..f403b2a 100644
--- a/src/main/java/com/lion/lionwebsite/Service/UserService.java
+++ b/src/main/java/com/lion/lionwebsite/Service/UserService.java
@@ -7,13 +7,14 @@ import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.Response;
-import tools.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
+import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
+
@Service
@Slf4j
@RequiredArgsConstructor
@@ -127,7 +128,7 @@ public class UserService{
Response response = Response.generateResponse();
User[] users = userMapper.selectAllUser();
- response.success(new ObjectMapper().valueToTree(users).toString());
+ response.success(objectMapper.valueToTree(users).toString());
return response.toJSONString();
}
diff --git a/src/main/java/com/lion/lionwebsite/Util/FileDownload.java b/src/main/java/com/lion/lionwebsite/Util/FileDownload.java
index 7d61603..78b4ea1 100644
--- a/src/main/java/com/lion/lionwebsite/Util/FileDownload.java
+++ b/src/main/java/com/lion/lionwebsite/Util/FileDownload.java
@@ -59,17 +59,20 @@ public class FileDownload {
if ("HEAD".equalsIgnoreCase(request.getMethod()))
return;
input.seek(start);
- BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream());
- byte[] buffer = new byte[8192];
- while (remaining > 0) {
- int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
- if (count == -1)
- throw new EOFException("File changed during download");
- output.write(buffer, 0, count);
- remaining -= count;
+ // 关闭响应流:Tomcat 为大响应体创建的临时文件会在流关闭时删除,
+ // 不关闭会一直堆积到 Full GC。flushBuffer 只能把内容送出去,不能触发清理。
+ try (BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream())) {
+ byte[] buffer = new byte[8192];
+ while (remaining > 0) {
+ int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
+ if (count == -1)
+ throw new EOFException("File changed during download");
+ output.write(buffer, 0, count);
+ remaining -= count;
+ }
+ output.flush();
+ response.flushBuffer();
}
- output.flush();
- response.flushBuffer();
} catch (ClientAbortException e) {
// The client cancelled its download.
} catch (IOException e) {
diff --git a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java
index 1c48d44..34564e2 100644
--- a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java
+++ b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java
@@ -29,7 +29,6 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
@@ -45,7 +44,17 @@ public class GalleryUtil {
static String JSON = "json";
- static ConcurrentHashMap 历史实现只捕 {@code IndexOutOfBoundsException}:非数字段会抛
+ * {@code NumberFormatException},{@code link == null} 会抛 NPE,两者都会在
+ * 「无 @ControllerAdvice」的项目里穿透成 500。这里与
+ * {@code GalleryManageService.parseGidFromLink} 保持一致的宽松语义:
+ * 任何畸形输入都返回 null,由调用方转成业务失败。
+ */
public static Integer parseGid(String link){
+ if (link == null)
+ return null;
+ String[] parts = link.split("/g/");
+ if (parts.length < 2)
+ return null;
+ String[] segments = parts[1].split("/");
+ if (segments.length == 0)
+ return null;
try {
- return Integer.parseInt(link.split("/g/")[1].split("/")[0]);
- }catch (IndexOutOfBoundsException e){
+ return Integer.parseInt(segments[0]);
+ } catch (NumberFormatException e) {
return null;
}
}
diff --git a/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java
index 7df503a..3464ebe 100644
--- a/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java
+++ b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
+import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -84,6 +85,10 @@ class PublicControllerTest {
/**
* 返回体形如 {"result":"success","data":"{\"userId\": 7, ...}"}——
* data 是「JSON 文本的字符串」(历史契约,前端按字符串解析后再反序列化)。
+ *
+ * 内层 JSON 改由 ObjectMapper 生成(原先手工 String.format 拼接,用户名带引号
+ * 会产出非法 JSON),因此不再有冒号后的空格;这里断言的是字段与取值,
+ * 断言的字符串形式随之调整。
*/
@Test
void validateReturnsIdentityAndNodeAvailability() throws Exception {
@@ -92,11 +97,11 @@ class PublicControllerTest {
mockMvc.perform(post("/validate").param("AuthCode", "code"))
.andExpect(status().isOk())
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\": 7")))
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\": \\\"alice\\\"")))
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": true")))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\":7")))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\":\\\"alice\\\"")))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":true")))
// 普通用户 isAdmin=false,前端据此隐藏下载人信息。
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\": false")));
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":false")));
}
/** 管理员(id=3)必须带上 isAdmin=true,前端据此显示下载人信息与筛选。 */
@@ -107,7 +112,29 @@ class PublicControllerTest {
mockMvc.perform(post("/validate").param("AuthCode", "admin"))
.andExpect(status().isOk())
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\": true")));
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":true")));
+ }
+
+ /**
+ * 用户名含引号/反斜杠时,内层 data 仍必须是合法 JSON。
+ * 回归:原先手工 String.format 拼接会在这种输入下产出非法 JSON,
+ * 前端 JSON.parse(res.data.data) 会直接失败,用户卡在登录态。
+ */
+ @Test
+ void validateEscapesSpecialCharactersInUsername() throws Exception {
+ when(publicService.getUserId("code")).thenReturn(new User(7, "code", "a\"b\\c", null, true));
+ when(remoteService.isDead()).thenReturn(false);
+
+ var result = mockMvc.perform(post("/validate").param("AuthCode", "code"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ // 取出外层 data(是内层 JSON 的字符串形式),必须能被 JSON 解析回原名。
+ var outer = com.lion.lionwebsite.Util.CustomUtil.objectMapper
+ .readTree(result.getResponse().getContentAsString());
+ var inner = com.lion.lionwebsite.Util.CustomUtil.objectMapper
+ .readTree(outer.get("data").asText());
+ assertEquals("a\"b\\c", inner.get("username").asText(), "特殊字符必须原样保留且 JSON 合法");
}
/** 存储节点掉线时 isAvailable 必须为 false,前端据此提示。 */
@@ -118,7 +145,7 @@ class PublicControllerTest {
mockMvc.perform(post("/validate").param("AuthCode", "code"))
.andExpect(status().isOk())
- .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": false")));
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":false")));
}
@Test
diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java
index ad8321d..52b3b79 100644
--- a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java
+++ b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java
@@ -137,6 +137,79 @@ class GalleryManageServiceTest {
verify(imageCacheMapper, never()).insertGidToKey(any());
}
+ /**
+ * 回归:索引写入不是原子的。若 gidToKey 写入成功、逐页写 ImageKeyCache 时失败,
+ * 会留下「gidToKey 命中但页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。
+ * 现在任何异常都要把已写入的部分清掉再回业务失败。
+ */
+ @Test
+ void cacheImagesKeyRollsBackWhenPageInsertFails() throws Exception {
+ ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
+ when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null);
+ doThrow(new RuntimeException("db down")).when(imageCacheMapper).insertImageKeyCache(any());
+ GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
+ configurationMapper, users,
+ imageCacheMapper, remote, push);
+
+ String realMpvPage = "