feat: finalize backend deployment and api docs

这个提交包含在:
XuqmGroup 2026-04-24 10:42:11 +08:00
父节点 05c9639523
当前提交 8fe4ae99cc
共有 11 个文件被更改,包括 312 次插入10 次删除

22
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"]

查看文件

@ -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 多模块
## 模块结构

143
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 <tenant_jwt>` |
| 运营平台接口 | `Authorization: Bearer <ops_jwt>` |
| IM HTTP 接口 | `Authorization: Bearer <im_jwt>` |
| IM WebSocket | `?token=<im_jwt>` |
| 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&currentVersionCode=1'
```
### RN 热更新检查
```bash
curl 'https://sentry.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platform=ANDROID&moduleId=chat-home&currentVersion=1.0.0'
```
### IM 登录
```bash
curl -X POST 'https://sentry.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice'
```

查看文件

@ -45,9 +45,10 @@ public class MessageController {
@GetMapping("/history/{toId}")
public ResponseEntity<ApiResponse<?>> 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)));
}
}

查看文件

@ -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<ImMessageEntity, String> {
Page<ImMessageEntity> findByAppIdAndToIdOrderByCreatedAtDesc(
String appId, String toId, Pageable pageable);
Page<ImMessageEntity> 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<ImMessageEntity> findSingleConversation(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("peerId") String peerId,
Pageable pageable);
}

查看文件

@ -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<ImMessageEntity> history(String appId, String toId, int page, int size) {
return messageRepository.findByAppIdAndToIdOrderByCreatedAtDesc(
appId, toId, PageRequest.of(page, size));
public Page<ImMessageEntity> history(String appId, String userId, String toId, int page, int size) {
return messageRepository.findSingleConversation(
appId, userId, toId, PageRequest.of(page, size));
}
@Async

查看文件

@ -78,6 +78,13 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>

查看文件

@ -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);
}

查看文件

@ -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();
}
}

查看文件

@ -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;
}
}

查看文件

@ -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<Resource> 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<Resource> 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<Resource> 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);
}
}