feat: 简化 IM 登录接口,JWT 永久有效,离线推送同步化

- /api/im/auth/login 移除 nickname/avatar 参数
- AppRequestSignatureUtil.payload 简化签名(不含 nickname/avatar)
- JwtUtil 默认过期时间改为极大值(≈100年)
- ImAccountService.validateSignature/loginOrRegister 适配
- DemoAuthService.callImServiceLogin 不传 nickname
- XuqmImServerSdk.login() 签名方法同步简化
- ImPushBridge 改为批量同步调用 /api/push/internal/notify
- PushDispatcher 移除 @Async
这个提交包含在:
XuqmGroup 2026-05-01 22:18:54 +08:00
父节点 dd465becea
当前提交 83cf9541e7
共有 8 个文件被更改,包括 52 次插入63 次删除

查看文件

@ -15,14 +15,10 @@ public final class AppRequestSignatureUtil {
public static String payload(String appId,
String userId,
String nickname,
String avatar,
long timestamp,
String nonce) {
return normalize(appId) + '\n'
+ normalize(userId) + '\n'
+ normalize(nickname) + '\n'
+ normalize(avatar) + '\n'
+ timestamp + '\n'
+ normalize(nonce);
}

查看文件

@ -17,7 +17,7 @@ public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:86400000}")
@Value("${jwt.expiration:3153600000000}")
private long expiration;
public long getExpirationMillis() {
@ -30,13 +30,15 @@ public class JwtUtil {
}
public String generate(String subject, Map<String, Object> claims) {
return Jwts.builder()
var builder = Jwts.builder()
.subject(subject)
.claims(claims)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
.signWith(getSigningKey());
if (expiration > 0) {
builder.expiration(new Date(System.currentTimeMillis() + expiration));
}
return builder.compact();
}
public String generate(String subject) {

查看文件

@ -73,7 +73,7 @@ public class DemoAuthService {
userRepository.save(user);
String demoToken = generateDemoToken(appId, userId);
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
ImCredential imCredential = callImServiceLogin(appId, userId);
return new AuthResult(
demoToken,
@ -94,7 +94,7 @@ public class DemoAuthService {
}
String demoToken = generateDemoToken(appId, userId);
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
ImCredential imCredential = callImServiceLogin(appId, userId);
return new AuthResult(
demoToken,
@ -124,27 +124,25 @@ public class DemoAuthService {
public ImCredential refreshImToken(String appId, String userId) {
DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId)
.orElseThrow(() -> new BusinessException(404, "User not found: " + userId));
return callImServiceLogin(appId, userId, user.getNickname());
return callImServiceLogin(appId, userId);
}
/**
* Calls im-service to ensure the IM account exists and obtain an IM token.
* POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname}
* POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}
* Response: {"code":200,"data":{"token":"..."}}
*/
private ImCredential callImServiceLogin(String appId, String userId, String nickname) {
private ImCredential callImServiceLogin(String appId, String userId) {
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString();
String effectiveNickname = nickname != null ? nickname : userId;
String appSecret = appSecretClient.getAppSecret(appId);
String payload = AppRequestSignatureUtil.payload(appId, userId, effectiveNickname, null, timestamp, nonce);
String payload = AppRequestSignatureUtil.payload(appId, userId, timestamp, nonce);
String signature = AppRequestSignatureUtil.sign(appSecret, payload);
URI uri = UriComponentsBuilder.fromHttpUrl(imServiceUrl)
.path("/api/im/auth/login")
.queryParam("appId", appId)
.queryParam("userId", userId)
.queryParam("nickname", effectiveNickname)
.encode()
.build()
.toUri();

查看文件

@ -62,14 +62,14 @@ public final class XuqmImServerSdk {
return new Builder();
}
public LoginResponse login(String userId, String nickname, String avatar) {
public LoginResponse login(String userId) {
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replace("-", "");
String payload = AppRequestSignatureUtil.payload(appId, userId, nickname, avatar, timestamp, nonce);
String payload = AppRequestSignatureUtil.payload(appId, userId, timestamp, nonce);
String signature = AppRequestSignatureUtil.sign(appSecret, payload);
URI uri = buildUri(
"/api/im/auth/login",
loginQuery(userId, nickname, avatar, timestamp, nonce)
loginQuery(userId, timestamp, nonce)
);
ApiResponse<LoginResponse> response = request(
"POST",
@ -1379,18 +1379,12 @@ public final class XuqmImServerSdk {
return URI.create(builder.toString());
}
private Map<String, String> loginQuery(String userId, String nickname, String avatar, long timestamp, String nonce) {
private Map<String, String> loginQuery(String userId, long timestamp, String nonce) {
Map<String, String> query = new LinkedHashMap<>();
query.put("appId", appId);
query.put("userId", userId);
query.put("timestamp", String.valueOf(timestamp));
query.put("nonce", nonce);
if (nickname != null && !nickname.isBlank()) {
query.put("nickname", nickname);
}
if (avatar != null && !avatar.isBlank()) {
query.put("avatar", avatar);
}
return query;
}

查看文件

@ -26,16 +26,14 @@ public class AuthController {
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam(required = false) String nickname,
@RequestParam(required = false) String avatar,
@RequestHeader(value = "X-App-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
if (timestamp == null || nonce == null || signature == null) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
}
accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature);
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId, nickname, avatar);
accountService.validateSignature(appId, userId, timestamp, nonce, signature);
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"token", result.token(),
"expiresAt", result.expiresAt()

查看文件

@ -31,8 +31,6 @@ public class ImAccountService {
public void validateSignature(String appId,
String userId,
String nickname,
String avatar,
String timestamp,
String nonce,
String signature) {
@ -47,21 +45,19 @@ public class ImAccountService {
throw new BusinessException(401, "App signature expired");
}
String secret = appSecretClient.getAppSecret(appId);
String payload = AppRequestSignatureUtil.payload(appId, userId, nickname, avatar, ts, nonce);
String payload = AppRequestSignatureUtil.payload(appId, userId, ts, nonce);
if (!AppRequestSignatureUtil.matches(secret, payload, signature)) {
throw new BusinessException(401, "Invalid app signature");
}
}
public LoginResult loginOrRegister(String appId, String userId, String nickname, String avatar) {
public LoginResult loginOrRegister(String appId, String userId) {
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
.orElseGet(() -> {
ImAccountEntity e = new ImAccountEntity();
e.setId(UUID.randomUUID().toString());
e.setAppId(appId);
e.setUserId(userId);
e.setNickname(nickname);
e.setAvatar(avatar);
e.setGender(ImAccountEntity.Gender.UNKNOWN);
e.setStatus(ImAccountEntity.Status.ACTIVE);
e.setCreatedAt(LocalDateTime.now());
@ -72,7 +68,9 @@ public class ImAccountService {
throw new BusinessException(403, "账号已被封禁");
}
long expiresAt = Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis();
long expiresAt = jwtUtil.getExpirationMillis() > 0
? Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis()
: Long.MAX_VALUE;
return new LoginResult(jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")), expiresAt);
}

查看文件

@ -1,5 +1,6 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@ -8,11 +9,10 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
@Component
public class ImPushBridge {
@ -21,6 +21,7 @@ public class ImPushBridge {
private final SimpUserRegistry userRegistry;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Value("${im.push-service-url:http://127.0.0.1:8083}")
private String pushServiceUrl;
@ -28,40 +29,45 @@ public class ImPushBridge {
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public ImPushBridge(SimpUserRegistry userRegistry) {
public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) {
this.userRegistry = userRegistry;
this.restTemplate = new RestTemplate();
this.objectMapper = objectMapper;
}
public void sendOfflinePush(String appId, String userId, String title, String body, String payload) {
if (isOnline(userId)) {
return;
}
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("X-Internal-Token", internalToken);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("appId", appId);
map.add("userId", userId);
map.add("title", title);
map.add("body", body);
if (payload != null) {
map.add("payload", payload);
}
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers);
restTemplate.postForEntity(pushServiceUrl + "/api/push/send", request, String.class);
} catch (Exception e) {
log.warn("Failed to send offline push appId={} userId={}: {}", appId, userId, e.getMessage());
}
sendOfflinePushToUsers(appId, List.of(userId), title, body, payload);
}
public void sendOfflinePushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;
}
for (String userId : userIds) {
sendOfflinePush(appId, userId, title, body, payload);
List<String> offlineUserIds = userIds.stream()
.filter(userId -> !isOnline(userId))
.toList();
if (offlineUserIds.isEmpty()) {
return;
}
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Internal-Token", internalToken);
Map<String, Object> bodyMap = Map.of(
"appId", appId,
"userIds", offlineUserIds,
"title", title,
"body", body,
"payload", payload
);
HttpEntity<String> request = new HttpEntity<>(objectMapper.writeValueAsString(bodyMap), headers);
restTemplate.postForEntity(pushServiceUrl + "/api/push/internal/notify", request, String.class);
log.debug("Sync offline push sent appId={} users={} title={}", appId, offlineUserIds.size(), title);
} catch (Exception e) {
log.warn("Failed to send offline push appId={} users={}: {}", appId, offlineUserIds.size(), e.getMessage());
}
}

查看文件

@ -5,7 +5,6 @@ import com.xuqm.push.repository.DeviceTokenRepository;
import com.xuqm.push.service.provider.PushProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@ -29,7 +28,6 @@ public class PushDispatcher {
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
}
@Async
public void pushToUser(String appId, String userId, String title, String body, String payload) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
for (DeviceTokenEntity t : tokens) {
@ -41,7 +39,6 @@ public class PushDispatcher {
}
}
@Async
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;