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
这个提交包含在:
父节点
dd465becea
当前提交
83cf9541e7
@ -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;
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户