Skip to content

Commit 45d529c

Browse files
authored
🎨 #3932 修复长时间运行时 Redis 命令中断导致 accessToken 刷新失败的问题
1 parent cae7d4f commit 45d529c

4 files changed

Lines changed: 308 additions & 7 deletions

File tree

weixin-java-common/src/main/java/me/chanjar/weixin/common/util/locks/RedisTemplateSimpleDistributedLock.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ public RedisTemplateSimpleDistributedLock( StringRedisTemplate redisTemplate, S
4242

4343
@Override
4444
public void lock() {
45+
boolean interrupted = false;
4546
while (!tryLock()) {
4647
try {
4748
Thread.sleep(1000);
4849
} catch (InterruptedException e) {
49-
// Ignore
50+
interrupted = true;
5051
}
5152
}
53+
if (interrupted) {
54+
Thread.currentThread().interrupt();
55+
}
5256
}
5357

5458
@Override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package me.chanjar.weixin.common.util.locks;
2+
3+
import org.mockito.Mockito;
4+
import org.springframework.data.redis.core.StringRedisTemplate;
5+
import org.springframework.data.redis.core.ValueOperations;
6+
import org.testng.Assert;
7+
import org.testng.annotations.BeforeMethod;
8+
import org.testng.annotations.Test;
9+
10+
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.atomic.AtomicBoolean;
12+
13+
/**
14+
* 测试 RedisTemplateSimpleDistributedLock 的线程中断处理行为
15+
*
16+
* @author GitHub Copilot
17+
*/
18+
public class RedisTemplateSimpleDistributedLockInterruptTest {
19+
20+
private StringRedisTemplate mockRedisTemplate;
21+
private ValueOperations<String, String> mockValueOps;
22+
private RedisTemplateSimpleDistributedLock lock;
23+
24+
@BeforeMethod
25+
@SuppressWarnings("unchecked")
26+
public void setUp() {
27+
mockRedisTemplate = Mockito.mock(StringRedisTemplate.class);
28+
mockValueOps = Mockito.mock(ValueOperations.class);
29+
Mockito.when(mockRedisTemplate.opsForValue()).thenReturn(mockValueOps);
30+
lock = new RedisTemplateSimpleDistributedLock(mockRedisTemplate, "test_interrupt_lock", 60000);
31+
}
32+
33+
/**
34+
* 测试 lock() 在 Thread.sleep 被中断时应恢复线程中断标志
35+
* <p>
36+
* 修复前:InterruptedException 被忽略(// Ignore),线程中断标志丢失
37+
* 修复后:调用 Thread.currentThread().interrupt() 恢复中断标志
38+
* </p>
39+
*/
40+
@Test(description = "lock() 方法在中断时应恢复线程中断标志")
41+
public void testLockRestoresInterruptedFlagAfterSleepInterruption() throws InterruptedException {
42+
AtomicBoolean interruptedFlagAfterLock = new AtomicBoolean(false);
43+
44+
// 第一次 setIfAbsent 返回 false(模拟锁被占用),第二次返回 true(模拟锁释放)
45+
Mockito.when(mockValueOps.setIfAbsent(Mockito.anyString(), Mockito.anyString(),
46+
Mockito.anyLong(), Mockito.any(TimeUnit.class)))
47+
.thenReturn(false)
48+
.thenReturn(true);
49+
// get() 返回不同的值,确保不走可重入路径
50+
Mockito.when(mockValueOps.get(Mockito.anyString())).thenReturn("other-value");
51+
52+
Thread testThread = new Thread(() -> {
53+
// 设置中断标志
54+
Thread.currentThread().interrupt();
55+
// 调用 lock(),第一次 tryLock 失败,sleep 会因中断标志立即抛出 InterruptedException
56+
lock.lock();
57+
interruptedFlagAfterLock.set(Thread.currentThread().isInterrupted());
58+
});
59+
60+
testThread.start();
61+
testThread.join(5000);
62+
63+
// 线程应该已经完成(不会永远阻塞)
64+
Assert.assertFalse(testThread.isAlive(), "线程应该已完成");
65+
// 关键验证:中断标志应被恢复(而非被忽略丢失)
66+
Assert.assertTrue(interruptedFlagAfterLock.get(), "lock()执行后线程中断标志应被恢复");
67+
}
68+
69+
/**
70+
* 测试 tryLock() 在 Redis 正常响应时的基本行为
71+
*/
72+
@Test(description = "tryLock() 成功获取锁时应返回 true")
73+
public void testTryLockSuccessfully() {
74+
Mockito.when(mockValueOps.setIfAbsent(Mockito.anyString(), Mockito.anyString(),
75+
Mockito.anyLong(), Mockito.any(TimeUnit.class)))
76+
.thenReturn(true);
77+
78+
boolean result = lock.tryLock();
79+
80+
Assert.assertTrue(result, "应成功获取锁");
81+
Assert.assertNotNull(lock.getLockSecretValue(), "锁值不应为null");
82+
}
83+
84+
/**
85+
* 测试 tryLock() 在锁已被其他线程持有时应返回 false
86+
*/
87+
@Test(description = "锁被占用时 tryLock() 应返回 false")
88+
public void testTryLockWhenLockHeld() {
89+
Mockito.when(mockValueOps.setIfAbsent(Mockito.anyString(), Mockito.anyString(),
90+
Mockito.anyLong(), Mockito.any(TimeUnit.class)))
91+
.thenReturn(false);
92+
Mockito.when(mockValueOps.get(Mockito.anyString())).thenReturn("other-lock-value");
93+
94+
boolean result = lock.tryLock();
95+
96+
Assert.assertFalse(result, "锁被占用时应返回false");
97+
}
98+
}

weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/AbstractWxCpInRedisConfigImpl.java

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package me.chanjar.weixin.cp.config.impl;
22

33
import lombok.NonNull;
4+
import lombok.extern.slf4j.Slf4j;
45
import me.chanjar.weixin.common.bean.WxAccessToken;
56
import me.chanjar.weixin.common.redis.WxRedisOps;
67
import org.apache.commons.lang3.StringUtils;
78

9+
import java.util.concurrent.CancellationException;
810
import java.util.concurrent.TimeUnit;
911
import java.util.concurrent.locks.Lock;
1012

1113
/**
1214
* @author yl
1315
* created on 2023/04/23
1416
*/
17+
@Slf4j
1518
public abstract class AbstractWxCpInRedisConfigImpl extends WxCpDefaultConfigImpl {
1619
private static final long serialVersionUID = 7157341535439380615L;
1720
/**
@@ -120,8 +123,34 @@ public String getAccessToken() {
120123

121124
@Override
122125
public boolean isAccessTokenExpired() {
123-
Long expire = redisOps.getExpire(this.accessTokenKey);
124-
return expire == null || expire < 2;
126+
try {
127+
Long expire = redisOps.getExpire(this.accessTokenKey);
128+
return expire == null || expire < 2;
129+
} catch (Exception e) {
130+
log.warn("获取access_token过期时间时发生异常,将视为已过期以触发刷新,异常信息: {}", e.getMessage());
131+
// 仅在当前线程已中断且异常为中断相关时,才清除中断标志,避免吞掉上层的中断语义
132+
if (Thread.currentThread().isInterrupted() && isInterruptionRelated(e)) {
133+
Thread.interrupted();
134+
}
135+
return true;
136+
}
137+
}
138+
139+
/**
140+
* 判断异常及其原因链是否为中断相关异常。
141+
*
142+
* @param throwable 异常
143+
* @return 如果异常链中包含 {@link InterruptedException} 或 {@link CancellationException},返回 true;否则返回 false
144+
*/
145+
private boolean isInterruptionRelated(Throwable throwable) {
146+
Throwable current = throwable;
147+
while (current != null) {
148+
if (current instanceof InterruptedException || current instanceof CancellationException) {
149+
return true;
150+
}
151+
current = current.getCause();
152+
}
153+
return false;
125154
}
126155

127156
@Override
@@ -146,8 +175,13 @@ public String getJsapiTicket() {
146175

147176
@Override
148177
public boolean isJsapiTicketExpired() {
149-
Long expire = redisOps.getExpire(this.jsapiTicketKey);
150-
return expire == null || expire < 2;
178+
try {
179+
Long expire = redisOps.getExpire(this.jsapiTicketKey);
180+
return expire == null || expire < 2;
181+
} catch (Exception e) {
182+
log.warn("获取jsapi_ticket过期时间时发生异常,将视为已过期,异常信息: {}", e.getMessage());
183+
return true;
184+
}
151185
}
152186

153187
@Override
@@ -177,8 +211,17 @@ public String getAgentJsapiTicket() {
177211

178212
@Override
179213
public boolean isAgentJsapiTicketExpired() {
180-
Long expire = redisOps.getExpire(this.agentJsapiTicketKey);
181-
return expire == null || expire < 2;
214+
try {
215+
Long expire = redisOps.getExpire(this.agentJsapiTicketKey);
216+
return expire == null || expire < 2;
217+
} catch (Exception e) {
218+
log.warn("获取agent_jsapi_ticket过期时间时发生异常,将视为已过期,异常信息: {}", e.getMessage());
219+
// 仅在当前线程已中断且异常为中断相关时,才清除中断标志,避免吞掉上层的中断语义
220+
if (Thread.currentThread().isInterrupted() && isInterruptionRelated(e)) {
221+
Thread.interrupted();
222+
}
223+
return true;
224+
}
182225
}
183226

184227
@Override
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package me.chanjar.weixin.cp.config.impl;
2+
3+
import me.chanjar.weixin.common.redis.WxRedisOps;
4+
import org.mockito.Mockito;
5+
import org.testng.Assert;
6+
import org.testng.annotations.BeforeMethod;
7+
import org.testng.annotations.Test;
8+
9+
import java.util.concurrent.locks.Lock;
10+
import java.util.concurrent.locks.ReentrantLock;
11+
12+
/**
13+
* 测试 AbstractWxCpInRedisConfigImpl 对 Redis 异常的容错处理
14+
*
15+
* @author GitHub Copilot
16+
*/
17+
public class AbstractWxCpInRedisConfigImplTest {
18+
19+
private WxRedisOps mockRedisOps;
20+
private AbstractWxCpInRedisConfigImpl config;
21+
22+
@BeforeMethod
23+
public void setUp() {
24+
mockRedisOps = Mockito.mock(WxRedisOps.class);
25+
Mockito.when(mockRedisOps.getLock(Mockito.anyString()))
26+
.thenReturn(new ReentrantLock());
27+
28+
config = new AbstractWxCpInRedisConfigImpl(mockRedisOps, "test") {
29+
// 使用匿名类提供具体实现用于测试
30+
};
31+
config.setCorpId("testCorpId");
32+
config.setAgentId(1);
33+
}
34+
35+
/**
36+
* 测试当 Redis getExpire 抛出异常时,isAccessTokenExpired() 应返回 true(视为已过期),且不影响线程中断标志
37+
*/
38+
@Test
39+
public void testIsAccessTokenExpiredWhenRedisThrowsException() {
40+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString()))
41+
.thenThrow(new RuntimeException("Redis command interrupted"));
42+
43+
boolean expired = config.isAccessTokenExpired();
44+
45+
Assert.assertTrue(expired, "Redis异常时应将token视为已过期");
46+
// 非中断相关异常不应影响线程中断标志
47+
Assert.assertFalse(Thread.currentThread().isInterrupted(), "非中断异常时线程中断标志不应被改变");
48+
}
49+
50+
/**
51+
* 测试当线程中断状态已设置时,Redis 调用抛出中断相关异常,isAccessTokenExpired() 应处理并清除中断标志
52+
*/
53+
@Test
54+
public void testIsAccessTokenExpiredClearsInterruptedFlag() {
55+
// 使用包含 InterruptedException cause 的异常,模拟 Lettuce 的 RedisCommandInterruptedException 行为
56+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString()))
57+
.thenThrow(new RuntimeException("wrapped", new InterruptedException("command interrupted")));
58+
59+
Thread.currentThread().interrupt();
60+
try {
61+
boolean expired = config.isAccessTokenExpired();
62+
63+
Assert.assertTrue(expired, "Redis中断异常时应将token视为已过期");
64+
// 中断标志应该被清除,允许后续操作正常进行
65+
Assert.assertFalse(Thread.currentThread().isInterrupted(), "中断相关异常处理后线程中断标志应被清除");
66+
} finally {
67+
// 兜底清除当前线程的中断标志,避免影响后续测试用例
68+
Thread.interrupted();
69+
}
70+
}
71+
72+
/**
73+
* 测试正常情况下 isAccessTokenExpired() 的行为
74+
*/
75+
@Test
76+
public void testIsAccessTokenExpiredWhenTokenValid() {
77+
// 返回60秒后过期(未过期)
78+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString())).thenReturn(60L);
79+
80+
boolean expired = config.isAccessTokenExpired();
81+
82+
Assert.assertFalse(expired, "token未过期时应返回false");
83+
}
84+
85+
/**
86+
* 测试 isAccessTokenExpired() 当 expire 为 null 时视为已过期
87+
*/
88+
@Test
89+
public void testIsAccessTokenExpiredWhenExpireIsNull() {
90+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString())).thenReturn(null);
91+
92+
boolean expired = config.isAccessTokenExpired();
93+
94+
Assert.assertTrue(expired, "expire为null时应视为已过期");
95+
}
96+
97+
/**
98+
* 测试当 Redis getExpire 抛出异常时,isJsapiTicketExpired() 应返回 true(视为已过期),且不影响线程中断标志
99+
*/
100+
@Test
101+
public void testIsJsapiTicketExpiredWhenRedisThrowsException() {
102+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString()))
103+
.thenThrow(new RuntimeException("Redis command interrupted"));
104+
105+
boolean expired = config.isJsapiTicketExpired();
106+
107+
Assert.assertTrue(expired, "Redis异常时应将jsapi_ticket视为已过期");
108+
Assert.assertFalse(Thread.currentThread().isInterrupted(), "非中断异常时线程中断标志不应被改变");
109+
}
110+
111+
/**
112+
* 测试当 Redis getExpire 抛出异常时,isAgentJsapiTicketExpired() 应返回 true(视为已过期),且不影响线程中断标志
113+
*/
114+
@Test
115+
public void testIsAgentJsapiTicketExpiredWhenRedisThrowsException() {
116+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString()))
117+
.thenThrow(new RuntimeException("Redis command interrupted"));
118+
119+
boolean expired = config.isAgentJsapiTicketExpired();
120+
121+
Assert.assertTrue(expired, "Redis异常时应将agent_jsapi_ticket视为已过期");
122+
Assert.assertFalse(Thread.currentThread().isInterrupted(), "非中断异常时线程中断标志不应被改变");
123+
}
124+
125+
/**
126+
* 测试当线程中断状态已设置时,Redis 调用抛出中断相关异常,isAgentJsapiTicketExpired() 应处理并清除中断标志
127+
*/
128+
@Test
129+
public void testIsAgentJsapiTicketExpiredClearsInterruptedFlag() {
130+
Mockito.when(mockRedisOps.getExpire(Mockito.anyString()))
131+
.thenThrow(new RuntimeException("wrapped", new InterruptedException("command interrupted")));
132+
133+
Thread.currentThread().interrupt();
134+
try {
135+
boolean expired = config.isAgentJsapiTicketExpired();
136+
137+
Assert.assertTrue(expired, "Redis中断异常时应将agent_jsapi_ticket视为已过期");
138+
Assert.assertFalse(Thread.currentThread().isInterrupted(), "中断相关异常处理后线程中断标志应被清除");
139+
} finally {
140+
Thread.interrupted();
141+
}
142+
}
143+
144+
/**
145+
* 测试提供自定义 Lock 实现时 getAccessTokenLock() 返回正确的锁
146+
*/
147+
@Test
148+
public void testGetAccessTokenLockReturnsMockedLock() {
149+
Lock mockLock = Mockito.mock(Lock.class);
150+
Mockito.when(mockRedisOps.getLock(Mockito.anyString())).thenReturn(mockLock);
151+
152+
Lock lock = config.getAccessTokenLock();
153+
154+
Assert.assertNotNull(lock, "获取到的锁不应为null");
155+
}
156+
}

0 commit comments

Comments
 (0)