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,
|
public static String payload(String appId,
|
||||||
String userId,
|
String userId,
|
||||||
String nickname,
|
|
||||||
String avatar,
|
|
||||||
long timestamp,
|
long timestamp,
|
||||||
String nonce) {
|
String nonce) {
|
||||||
return normalize(appId) + '\n'
|
return normalize(appId) + '\n'
|
||||||
+ normalize(userId) + '\n'
|
+ normalize(userId) + '\n'
|
||||||
+ normalize(nickname) + '\n'
|
|
||||||
+ normalize(avatar) + '\n'
|
|
||||||
+ timestamp + '\n'
|
+ timestamp + '\n'
|
||||||
+ normalize(nonce);
|
+ normalize(nonce);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public class JwtUtil {
|
|||||||
@Value("${jwt.secret}")
|
@Value("${jwt.secret}")
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
@Value("${jwt.expiration:86400000}")
|
@Value("${jwt.expiration:3153600000000}")
|
||||||
private long expiration;
|
private long expiration;
|
||||||
|
|
||||||
public long getExpirationMillis() {
|
public long getExpirationMillis() {
|
||||||
@ -30,13 +30,15 @@ public class JwtUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String generate(String subject, Map<String, Object> claims) {
|
public String generate(String subject, Map<String, Object> claims) {
|
||||||
return Jwts.builder()
|
var builder = Jwts.builder()
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.claims(claims)
|
.claims(claims)
|
||||||
.issuedAt(new Date())
|
.issuedAt(new Date())
|
||||||
.expiration(new Date(System.currentTimeMillis() + expiration))
|
.signWith(getSigningKey());
|
||||||
.signWith(getSigningKey())
|
if (expiration > 0) {
|
||||||
.compact();
|
builder.expiration(new Date(System.currentTimeMillis() + expiration));
|
||||||
|
}
|
||||||
|
return builder.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String generate(String subject) {
|
public String generate(String subject) {
|
||||||
|
|||||||
@ -73,7 +73,7 @@ public class DemoAuthService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appId, userId);
|
String demoToken = generateDemoToken(appId, userId);
|
||||||
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
|
ImCredential imCredential = callImServiceLogin(appId, userId);
|
||||||
|
|
||||||
return new AuthResult(
|
return new AuthResult(
|
||||||
demoToken,
|
demoToken,
|
||||||
@ -94,7 +94,7 @@ public class DemoAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appId, userId);
|
String demoToken = generateDemoToken(appId, userId);
|
||||||
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
|
ImCredential imCredential = callImServiceLogin(appId, userId);
|
||||||
|
|
||||||
return new AuthResult(
|
return new AuthResult(
|
||||||
demoToken,
|
demoToken,
|
||||||
@ -124,27 +124,25 @@ public class DemoAuthService {
|
|||||||
public ImCredential refreshImToken(String appId, String userId) {
|
public ImCredential refreshImToken(String appId, String userId) {
|
||||||
DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId)
|
DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "User not found: " + 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.
|
* 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":"..."}}
|
* 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();
|
long timestamp = System.currentTimeMillis();
|
||||||
String nonce = UUID.randomUUID().toString();
|
String nonce = UUID.randomUUID().toString();
|
||||||
String effectiveNickname = nickname != null ? nickname : userId;
|
|
||||||
String appSecret = appSecretClient.getAppSecret(appId);
|
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);
|
String signature = AppRequestSignatureUtil.sign(appSecret, payload);
|
||||||
|
|
||||||
URI uri = UriComponentsBuilder.fromHttpUrl(imServiceUrl)
|
URI uri = UriComponentsBuilder.fromHttpUrl(imServiceUrl)
|
||||||
.path("/api/im/auth/login")
|
.path("/api/im/auth/login")
|
||||||
.queryParam("appId", appId)
|
.queryParam("appId", appId)
|
||||||
.queryParam("userId", userId)
|
.queryParam("userId", userId)
|
||||||
.queryParam("nickname", effectiveNickname)
|
|
||||||
.encode()
|
.encode()
|
||||||
.build()
|
.build()
|
||||||
.toUri();
|
.toUri();
|
||||||
|
|||||||
@ -62,14 +62,14 @@ public final class XuqmImServerSdk {
|
|||||||
return new Builder();
|
return new Builder();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoginResponse login(String userId, String nickname, String avatar) {
|
public LoginResponse login(String userId) {
|
||||||
long timestamp = System.currentTimeMillis();
|
long timestamp = System.currentTimeMillis();
|
||||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
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);
|
String signature = AppRequestSignatureUtil.sign(appSecret, payload);
|
||||||
URI uri = buildUri(
|
URI uri = buildUri(
|
||||||
"/api/im/auth/login",
|
"/api/im/auth/login",
|
||||||
loginQuery(userId, nickname, avatar, timestamp, nonce)
|
loginQuery(userId, timestamp, nonce)
|
||||||
);
|
);
|
||||||
ApiResponse<LoginResponse> response = request(
|
ApiResponse<LoginResponse> response = request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -1379,18 +1379,12 @@ public final class XuqmImServerSdk {
|
|||||||
return URI.create(builder.toString());
|
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<>();
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
query.put("appId", appId);
|
query.put("appId", appId);
|
||||||
query.put("userId", userId);
|
query.put("userId", userId);
|
||||||
query.put("timestamp", String.valueOf(timestamp));
|
query.put("timestamp", String.valueOf(timestamp));
|
||||||
query.put("nonce", nonce);
|
query.put("nonce", nonce);
|
||||||
if (nickname != null && !nickname.isBlank()) {
|
|
||||||
query.put("nickname", nickname);
|
|
||||||
}
|
|
||||||
if (avatar != null && !avatar.isBlank()) {
|
|
||||||
query.put("avatar", avatar);
|
|
||||||
}
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,16 +26,14 @@ public class AuthController {
|
|||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@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-Timestamp", required = false) String timestamp,
|
||||||
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
|
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
|
||||||
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
|
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
|
||||||
if (timestamp == null || nonce == null || signature == null) {
|
if (timestamp == null || nonce == null || signature == null) {
|
||||||
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
|
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
|
||||||
}
|
}
|
||||||
accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature);
|
accountService.validateSignature(appId, userId, timestamp, nonce, signature);
|
||||||
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId, nickname, avatar);
|
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId);
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
"token", result.token(),
|
"token", result.token(),
|
||||||
"expiresAt", result.expiresAt()
|
"expiresAt", result.expiresAt()
|
||||||
|
|||||||
@ -31,8 +31,6 @@ public class ImAccountService {
|
|||||||
|
|
||||||
public void validateSignature(String appId,
|
public void validateSignature(String appId,
|
||||||
String userId,
|
String userId,
|
||||||
String nickname,
|
|
||||||
String avatar,
|
|
||||||
String timestamp,
|
String timestamp,
|
||||||
String nonce,
|
String nonce,
|
||||||
String signature) {
|
String signature) {
|
||||||
@ -47,21 +45,19 @@ public class ImAccountService {
|
|||||||
throw new BusinessException(401, "App signature expired");
|
throw new BusinessException(401, "App signature expired");
|
||||||
}
|
}
|
||||||
String secret = appSecretClient.getAppSecret(appId);
|
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)) {
|
if (!AppRequestSignatureUtil.matches(secret, payload, signature)) {
|
||||||
throw new BusinessException(401, "Invalid app 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)
|
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
ImAccountEntity e = new ImAccountEntity();
|
ImAccountEntity e = new ImAccountEntity();
|
||||||
e.setId(UUID.randomUUID().toString());
|
e.setId(UUID.randomUUID().toString());
|
||||||
e.setAppId(appId);
|
e.setAppId(appId);
|
||||||
e.setUserId(userId);
|
e.setUserId(userId);
|
||||||
e.setNickname(nickname);
|
|
||||||
e.setAvatar(avatar);
|
|
||||||
e.setGender(ImAccountEntity.Gender.UNKNOWN);
|
e.setGender(ImAccountEntity.Gender.UNKNOWN);
|
||||||
e.setStatus(ImAccountEntity.Status.ACTIVE);
|
e.setStatus(ImAccountEntity.Status.ACTIVE);
|
||||||
e.setCreatedAt(LocalDateTime.now());
|
e.setCreatedAt(LocalDateTime.now());
|
||||||
@ -72,7 +68,9 @@ public class ImAccountService {
|
|||||||
throw new BusinessException(403, "账号已被封禁");
|
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);
|
return new LoginResult(jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")), expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.xuqm.im.service;
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -8,11 +9,10 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ImPushBridge {
|
public class ImPushBridge {
|
||||||
@ -21,6 +21,7 @@ public class ImPushBridge {
|
|||||||
|
|
||||||
private final SimpUserRegistry userRegistry;
|
private final SimpUserRegistry userRegistry;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.push-service-url:http://127.0.0.1:8083}")
|
@Value("${im.push-service-url:http://127.0.0.1:8083}")
|
||||||
private String pushServiceUrl;
|
private String pushServiceUrl;
|
||||||
@ -28,40 +29,45 @@ public class ImPushBridge {
|
|||||||
@Value("${im.internal-token:xuqm-internal-token}")
|
@Value("${im.internal-token:xuqm-internal-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
public ImPushBridge(SimpUserRegistry userRegistry) {
|
public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) {
|
||||||
this.userRegistry = userRegistry;
|
this.userRegistry = userRegistry;
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendOfflinePush(String appId, String userId, String title, String body, String payload) {
|
public void sendOfflinePush(String appId, String userId, String title, String body, String payload) {
|
||||||
if (isOnline(userId)) {
|
if (isOnline(userId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
sendOfflinePushToUsers(appId, List.of(userId), title, body, payload);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendOfflinePushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
|
public void sendOfflinePushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
|
||||||
if (userIds == null || userIds.isEmpty()) {
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (String userId : userIds) {
|
List<String> offlineUserIds = userIds.stream()
|
||||||
sendOfflinePush(appId, userId, title, body, payload);
|
.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 com.xuqm.push.service.provider.PushProvider;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -29,7 +28,6 @@ public class PushDispatcher {
|
|||||||
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Async
|
|
||||||
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
||||||
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
|
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
|
||||||
for (DeviceTokenEntity t : tokens) {
|
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) {
|
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
|
||||||
if (userIds == null || userIds.isEmpty()) {
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户