diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9477d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +ARG SERVICE_MODULE=tenant-service + +FROM maven:3.9.9-eclipse-temurin-21 AS build +ARG SERVICE_MODULE +WORKDIR /workspace + +COPY pom.xml ./pom.xml +COPY common ./common +COPY tenant-service ./tenant-service +COPY im-service ./im-service +COPY push-service ./push-service +COPY update-service ./update-service + +RUN mvn -pl ${SERVICE_MODULE} -am -DskipTests package + +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +ARG SERVICE_MODULE + +COPY --from=build /workspace/${SERVICE_MODULE}/target/${SERVICE_MODULE}-0.1.0-SNAPSHOT.jar /app/app.jar + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/README.md b/README.md index 3589ab9..9815d16 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # XuqmGroup-Server 后端文档 +> 当前推荐阅读入口:[`/docs/server/README.md`](../docs/server/README.md) +> 仓库内联调文档:[`docs/API_ACCESS.md`](./docs/API_ACCESS.md) +> 该文档保留为仓库内说明,线上地址、初始化账号与最新联调接口请以最新文档为准。 + > Spring Boot 3.4.4 · Java 21 · Maven 多模块 ## 模块结构 diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md new file mode 100644 index 0000000..907037a --- /dev/null +++ b/docs/API_ACCESS.md @@ -0,0 +1,143 @@ +# XuqmGroup Server 联调接口文档 + +> 最后更新:2026-04-24 + +## 线上入口 + +| 服务 | 地址 | 说明 | +|------|------|------| +| 租户服务 | `https://sentry.xuqinmin.com/api/` | 认证、应用、子账号、运营平台 | +| IM HTTP | `https://sentry.xuqinmin.com/api/im/` | IM 登录、消息发送、撤回、历史消息 | +| IM WebSocket | `wss://sentry.xuqinmin.com/ws/im` | 实时消息 | +| App 更新 | `https://sentry.xuqinmin.com/api/v1/updates/` | 原生版本管理 | +| RN 热更新 | `https://sentry.xuqinmin.com/api/v1/rn/` | Bundle 热更新 | + +## 初始化管理员账号 + +| 字段 | 值 | +|------|----| +| 用户名 | `admin` | +| 初始密码 | `Admin@123456` | +| 登录接口 | `POST /api/auth/ops/login` | + +## 统一响应 + +```json +{ + "code": 200, + "status": "0", + "data": {}, + "message": "success" +} +``` + +## 常见错误码 + +| `code` | `status` | 说明 | +|--------|----------|------| +| `200` | `"0"` | 成功 | +| `400` | `"1"` | 参数校验失败或请求不合法 | +| `401` | `"1"` | 未登录、Token 无效或已过期 | +| `403` | `"1"` | 无权限访问 | +| `500` | `"1"` | 服务端内部错误 | + +## 鉴权规则 + +| 场景 | 鉴权方式 | +|------|----------| +| 租户平台接口 | `Authorization: Bearer ` | +| 运营平台接口 | `Authorization: Bearer ` | +| IM HTTP 接口 | `Authorization: Bearer ` | +| IM WebSocket | `?token=` | +| App 更新检查 | 无需登录 | +| RN 更新检查 | 无需登录 | +| Bundle 下载 | 无需登录 | + +## 核心接口清单 + +### tenant-service + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | `/api/auth/captcha` | 否 | 获取图形验证码 | +| POST | `/api/auth/send-email-code` | 否 | 发送邮箱验证码 | +| POST | `/api/auth/register` | 否 | 注册主账号 | +| POST | `/api/auth/login` | 否 | 租户登录 | +| POST | `/api/auth/forgot-password` | 否 | 发送找回密码邮件 | +| POST | `/api/auth/reset-password` | 否 | 重置密码 | +| GET | `/api/apps` | 是 | 应用列表 | +| GET | `/api/apps/{id}` | 是 | 应用详情 | +| POST | `/api/apps` | 是 | 创建应用 | +| PUT | `/api/apps/{id}` | 是 | 更新应用 | +| DELETE | `/api/apps/{id}` | 是 | 删除应用 | +| GET | `/api/apps/{appId}/services` | 是 | 服务列表 | +| POST | `/api/apps/{appId}/services/toggle` | 是 | 开关服务 | +| POST | `/api/apps/{appId}/services/{id}/regenerate-key` | 是 | 重新生成服务密钥 | +| GET | `/api/sub-accounts` | 是 | 子账号列表 | +| POST | `/api/sub-accounts/send-verify-code` | 是 | 子账号邮箱验证码 | +| POST | `/api/sub-accounts/verify-email` | 是 | 校验子账号邮箱 | +| POST | `/api/sub-accounts` | 是 | 创建子账号 | +| DELETE | `/api/sub-accounts/{id}` | 是 | 禁用子账号 | +| POST | `/api/auth/ops/login` | 否 | 运营管理员登录 | +| GET | `/api/ops/tenants` | 是 | 运营租户列表 | +| POST | `/api/ops/tenants/{id}/toggle-status` | 是 | 租户启停 | +| GET | `/api/ops/statistics` | 是 | 统计面板 | + +### im-service + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | `/api/im/auth/login` | 否 | 获取 IM Token | +| POST | `/api/im/messages/send` | 是 | 发送消息 | +| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 | +| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 | +| WS | `/ws/im` | IM Token | 建立实时连接 | + +### push-service + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | `/api/push/register` | 是 | 注册设备 token | +| POST | `/api/push/send` | 是 | 发送推送通知 | + +### update-service + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | +| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本 | +| POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 | +| GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | +| GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | +| GET | `/api/v1/rn/update/check` | 否 | 检查 RN 热更新 | +| POST | `/api/v1/rn/upload` | 是 | 上传 Bundle | +| POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle | +| GET | `/api/v1/rn/files/{appId}/{platform}/{moduleId}` | 否 | 下载 Bundle | + +## curl 示例 + +### 运营平台登录 + +```bash +curl -X POST 'https://sentry.xuqinmin.com/api/auth/ops/login' \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"Admin@123456"}' +``` + +### App 更新检查 + +```bash +curl 'https://sentry.xuqinmin.com/api/v1/updates/app/check?appId=ak_demo_chat&platform=ANDROID¤tVersionCode=1' +``` + +### RN 热更新检查 + +```bash +curl 'https://sentry.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platform=ANDROID&moduleId=chat-home¤tVersion=1.0.0' +``` + +### IM 登录 + +```bash +curl -X POST 'https://sentry.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice' +``` diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java index ed664d0..3f95763 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -45,9 +45,10 @@ public class MessageController { @GetMapping("/history/{toId}") public ResponseEntity> history( @PathVariable String toId, + @AuthenticationPrincipal String userId, @RequestParam String appId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, toId, page, size))); + return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, userId, toId, page, size))); } } diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java index c483ea1..48dbdda 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java @@ -4,10 +4,26 @@ import com.xuqm.im.entity.ImMessageEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ImMessageRepository extends JpaRepository { Page findByAppIdAndToIdOrderByCreatedAtDesc( String appId, String toId, Pageable pageable); Page findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc( String appId, String fromUserId, String toId, Pageable pageable); + + @Query(""" + select m from ImMessageEntity m + where m.appId = :appId + and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.SINGLE + and ((m.fromUserId = :userId and m.toId = :peerId) + or (m.fromUserId = :peerId and m.toId = :userId)) + order by m.createdAt desc + """) + Page findSingleConversation( + @Param("appId") String appId, + @Param("userId") String userId, + @Param("peerId") String peerId, + Pageable pageable); } diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index 298e348..f4c3d88 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -72,6 +72,9 @@ public class MessageService { ? "/user/" + req.toId() + "/queue/messages" : "/topic/group/" + req.toId(); messagingTemplate.convertAndSend(destination, message); + if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { + messagingTemplate.convertAndSend("/user/" + fromUserId + "/queue/messages", message); + } dispatchWebhooks(appId, message); @@ -89,12 +92,21 @@ public class MessageService { } message.setStatus(ImMessageEntity.MsgStatus.REVOKED); message.setMsgType(ImMessageEntity.MsgType.REVOKED); - return messageRepository.save(message); + ImMessageEntity saved = messageRepository.save(message); + if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { + messagingTemplate.convertAndSend("/user/" + saved.getToId() + "/queue/messages", saved); + if (!saved.getFromUserId().equals(saved.getToId())) { + messagingTemplate.convertAndSend("/user/" + saved.getFromUserId() + "/queue/messages", saved); + } + } else { + messagingTemplate.convertAndSend("/topic/group/" + saved.getToId(), saved); + } + return saved; } - public Page history(String appId, String toId, int page, int size) { - return messageRepository.findByAppIdAndToIdOrderByCreatedAtDesc( - appId, toId, PageRequest.of(page, size)); + public Page history(String appId, String userId, String toId, int page, int size) { + return messageRepository.findSingleConversation( + appId, userId, toId, PageRequest.of(page, size)); } @Async diff --git a/pom.xml b/pom.xml index 1c3a05b..bc7e4d9 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,13 @@ org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} + + + + repackage + + + diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java index 0f5ede9..ae2d580 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java @@ -6,8 +6,8 @@ import com.xuqm.tenant.repository.EmailVerificationRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.LocalDateTime; @@ -32,7 +32,7 @@ public class EmailService { this.verificationRepository = verificationRepository; } - @Async + @Transactional public void sendVerificationCode(String email, String purpose) { String code = String.format("%06d", random.nextInt(1_000_000)); @@ -44,13 +44,13 @@ public class EmailService { entity.setUsed(false); entity.setCreatedAt(LocalDateTime.now()); entity.setExpiresAt(LocalDateTime.now().plusSeconds(expireSeconds)); - verificationRepository.save(entity); - SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(fromAddress); message.setTo(email); message.setSubject("XuqmGroup - 邮箱验证码"); message.setText(String.format("您的验证码是:%s,%d分钟内有效。", code, expireSeconds / 60)); + + verificationRepository.save(entity); mailSender.send(message); } diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java new file mode 100644 index 0000000..5e9dfe5 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package com.xuqm.update.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/actuator/**", + "/api/v1/updates/app/check", + "/api/v1/rn/update/check", + "/api/v1/rn/files/**", + "/files/apk/**" + ).permitAll() + .anyRequest().authenticated() + ) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable); + return http.build(); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index c96d578..e11670a 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -62,7 +62,7 @@ public class RnBundleController { return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", needsUpdate, "latestVersion", b.getVersion(), - "downloadUrl", baseUrl + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId, + "downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId, "md5", b.getMd5(), "minCommonVersion", b.getMinCommonVersion() != null ? b.getMinCommonVersion() : "0.0.0", "note", b.getNote() != null ? b.getNote() : "" @@ -117,4 +117,13 @@ public class RnBundleController { } return HexFormat.of().formatHex(digest.digest()); } + + private String resolvePublicBaseUrl() { + String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + String suffix = "/api/v1/updates"; + if (normalized.endsWith(suffix)) { + return normalized.substring(0, normalized.length() - suffix.length()); + } + return normalized; + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java b/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java new file mode 100644 index 0000000..01b5482 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java @@ -0,0 +1,54 @@ +package com.xuqm.update.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@RestController +@RequestMapping +public class UpdateFileController { + + @Value("${update.upload-dir:/tmp/xuqm-update}") + private String uploadDir; + + @GetMapping({"/files/apk/{filename:.+}", "/api/v1/updates/files/apk/{filename:.+}"}) + public ResponseEntity downloadApk(@PathVariable String filename) throws Exception { + Path file = Paths.get(uploadDir, "apk", filename).normalize(); + return serveFile(file, MediaType.parseMediaType("application/vnd.android.package-archive")); + } + + @GetMapping("/api/v1/rn/files/{appId}/{platform}/{moduleId}") + public ResponseEntity downloadRnBundle( + @PathVariable String appId, + @PathVariable String platform, + @PathVariable String moduleId) throws Exception { + Path file = Paths.get(uploadDir, "rn", appId, platform, moduleId, + moduleId + "." + platform + ".bundle").normalize(); + return serveFile(file, MediaType.APPLICATION_OCTET_STREAM); + } + + private ResponseEntity serveFile(Path file, MediaType mediaType) throws Exception { + if (!Files.exists(file) || !Files.isRegularFile(file)) { + return ResponseEntity.notFound().build(); + } + + FileSystemResource resource = new FileSystemResource(file); + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(file.getFileName().toString()).build().toString()) + .body(resource); + } +}