diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..adde691 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.codex +.agents +.worktrees + +.env +.env.* +!.env.example + +docker-data +logs +*.log + +**/node_modules +**/dist +**/coverage +**/.vite + +**/target/** +!backend/*/target/ +!backend/*/target/*.jar + +01-login-page.png +test-*.png +*-browser-qa*.png +review_diff.txt diff --git a/.env.example b/.env.example index 06712cf..140f541 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ VITE_API_PROXY_TARGET=http://localhost:8080 JWT_SECRET=local-dev-secret-32-bytes-minimum-value GATEWAY_INTERNAL_SECRET=local-dev-gateway-secret-32-bytes TOKEN_TTL_MINUTES=720 +JAVA_TOOL_OPTIONS=-Xms64m -Xmx256m -XX:MaxMetaspaceSize=192m -XX:+UseSerialGC GATEWAY_PORT=8080 AUTH_SERVICE_PORT=8081 @@ -29,19 +30,19 @@ AI_ADAPTER_SERVICE_PORT=8089 AUDIT_SERVICE_PORT=8091 MONITOR_SERVICE_PORT=8092 -GATEWAY_HOST_PORT=8080 -AUTH_SERVICE_HOST_PORT=8081 -PRODUCT_SERVICE_HOST_PORT=8082 -ACTIVITY_SERVICE_HOST_PORT=8083 -RISK_SERVICE_HOST_PORT=8084 -ORDER_SERVICE_HOST_PORT=8085 -PAYMENT_SERVICE_HOST_PORT=8086 -LEGACY_SERVICE_HOST_PORT=18080 -INVENTORY_SERVICE_HOST_PORT=8087 -SECKILL_SERVICE_HOST_PORT=8088 -AI_ADAPTER_SERVICE_HOST_PORT=8089 -AUDIT_SERVICE_HOST_PORT=8091 -MONITOR_SERVICE_HOST_PORT=8092 +GATEWAY_HOST_PORT=18080 +AUTH_SERVICE_HOST_PORT=18081 +PRODUCT_SERVICE_HOST_PORT=18082 +ACTIVITY_SERVICE_HOST_PORT=18083 +RISK_SERVICE_HOST_PORT=18084 +ORDER_SERVICE_HOST_PORT=18085 +PAYMENT_SERVICE_HOST_PORT=18086 +LEGACY_SERVICE_HOST_PORT=18087 +INVENTORY_SERVICE_HOST_PORT=18093 +SECKILL_SERVICE_HOST_PORT=18088 +AI_ADAPTER_SERVICE_HOST_PORT=18089 +AUDIT_SERVICE_HOST_PORT=18091 +MONITOR_SERVICE_HOST_PORT=18092 AUTH_SERVICE_URL=http://sg-auth-service:8081 PRODUCT_SERVICE_URL=http://sg-product-service:8082 @@ -137,14 +138,22 @@ ZIPKIN_PORT=9411 PROMETHEUS_PORT=9090 # Grafana +GRAFANA_IMAGE=grafana/grafana:11.5.2 +GRAFANA_USER=0 GRAFANA_PORT=3000 GRAFANA_ADMIN_PASSWORD=admin +# Sentinel +SENTINEL_DASHBOARD_ADDR=192.168.101.130:8858 +SENTINEL_DASHBOARD_PORT=8858 + # Elasticsearch ES_HOST=192.168.101.130 ES_PORT=9200 # Kibana +KIBANA_IMAGE=docker.elastic.co/kibana/kibana:8.15.3 +KIBANA_ELASTICSEARCH_HOSTS=http://192.168.101.130:9200 KIBANA_PORT=5601 # MinIO @@ -155,12 +164,15 @@ MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin # Seata +SEATA_IP=192.168.101.130 +SEATA_SERVER_ADDR=192.168.101.130:8091 SEATA_PORT=8091 SEATA_CONSOLE_PORT=7091 +SEATA_STORE_MODE=file # Mail Service -MAIL_USERNAME=3514938226@qq.com -MAIL_APP_PASSWORD=qxlicpedspikdaif -MAIL_FROM=SpikeGuard +MAIL_USERNAME=your-email@qq.com +MAIL_APP_PASSWORD=change-me-mail-app-password +MAIL_FROM=SpikeGuard MAIL_HOST=smtp.qq.com MAIL_PORT=465 diff --git a/.gitignore b/.gitignore index 24c5f18..ca4b3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ backend/L*/ backend/*/L*/ .codex/ .agents/ +.worktrees/ +review_diff.txt +*-browser-qa*.png +01-login-page.png +test-*.png frontend-mall/node_modules/ frontend-mall/dist/ diff --git a/01-login-page.png b/01-login-page.png deleted file mode 100644 index f68368f..0000000 Binary files a/01-login-page.png and /dev/null differ diff --git a/README.md b/README.md index 4bd3ec2..e96d82c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ # SpikeGuard -> **v1.1.0** — 中间件增强版本 +> **v1.1.0** — 中间件增强 + 全链路联调稳定版(2026-07-03) SpikeGuard(高并发智能秒杀风控平台)面向电商大促、票务抢购、限量商品发售、优惠券抢领和会员权益秒杀等场景,提供活动配置、库存预热、秒杀令牌、实时风控、Redis Lua 原子扣减、RabbitMQ 异步下单、AI 风控解释、分布式事务、链路追踪、监控告警、日志聚合和审计追踪。 -支持邮箱验证码注册和商城游客预览模式(首页/活动列表无需登录可浏览)。 +支持邮箱验证码注册和商城游客预览模式:商城首页与活动列表无需登录可浏览,活动详情、秒杀、订单和个人中心需登录后访问。 + +## 当前联调状态(2026-07-03) + +- 工作分支:`codex/full-quality-optimization` +- Gateway:`http://192.168.101.130:18080` +- 管理后台:`http://127.0.0.1:5183` +- 商城前端:`http://127.0.0.1:5184` +- 演示数据:商品 20 条、秒杀活动 6 场、活动 SKU 20 条 +- 测试账号由维护者另行提供;不要把真实账号密码提交到 Git。 +- 最新验证覆盖:后端 `mvn test`、管理后台构建、商城构建、API 冒烟、双端浏览器冒烟。 ## 技术栈 @@ -19,10 +29,10 @@ SpikeGuard(高并发智能秒杀风控平台)面向电商大促、票务抢 | --- | --- | --- | | Nacos | 服务注册发现 + 配置中心 | v2.3.0 | | Knife4j | API 文档聚合(/doc.html) | 4.x | -| Prometheus + Grafana | 指标监控与告警 | latest | +| Prometheus + Grafana | 指标监控与告警 | Prometheus latest, Grafana 11.5.2 | | Zipkin | 分布式链路追踪 | 3 | | Sentinel | 限流熔断 | — | -| Elasticsearch + Kibana | 日志聚合与检索 | 8.12.0 | +| Elasticsearch + Kibana | 日志聚合与检索 | Elasticsearch 8.12.0 / Kibana 8.15.3 | | MinIO | 对象存储 | latest | | Seata AT | 分布式事务 | 2.0.0 | @@ -30,6 +40,8 @@ SpikeGuard(高并发智能秒杀风控平台)面向电商大促、票务抢 仓库只提交 `.env.example`。真实 `.env`、数据库密码、Redis 密码、RabbitMQ 密码、虚拟机密码、GitHub Token 和模型 API Key 不得提交到仓库。所有配置文件都通过环境变量读取敏感值。 +邮件配置请在本地 `.env` 中填写真实 QQ SMTP 信息;文档和示例文件统一使用 `MAIL_USERNAME=your-email@qq.com` 与 `MAIL_APP_PASSWORD=change-me-mail-app-password` 占位值。 + ## 快速启动 ```powershell @@ -39,6 +51,8 @@ docker compose config docker compose up -d ``` +Docker build context is constrained by `.dockerignore`; keep runtime volumes such as `docker-data/`, frontend `node_modules/`, and generated screenshots out of image builds. + 后端: ```powershell @@ -61,7 +75,7 @@ conda run -n opspilot uvicorn main:app --host 0.0.0.0 --port 8090 --reload cd L:\ProjectSource\Project\SpikeGuard\frontend npm install npm run build -npm run dev +npm run dev -- --host 127.0.0.1 --port 5183 ``` 前端(商城): @@ -70,10 +84,12 @@ npm run dev cd L:\ProjectSource\Project\SpikeGuard\frontend-mall npm install npm run build -npm run dev +npm run dev -- --host 127.0.0.1 --port 5184 ``` -首次启动后,通过登录页的注册入口创建第一个用户。注册需邮箱验证码(通过 QQ SMTP 发送),系统会将首个注册用户授予管理员角色。商城首页和活动列表支持游客浏览,无需登录。生产环境请使用强密码并妥善保管运行时 `.env`。 +本地 Vite 开发服务器默认通过同源 `/api` 代理访问 Gateway,保持 `VITE_API_BASE_URL` 为空即可;需要代理到虚拟机或远端 Gateway 时,设置 `VITE_API_PROXY_TARGET=http://:18080`。 + +首次启动后,通过登录页的注册入口创建第一个用户。注册需邮箱验证码(通过 QQ SMTP 发送),系统会将首个注册用户授予管理员角色。商城首页和活动列表支持游客浏览,无需登录;活动详情、秒杀、订单和个人中心需要登录。生产环境请使用强密码并妥善保管运行时 `.env`。 ## 系统架构 @@ -117,7 +133,7 @@ graph TB | Kibana 日志 | `http://192.168.101.130:5601` | | MinIO 控制台 | `http://192.168.101.130:9001` | | Seata 控制台 | `http://192.168.101.130:7091` | -| Knife4j API 文档 | `http://localhost:8080/doc.html` | +| Knife4j API 文档 | `http://192.168.101.130:18080/doc.html`(VM)/ `http://localhost:8080/doc.html`(本地) | ## 项目结构 @@ -138,4 +154,5 @@ docker compose config cd backend; L:\Develop\apache-maven-3.9.12\bin\mvn.cmd clean package cd ..\ai-service; conda run -n opspilot python -m compileall . cd ..\frontend; npm install; npm run build +cd ..\frontend-mall; npm install; npm run build ``` diff --git a/backend/sg-activity-service/src/main/resources/application.yml b/backend/sg-activity-service/src/main/resources/application.yml index 132325c..59d8312 100644 --- a/backend/sg-activity-service/src/main/resources/application.yml +++ b/backend/sg-activity-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -56,7 +58,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-ai-adapter-service/src/main/resources/application.yml b/backend/sg-ai-adapter-service/src/main/resources/application.yml index 52c5c1b..92807ee 100644 --- a/backend/sg-ai-adapter-service/src/main/resources/application.yml +++ b/backend/sg-ai-adapter-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -42,7 +44,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: ai: diff --git a/backend/sg-audit-service/src/main/resources/application.yml b/backend/sg-audit-service/src/main/resources/application.yml index 3a88165..6a25434 100644 --- a/backend/sg-audit-service/src/main/resources/application.yml +++ b/backend/sg-audit-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -42,7 +44,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java b/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java index 5f63e7c..f761394 100644 --- a/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java +++ b/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java @@ -46,20 +46,18 @@ public AuthResponse register(RegisterRequest request) { throw new BusinessException(ErrorCode.CONFLICT, "username already exists"); }); - // 如果提供了 email 和 code,则验证 String email = request.email(); - if (email != null && !email.isBlank()) { - if (request.code() == null || request.code().isBlank()) { - throw new BusinessException(ErrorCode.BAD_REQUEST, "验证码不能为空"); - } - var vcRecord = verificationCodeRepository - .findTopByEmailAndCodeAndUsedFalseOrderByCreatedAtDesc(email, request.code()) - .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "验证码无效")); - if (vcRecord.expiresAt().isBefore(LocalDateTime.now())) { - throw new BusinessException(ErrorCode.BAD_REQUEST, "验证码已过期"); - } - verificationCodeRepository.markUsed(vcRecord.id()); + String code = request.code(); + if (email == null || email.isBlank() || code == null || code.isBlank()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "邮箱和验证码不能为空"); } + var vcRecord = verificationCodeRepository + .findTopByEmailAndCodeAndUsedFalseOrderByCreatedAtDesc(email, code) + .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "验证码无效")); + if (vcRecord.expiresAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "验证码已过期"); + } + verificationCodeRepository.markUsed(vcRecord.id()); Set roles = userRepository.isEmpty() ? Set.of("ADMIN", "USER") : Set.of("USER"); String nickname = request.nickname() == null || request.nickname().isBlank() ? request.username() : request.nickname(); diff --git a/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/EmailService.java b/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/EmailService.java index f48ad2d..409264b 100644 --- a/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/EmailService.java +++ b/backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/EmailService.java @@ -28,27 +28,27 @@ public void sendVerificationCode(String toEmail, String code) { MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom(fromAddress); helper.setTo(toEmail); - helper.setSubject("SpikeGuard 邮箱验证码"); + helper.setSubject("SpikeGuard verification code"); helper.setText(buildHtmlContent(code), true); mailSender.send(message); log.info("Verification code sent to {}", toEmail); } catch (MessagingException e) { log.error("Failed to send verification code to {}: {}", toEmail, e.getMessage()); - throw new RuntimeException("邮件发送失败", e); + throw new RuntimeException("Failed to send verification email", e); } } private String buildHtmlContent(String code) { return """
-

SpikeGuard 验证码

-

您的验证码为:

+

SpikeGuard verification code

+

Your verification code is:

%s
-

验证码 5 分钟内有效,请勿泄露给他人。

+

This code is valid for 5 minutes. Do not share it with anyone.

""".formatted(code); } diff --git a/backend/sg-auth-service/src/main/resources/application.yml b/backend/sg-auth-service/src/main/resources/application.yml index d8ddaae..3294249 100644 --- a/backend/sg-auth-service/src/main/resources/application.yml +++ b/backend/sg-auth-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -53,7 +55,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: security: diff --git a/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java b/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java index 2fc5e92..0c97738 100644 --- a/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java +++ b/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java @@ -6,12 +6,14 @@ import com.kaduox.spikeguard.auth.model.LoginRequest; import com.kaduox.spikeguard.auth.model.RegisterRequest; import com.kaduox.spikeguard.auth.service.AuthService; +import com.kaduox.spikeguard.auth.service.EmailService; import com.kaduox.spikeguard.common.error.BusinessException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; @SpringBootTest class AuthServiceTest { @@ -21,6 +23,9 @@ class AuthServiceTest { @Autowired JdbcTemplate jdbcTemplate; + @MockBean + EmailService emailService; + @BeforeEach void cleanUsers() { jdbcTemplate.update("delete from sg_verification_code"); @@ -29,19 +34,42 @@ void cleanUsers() { } @Test - void firstRegisteredUserReceivesAdminAndCanLogin() { - var registered = authService.register(new RegisterRequest("alice", "secret123", "Alice", null, null)); + void registrationRequiresEmailAndCode() { + assertThatThrownBy(() -> authService.register(new RegisterRequest("alice", "secret123", "Alice", null, null))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("邮箱和验证码不能为空"); + } + + @Test + void validVerificationCodeRegistersAndCanLogin() { + authService.sendVerificationCode("alice@example.com", "123456"); + + var registered = authService.register(new RegisterRequest( + "alice", "secret123", "Alice", "alice@example.com", "123456")); assertThat(registered.token()).isNotBlank(); + assertThat(registered.user().email()).isEqualTo("alice@example.com"); assertThat(registered.user().roles()).contains("ADMIN", "USER"); var loggedIn = authService.login(new LoginRequest("alice", "secret123")); assertThat(loggedIn.user().id()).isEqualTo(registered.user().id()); } + @Test + void usedVerificationCodeIsRejected() { + authService.sendVerificationCode("bob@example.com", "123456"); + authService.register(new RegisterRequest("bob", "secret123", "Bob", "bob@example.com", "123456")); + + assertThatThrownBy(() -> authService.register(new RegisterRequest( + "bobby", "secret123", "Bobby", "bob@example.com", "123456"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("验证码无效"); + } + @Test void duplicateUsernameIsRejected() { - authService.register(new RegisterRequest("bob", "secret123", "Bob", null, null)); + authService.sendVerificationCode("bob@example.com", "123456"); + authService.register(new RegisterRequest("bob", "secret123", "Bob", "bob@example.com", "123456")); assertThatThrownBy(() -> authService.register(new RegisterRequest("bob", "secret123", "Bob Again", null, null))) .isInstanceOf(BusinessException.class) diff --git a/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/service/EmailServiceTest.java b/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/service/EmailServiceTest.java new file mode 100644 index 0000000..e4227ad --- /dev/null +++ b/backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/service/EmailServiceTest.java @@ -0,0 +1,40 @@ +package com.kaduox.spikeguard.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +class EmailServiceTest { + + @Test + void sendsReadableVerificationCodeEmail() throws Exception { + JavaMailSender mailSender = mock(JavaMailSender.class); + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + when(mailSender.createMimeMessage()).thenReturn(message); + EmailService emailService = new EmailService(mailSender); + ReflectionTestUtils.setField(emailService, "fromAddress", "SpikeGuard "); + + emailService.sendVerificationCode("alice@example.com", "123456"); + + verify(mailSender).send(message); + assertThat(message.getSubject()).isEqualTo("SpikeGuard verification code"); + assertThat(message.getFrom()[0].toString()).contains("sender@example.com"); + assertThat(message.getAllRecipients()[0].toString()).isEqualTo("alice@example.com"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + message.writeTo(out); + String rawMessage = out.toString(StandardCharsets.UTF_8); + assertThat(rawMessage).contains("123456"); + assertThat(rawMessage).contains("valid for 5 minutes"); + } +} diff --git a/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfig.java b/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfig.java index 26eaeb1..9e07557 100644 --- a/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfig.java +++ b/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfig.java @@ -10,10 +10,8 @@ import com.alibaba.csp.sentinel.adapter.gateway.common.api.GatewayApiDefinitionManager; import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayFlowRule; import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayRuleManager; -import com.alibaba.csp.sentinel.adapter.gateway.sc.SentinelGatewayFilter; import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.BlockRequestHandler; import jakarta.annotation.PostConstruct; -import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @@ -32,14 +30,6 @@ @Configuration public class GatewaySentinelConfig { - /** - * Register Sentinel gateway filter as a global filter. - */ - @Bean - public GlobalFilter sentinelGatewayFilter() { - return new SentinelGatewayFilter(); - } - /** * Initialize gateway flow rules and API definitions. */ diff --git a/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilter.java b/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilter.java index 72a0995..b6c0f77 100644 --- a/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilter.java +++ b/backend/sg-gateway/src/main/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilter.java @@ -9,6 +9,7 @@ import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; @@ -16,13 +17,14 @@ @Component public class JwtGatewayFilter implements GlobalFilter, Ordered { - private static final Set PUBLIC_PREFIXES = Set.of( - "/api/auth/register", - "/api/auth/login", - "/api/auth/send-code", - "/actuator/health", - "/v3/api-docs", - "/swagger-ui" + private static final Set PUBLIC_ROUTES = Set.of( + PublicRoute.of(HttpMethod.POST, "/api/auth/register"), + PublicRoute.of(HttpMethod.POST, "/api/auth/login"), + PublicRoute.of(HttpMethod.POST, "/api/auth/send-code"), + PublicRoute.of(HttpMethod.GET, "/api/activities"), + PublicRoute.anyMethod("/actuator/health"), + PublicRoute.anyMethod("/v3/api-docs"), + PublicRoute.anyMethod("/swagger-ui") ); private final JwtService jwtService; @@ -34,7 +36,8 @@ public JwtGatewayFilter(JwtService jwtService) { @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getPath().value(); - if (isPublic(path) || "OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) { + HttpMethod method = exchange.getRequest().getMethod(); + if (isPublic(method, path) || HttpMethod.OPTIONS.equals(method)) { return chain.filter(exchange); } String auth = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); @@ -60,8 +63,8 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { } } - private boolean isPublic(String path) { - return PUBLIC_PREFIXES.stream().anyMatch(path::startsWith); + private boolean isPublic(HttpMethod method, String path) { + return PUBLIC_ROUTES.stream().anyMatch(route -> route.matches(method, path)); } private Mono reject(ServerWebExchange exchange) { @@ -74,4 +77,21 @@ private Mono reject(ServerWebExchange exchange) { public int getOrder() { return -100; } + + private record PublicRoute(HttpMethod method, String pathPrefix) { + private static PublicRoute of(HttpMethod method, String pathPrefix) { + return new PublicRoute(method, pathPrefix); + } + + private static PublicRoute anyMethod(String pathPrefix) { + return new PublicRoute(null, pathPrefix); + } + + private boolean matches(HttpMethod requestMethod, String path) { + if (method != null && !method.equals(requestMethod)) { + return false; + } + return path.equals(pathPrefix) || path.startsWith(pathPrefix + "/"); + } + } } diff --git a/backend/sg-gateway/src/main/resources/application.yml b/backend/sg-gateway/src/main/resources/application.yml index b582aad..510499a 100644 --- a/backend/sg-gateway/src/main/resources/application.yml +++ b/backend/sg-gateway/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -34,47 +36,47 @@ spring: allowCredentials: false routes: - id: auth-service - uri: lb://sg-auth-service + uri: ${AUTH_SERVICE_URL:lb://sg-auth-service} predicates: - Path=/api/auth/**, /api/users/** - id: product-service - uri: lb://sg-product-service + uri: ${PRODUCT_SERVICE_URL:lb://sg-product-service} predicates: - Path=/api/products/** - id: activity-service - uri: lb://sg-activity-service + uri: ${ACTIVITY_SERVICE_URL:lb://sg-activity-service} predicates: - Path=/api/activities/**, /api/files/** - id: risk-service - uri: lb://sg-risk-service + uri: ${RISK_SERVICE_URL:lb://sg-risk-service} predicates: - Path=/api/risk/** - id: order-service - uri: lb://sg-order-service + uri: ${ORDER_SERVICE_URL:lb://sg-order-service} predicates: - Path=/api/orders/** - id: payment-service - uri: lb://sg-payment-service + uri: ${PAYMENT_SERVICE_URL:lb://sg-payment-service} predicates: - Path=/api/payments/** - id: inventory-service - uri: lb://sg-inventory-service + uri: ${INVENTORY_SERVICE_URL:lb://sg-inventory-service} predicates: - Path=/api/inventory/** - id: seckill-service - uri: lb://sg-seckill-service + uri: ${SECKILL_SERVICE_URL:lb://sg-seckill-service} predicates: - Path=/api/seckill/** - id: ai-adapter-service - uri: lb://sg-ai-adapter-service + uri: ${AI_ADAPTER_SERVICE_URL:lb://sg-ai-adapter-service} predicates: - Path=/api/ai/** - id: audit-service - uri: lb://sg-audit-service + uri: ${AUDIT_SERVICE_URL:lb://sg-audit-service} predicates: - Path=/api/audit/** - id: monitor-service - uri: lb://sg-monitor-service + uri: ${MONITOR_SERVICE_URL:lb://sg-monitor-service} predicates: - Path=/api/monitor/** - id: legacy-monolith @@ -99,7 +101,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: security: diff --git a/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/GatewayRouteConfigTest.java b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/GatewayRouteConfigTest.java index 6a1bb44..99e6a8f 100644 --- a/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/GatewayRouteConfigTest.java +++ b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/GatewayRouteConfigTest.java @@ -16,60 +16,61 @@ void routesUseLoadBalancerForNacosServiceDiscovery() { // Discovery locator enabled assertThat(properties.getProperty("spring.cloud.gateway.discovery.locator.enabled")).isEqualTo("true"); assertThat(properties.getProperty("spring.cloud.gateway.discovery.locator.lower-case-service-id")).isEqualTo("true"); + assertThat(properties.getProperty("spring.cloud.nacos.config.import-check.enabled")).isEqualTo("false"); // Auth service route assertThat(properties.getProperty("spring.cloud.gateway.routes[0].id")).isEqualTo("auth-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[0].uri")).isEqualTo("lb://sg-auth-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[0].uri")).isEqualTo("${AUTH_SERVICE_URL:lb://sg-auth-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[0].predicates[0]")).isEqualTo("Path=/api/auth/**, /api/users/**"); // Product service route assertThat(properties.getProperty("spring.cloud.gateway.routes[1].id")).isEqualTo("product-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[1].uri")).isEqualTo("lb://sg-product-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[1].uri")).isEqualTo("${PRODUCT_SERVICE_URL:lb://sg-product-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[1].predicates[0]")).isEqualTo("Path=/api/products/**"); // Activity service route assertThat(properties.getProperty("spring.cloud.gateway.routes[2].id")).isEqualTo("activity-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[2].uri")).isEqualTo("lb://sg-activity-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[2].uri")).isEqualTo("${ACTIVITY_SERVICE_URL:lb://sg-activity-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[2].predicates[0]")).isEqualTo("Path=/api/activities/**, /api/files/**"); // Risk service route assertThat(properties.getProperty("spring.cloud.gateway.routes[3].id")).isEqualTo("risk-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[3].uri")).isEqualTo("lb://sg-risk-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[3].uri")).isEqualTo("${RISK_SERVICE_URL:lb://sg-risk-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[3].predicates[0]")).isEqualTo("Path=/api/risk/**"); // Order service route assertThat(properties.getProperty("spring.cloud.gateway.routes[4].id")).isEqualTo("order-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[4].uri")).isEqualTo("lb://sg-order-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[4].uri")).isEqualTo("${ORDER_SERVICE_URL:lb://sg-order-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[4].predicates[0]")).isEqualTo("Path=/api/orders/**"); // Payment service route assertThat(properties.getProperty("spring.cloud.gateway.routes[5].id")).isEqualTo("payment-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[5].uri")).isEqualTo("lb://sg-payment-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[5].uri")).isEqualTo("${PAYMENT_SERVICE_URL:lb://sg-payment-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[5].predicates[0]")).isEqualTo("Path=/api/payments/**"); // Inventory service route assertThat(properties.getProperty("spring.cloud.gateway.routes[6].id")).isEqualTo("inventory-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[6].uri")).isEqualTo("lb://sg-inventory-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[6].uri")).isEqualTo("${INVENTORY_SERVICE_URL:lb://sg-inventory-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[6].predicates[0]")).isEqualTo("Path=/api/inventory/**"); // Seckill service route assertThat(properties.getProperty("spring.cloud.gateway.routes[7].id")).isEqualTo("seckill-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[7].uri")).isEqualTo("lb://sg-seckill-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[7].uri")).isEqualTo("${SECKILL_SERVICE_URL:lb://sg-seckill-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[7].predicates[0]")).isEqualTo("Path=/api/seckill/**"); // AI adapter service route assertThat(properties.getProperty("spring.cloud.gateway.routes[8].id")).isEqualTo("ai-adapter-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[8].uri")).isEqualTo("lb://sg-ai-adapter-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[8].uri")).isEqualTo("${AI_ADAPTER_SERVICE_URL:lb://sg-ai-adapter-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[8].predicates[0]")).isEqualTo("Path=/api/ai/**"); // Audit service route assertThat(properties.getProperty("spring.cloud.gateway.routes[9].id")).isEqualTo("audit-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[9].uri")).isEqualTo("lb://sg-audit-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[9].uri")).isEqualTo("${AUDIT_SERVICE_URL:lb://sg-audit-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[9].predicates[0]")).isEqualTo("Path=/api/audit/**"); // Monitor service route assertThat(properties.getProperty("spring.cloud.gateway.routes[10].id")).isEqualTo("monitor-service"); - assertThat(properties.getProperty("spring.cloud.gateway.routes[10].uri")).isEqualTo("lb://sg-monitor-service"); + assertThat(properties.getProperty("spring.cloud.gateway.routes[10].uri")).isEqualTo("${MONITOR_SERVICE_URL:lb://sg-monitor-service}"); assertThat(properties.getProperty("spring.cloud.gateway.routes[10].predicates[0]")).isEqualTo("Path=/api/monitor/**"); // Legacy monolith still uses direct URL (not registered in Nacos) diff --git a/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfigTest.java b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfigTest.java new file mode 100644 index 0000000..e1f695e --- /dev/null +++ b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/config/GatewaySentinelConfigTest.java @@ -0,0 +1,16 @@ +package com.kaduox.spikeguard.gateway.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +class GatewaySentinelConfigTest { + @Test + void reliesOnSentinelAutoConfigurationForGatewayFilterBean() { + assertThat(Arrays.stream(GatewaySentinelConfig.class.getDeclaredMethods()).map(Method::getName)) + .doesNotContain("sentinelGatewayFilter"); + } +} diff --git a/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilterTest.java b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilterTest.java index 591489f..0427a77 100644 --- a/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilterTest.java +++ b/backend/sg-gateway/src/test/java/com/kaduox/spikeguard/gateway/security/JwtGatewayFilterTest.java @@ -40,6 +40,26 @@ void protectedPathRejectsMissingToken() { assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + @Test + void publicActivityReadPathPassesWithoutToken() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/activities")); + AtomicReference seen = new AtomicReference<>(); + + filter.filter(exchange, capture(seen)).block(); + + assertThat(seen.get()).isNotNull(); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + } + + @Test + void activityWritePathRejectsMissingToken() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/api/activities")); + + filter.filter(exchange, chain -> Mono.empty()).block(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + @Test void validTokenAddsTrustedUserHeaders() { String token = jwtService.issue(7L, "alice", Set.of("ADMIN", "USER")); diff --git a/backend/sg-inventory-service/src/main/java/com/kaduox/spikeguard/inventory/config/RedisConfig.java b/backend/sg-inventory-service/src/main/java/com/kaduox/spikeguard/inventory/config/RedisConfig.java index d3ba1e0..b95a355 100644 --- a/backend/sg-inventory-service/src/main/java/com/kaduox/spikeguard/inventory/config/RedisConfig.java +++ b/backend/sg-inventory-service/src/main/java/com/kaduox/spikeguard/inventory/config/RedisConfig.java @@ -2,13 +2,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class RedisConfig { @Bean - public StringRedisTemplate stringRedisTemplate() { - return new StringRedisTemplate(); + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); } } diff --git a/backend/sg-inventory-service/src/main/resources/application.yml b/backend/sg-inventory-service/src/main/resources/application.yml index a3bddaf..5d679c0 100644 --- a/backend/sg-inventory-service/src/main/resources/application.yml +++ b/backend/sg-inventory-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -38,16 +40,12 @@ seata: service: vgroup-mapping: spikeguard-seata-group: default + grouplist: + default: ${SEATA_SERVER_ADDR:192.168.101.130:8091} registry: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file config: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file management: endpoints: @@ -64,7 +62,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: redis: diff --git a/backend/sg-inventory-service/src/test/java/com/kaduox/spikeguard/inventory/config/RedisConfigTest.java b/backend/sg-inventory-service/src/test/java/com/kaduox/spikeguard/inventory/config/RedisConfigTest.java new file mode 100644 index 0000000..5c43d2b --- /dev/null +++ b/backend/sg-inventory-service/src/test/java/com/kaduox/spikeguard/inventory/config/RedisConfigTest.java @@ -0,0 +1,37 @@ +package com.kaduox.spikeguard.inventory.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +class RedisConfigTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(RedisConfig.class, RedisConnectionFactoryTestConfig.class); + + @Test + void stringRedisTemplateUsesConfiguredConnectionFactory() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + RedisConnectionFactory redisConnectionFactory = context.getBean(RedisConnectionFactory.class); + StringRedisTemplate stringRedisTemplate = context.getBean(StringRedisTemplate.class); + + assertThat(stringRedisTemplate.getConnectionFactory()).isSameAs(redisConnectionFactory); + }); + } + + @Configuration + static class RedisConnectionFactoryTestConfig { + + @Bean + RedisConnectionFactory redisConnectionFactory() { + return mock(RedisConnectionFactory.class); + } + } +} diff --git a/backend/sg-legacy-monolith/src/main/resources/application.yml b/backend/sg-legacy-monolith/src/main/resources/application.yml index 5524158..8cb3f81 100644 --- a/backend/sg-legacy-monolith/src/main/resources/application.yml +++ b/backend/sg-legacy-monolith/src/main/resources/application.yml @@ -60,7 +60,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: gateway: diff --git a/backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java b/backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java index 4fb1ad2..406b819 100644 --- a/backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java +++ b/backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java @@ -17,6 +17,8 @@ public class MonitorService { private static final Logger log = LoggerFactory.getLogger(MonitorService.class); + private static final ParameterizedTypeReference> MAP_RESPONSE = + new ParameterizedTypeReference<>() {}; private final RestClient orderRestClient; private final RestClient paymentRestClient; @@ -63,10 +65,7 @@ public Map realtime(Long activityId) { */ public Map fetchRiskSummary() { try { - return riskRestClient.get() - .uri("/api/risk/summary") - .retrieve() - .body(new ParameterizedTypeReference>() {}); + return fetchMap(riskRestClient, "/api/risk/summary"); } catch (Exception ex) { log.warn("Failed to fetch risk summary: {}", ex.getMessage()); return Map.of(); @@ -78,10 +77,7 @@ public Map fetchRiskSummary() { */ public Map fetchQueueSummary() { try { - return orderRestClient.get() - .uri("/api/order/queue-summary") - .retrieve() - .body(new ParameterizedTypeReference>() {}); + return fetchMap(orderRestClient, "/api/orders/queue-summary"); } catch (Exception ex) { log.warn("Failed to fetch queue summary: {}", ex.getMessage()); return Map.of(); @@ -90,11 +86,7 @@ public Map fetchQueueSummary() { private long fetchOrders() { try { - Long count = orderRestClient.get() - .uri("/api/order/count") - .retrieve() - .body(Long.class); - return count != null ? count : 0L; + return fetchCount(orderRestClient, "/api/orders/count"); } catch (Exception ex) { log.warn("Failed to fetch order count: {}", ex.getMessage()); return 0L; @@ -103,14 +95,39 @@ private long fetchOrders() { private long fetchPayments() { try { - Long count = paymentRestClient.get() - .uri("/api/payment/count") - .retrieve() - .body(Long.class); - return count != null ? count : 0L; + return fetchCount(paymentRestClient, "/api/payments/count"); } catch (Exception ex) { log.warn("Failed to fetch payment count: {}", ex.getMessage()); return 0L; } } + + private Map fetchMap(RestClient restClient, String path) { + Map response = restClient.get() + .uri(path) + .retrieve() + .body(MAP_RESPONSE); + Object data = response == null ? null : response.get("data"); + if (!(data instanceof Map dataMap)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + dataMap.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + + private long fetchCount(RestClient restClient, String path) { + Map response = restClient.get() + .uri(path) + .retrieve() + .body(MAP_RESPONSE); + Object data = response == null ? null : response.get("data"); + if (data instanceof Number number) { + return number.longValue(); + } + if (data instanceof String value) { + return Long.parseLong(value); + } + return 0L; + } } diff --git a/backend/sg-monitor-service/src/main/resources/application.yml b/backend/sg-monitor-service/src/main/resources/application.yml index 9f46e7a..79aa66b 100644 --- a/backend/sg-monitor-service/src/main/resources/application.yml +++ b/backend/sg-monitor-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -34,7 +36,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: services: diff --git a/backend/sg-monitor-service/src/test/java/com/kaduox/spikeguard/monitor/MonitorServiceHttpTest.java b/backend/sg-monitor-service/src/test/java/com/kaduox/spikeguard/monitor/MonitorServiceHttpTest.java new file mode 100644 index 0000000..3dd471c --- /dev/null +++ b/backend/sg-monitor-service/src/test/java/com/kaduox/spikeguard/monitor/MonitorServiceHttpTest.java @@ -0,0 +1,87 @@ +package com.kaduox.spikeguard.monitor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Stream; + +import com.kaduox.spikeguard.monitor.service.MonitorService; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +class MonitorServiceHttpTest { + private HttpServer orderServer; + private HttpServer paymentServer; + private HttpServer riskServer; + private final List orderPaths = new CopyOnWriteArrayList<>(); + private final List paymentPaths = new CopyOnWriteArrayList<>(); + private final List riskPaths = new CopyOnWriteArrayList<>(); + + @AfterEach + void tearDown() { + Stream.of(orderServer, paymentServer, riskServer) + .filter(Objects::nonNull) + .forEach(server -> server.stop(0)); + } + + @Test + void overviewCallsDocumentedServicePathsAndUnwrapsApiResponses() throws Exception { + orderServer = server(orderPaths, Map.of( + "/api/orders/count", "{\"code\":0,\"message\":\"success\",\"data\":3}", + "/api/orders/queue-summary", "{\"code\":0,\"message\":\"success\",\"data\":{\"createdOrders\":3,\"pendingOrders\":1}}")); + paymentServer = server(paymentPaths, Map.of( + "/api/payments/count", "{\"code\":0,\"message\":\"success\",\"data\":2}")); + riskServer = server(riskPaths, Map.of( + "/api/risk/summary", "{\"code\":0,\"message\":\"success\",\"data\":{\"pass\":1,\"review\":1,\"block\":0,\"events\":2}}")); + + var service = new MonitorService( + RestClient.builder().baseUrl(baseUrl(orderServer)).build(), + RestClient.builder().baseUrl(baseUrl(paymentServer)).build(), + RestClient.builder().baseUrl(baseUrl(riskServer)).build()); + + var snapshot = service.overview(); + + assertThat(snapshot.orders()).isEqualTo(3); + assertThat(snapshot.payments()).isEqualTo(2); + assertThat(snapshot.queue()).containsEntry("pendingOrders", 1); + assertThat(snapshot.risk()).containsEntry("events", 2); + assertThat(orderPaths).containsExactlyInAnyOrder("/api/orders/count", "/api/orders/queue-summary"); + assertThat(paymentPaths).containsExactly("/api/payments/count"); + assertThat(riskPaths).containsExactly("/api/risk/summary"); + } + + private static HttpServer server(List paths, Map responses) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + paths.add(path); + String response = responses.get(path); + if (response == null) { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + return; + } + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream body = exchange.getResponseBody()) { + body.write(bytes); + } + }); + server.start(); + return server; + } + + private static String baseUrl(HttpServer server) { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } +} diff --git a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/repository/OrderRepository.java b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/repository/OrderRepository.java index 73c8824..ce55fa9 100644 --- a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/repository/OrderRepository.java +++ b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/repository/OrderRepository.java @@ -67,6 +67,16 @@ public List findCreatedBefore(LocalDateTime cutoff, int limit) { (rs, rowNum) -> map(rs), cutoff, limit); } + public long count() { + Long count = jdbcTemplate.queryForObject("select count(*) from sg_order", Long.class); + return count == null ? 0L : count; + } + + public long countByStatus(OrderStatus status) { + Long count = jdbcTemplate.queryForObject("select count(*) from sg_order where status = ?", Long.class, status.name()); + return count == null ? 0L : count; + } + public OrderVO updateStatus(Long id, OrderStatus status) { jdbcTemplate.update("update sg_order set status = ? where id = ?", status.name(), id); return findById(id).orElseThrow(); diff --git a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/service/OrderService.java b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/service/OrderService.java index ba97506..d586336 100644 --- a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/service/OrderService.java +++ b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/service/OrderService.java @@ -4,6 +4,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; import com.kaduox.spikeguard.common.error.BusinessException; @@ -83,6 +84,16 @@ public List recent(int limit) { return orderRepository.findRecent(safeLimit); } + public long count() { + return orderRepository.count(); + } + + public Map queueSummary() { + return Map.of( + "createdOrders", count(), + "pendingOrders", orderRepository.countByStatus(OrderStatus.CREATED)); + } + @Transactional public OrderVO cancel(Long id) { return cancelWithReason(get(id), "manual cancel"); diff --git a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/web/OrderController.java b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/web/OrderController.java index ce605f0..d2b9dd2 100644 --- a/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/web/OrderController.java +++ b/backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/web/OrderController.java @@ -1,6 +1,7 @@ package com.kaduox.spikeguard.order.web; import java.util.List; +import java.util.Map; import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.kaduox.spikeguard.common.api.ApiResponse; @@ -41,6 +42,18 @@ ApiResponse> recent(@RequestParam(defaultValue = "20") int limit) return ApiResponse.success(orderService.recent(limit)); } + @GetMapping("/count") + @Operation(summary = "订单总数") + ApiResponse count() { + return ApiResponse.success(orderService.count()); + } + + @GetMapping("/queue-summary") + @Operation(summary = "订单队列摘要") + ApiResponse> queueSummary() { + return ApiResponse.success(orderService.queueSummary()); + } + @GetMapping("/{id}") @Operation(summary = "获取订单详情") ApiResponse get(@PathVariable Long id) { diff --git a/backend/sg-order-service/src/main/resources/application.yml b/backend/sg-order-service/src/main/resources/application.yml index 1e07a70..0c89467 100644 --- a/backend/sg-order-service/src/main/resources/application.yml +++ b/backend/sg-order-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -33,16 +35,12 @@ seata: service: vgroup-mapping: spikeguard-seata-group: default + grouplist: + default: ${SEATA_SERVER_ADDR:192.168.101.130:8091} registry: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file config: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file spikeguard: order: @@ -67,7 +65,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-order-service/src/test/java/com/kaduox/spikeguard/order/OrderServiceTest.java b/backend/sg-order-service/src/test/java/com/kaduox/spikeguard/order/OrderServiceTest.java index 64a56d5..09f7c6f 100644 --- a/backend/sg-order-service/src/test/java/com/kaduox/spikeguard/order/OrderServiceTest.java +++ b/backend/sg-order-service/src/test/java/com/kaduox/spikeguard/order/OrderServiceTest.java @@ -183,6 +183,20 @@ void timeoutJobCancelsExpiredUnpaidOrders() { assertThat(lifecycleEventPublisher.cancelledEvents()).hasSize(1); } + @Test + void queueSummaryCountsAllOrdersAndCreatedOrders() { + orderService.create(new OrderCreateRequest("req-summary-1", 10101L, 20101L, 30101L, 1, null, null)); + OrderVO paid = orderService.create(new OrderCreateRequest("req-summary-2", 10102L, 20102L, 30102L, 1, null, null)); + OrderVO cancelled = orderService.create(new OrderCreateRequest("req-summary-3", 10103L, 20103L, 30103L, 1, null, null)); + orderService.markPaid(paid.id()); + orderService.cancel(cancelled.id()); + + assertThat(orderService.count()).isEqualTo(3L); + assertThat(orderService.queueSummary()) + .containsEntry("createdOrders", 3L) + .containsEntry("pendingOrders", 1L); + } + private static final class CapturingLifecycleEventPublisher implements OrderLifecycleEventPublisher { private final List createdEvents = new ArrayList<>(); private final List cancelledEvents = new ArrayList<>(); diff --git a/backend/sg-payment-service/src/main/java/com/kaduox/spikeguard/payment/web/PaymentController.java b/backend/sg-payment-service/src/main/java/com/kaduox/spikeguard/payment/web/PaymentController.java index e40c6f1..b5b8ccb 100644 --- a/backend/sg-payment-service/src/main/java/com/kaduox/spikeguard/payment/web/PaymentController.java +++ b/backend/sg-payment-service/src/main/java/com/kaduox/spikeguard/payment/web/PaymentController.java @@ -37,4 +37,10 @@ ApiResponse payMock(@Valid @RequestBody PaymentMockRequest request) { ApiResponse> recent(@RequestParam(defaultValue = "20") int limit) { return ApiResponse.success(paymentService.recent(limit)); } + + @GetMapping("/count") + @Operation(summary = "支付总数") + ApiResponse count() { + return ApiResponse.success(paymentService.count()); + } } diff --git a/backend/sg-payment-service/src/main/resources/application.yml b/backend/sg-payment-service/src/main/resources/application.yml index cc45611..19327c6 100644 --- a/backend/sg-payment-service/src/main/resources/application.yml +++ b/backend/sg-payment-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -33,16 +35,12 @@ seata: service: vgroup-mapping: spikeguard-seata-group: default + grouplist: + default: ${SEATA_SERVER_ADDR:192.168.101.130:8091} registry: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file config: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file spikeguard: messaging: @@ -64,7 +62,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-product-service/src/main/resources/application.yml b/backend/sg-product-service/src/main/resources/application.yml index 340376d..4635aba 100644 --- a/backend/sg-product-service/src/main/resources/application.yml +++ b/backend/sg-product-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -42,7 +44,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskDecisionRepository.java b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskDecisionRepository.java index a3d42b4..52f4d66 100644 --- a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskDecisionRepository.java +++ b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskDecisionRepository.java @@ -54,6 +54,14 @@ public List findRecent(int limit) { return jdbcTemplate.query(selectSql() + " order by id desc limit ?", (rs, rowNum) -> map(rs), limit); } + public long countByDecision(RiskAction decision) { + Long count = jdbcTemplate.queryForObject( + "select count(*) from sg_risk_decision where decision = ?", + Long.class, + decision.name()); + return count == null ? 0L : count; + } + private String selectSql() { return """ select id, request_id, activity_id, sku_id, user_id, score, decision, hit_rules, reason, created_at diff --git a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskEventRepository.java b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskEventRepository.java index dc08ded..503eee4 100644 --- a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskEventRepository.java +++ b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskEventRepository.java @@ -35,6 +35,11 @@ public List findRecent(int limit) { """, (rs, rowNum) -> map(rs), limit); } + public long count() { + Long count = jdbcTemplate.queryForObject("select count(*) from sg_risk_event", Long.class); + return count == null ? 0L : count; + } + private RiskEventVO map(ResultSet rs) throws SQLException { return new RiskEventVO( rs.getLong("id"), diff --git a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/service/RiskDecisionService.java b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/service/RiskDecisionService.java index 766b9e0..222e28d 100644 --- a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/service/RiskDecisionService.java +++ b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/service/RiskDecisionService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.kaduox.spikeguard.common.error.BusinessException; import com.kaduox.spikeguard.common.error.ErrorCode; @@ -79,6 +80,14 @@ public RiskDecisionVO getByRequestId(String requestId) { .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "risk decision not found")); } + public Map summary() { + return Map.of( + "pass", riskDecisionRepository.countByDecision(RiskAction.PASS), + "review", riskDecisionRepository.countByDecision(RiskAction.REVIEW), + "block", riskDecisionRepository.countByDecision(RiskAction.BLOCK), + "events", riskEventRepository.count()); + } + private boolean matches(RiskRuleVO rule, RiskEventRequest request) { String expression = rule.conditionExpression().toLowerCase(); return containsValue(expression, "event_type", request.eventType()) diff --git a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/web/RiskDecisionController.java b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/web/RiskDecisionController.java index e41cd1f..816ad3f 100644 --- a/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/web/RiskDecisionController.java +++ b/backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/web/RiskDecisionController.java @@ -1,6 +1,7 @@ package com.kaduox.spikeguard.risk.web; import java.util.List; +import java.util.Map; import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.kaduox.spikeguard.common.api.ApiResponse; @@ -48,6 +49,12 @@ ApiResponse> recentEvents(@RequestParam(defaultValue = "20") i return ApiResponse.success(riskDecisionService.recentEvents(limit)); } + @GetMapping("/summary") + @Operation(summary = "风控摘要") + ApiResponse> summary() { + return ApiResponse.success(riskDecisionService.summary()); + } + @GetMapping("/decisions/{requestId}") @Operation(summary = "按请求ID查询风控决策") ApiResponse getByRequestId(@PathVariable String requestId) { diff --git a/backend/sg-risk-service/src/main/resources/application.yml b/backend/sg-risk-service/src/main/resources/application.yml index 3f27c56..7e19412 100644 --- a/backend/sg-risk-service/src/main/resources/application.yml +++ b/backend/sg-risk-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -42,7 +44,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans springdoc: api-docs: diff --git a/backend/sg-risk-service/src/test/java/com/kaduox/spikeguard/risk/service/RiskDecisionServiceTest.java b/backend/sg-risk-service/src/test/java/com/kaduox/spikeguard/risk/service/RiskDecisionServiceTest.java index 6e40d48..94bb0e0 100644 --- a/backend/sg-risk-service/src/test/java/com/kaduox/spikeguard/risk/service/RiskDecisionServiceTest.java +++ b/backend/sg-risk-service/src/test/java/com/kaduox/spikeguard/risk/service/RiskDecisionServiceTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.UUID; + import com.kaduox.spikeguard.common.error.BusinessException; import com.kaduox.spikeguard.risk.model.RiskAction; import com.kaduox.spikeguard.risk.model.RiskEventRequest; @@ -10,6 +12,7 @@ import com.kaduox.spikeguard.risk.repository.RiskDecisionRepository; import com.kaduox.spikeguard.risk.repository.RiskEventRepository; import com.kaduox.spikeguard.risk.repository.RiskRuleRepository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -26,6 +29,7 @@ class RiskDecisionServiceTest { void setUp() { database = new EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) + .setName("risk_service_" + UUID.randomUUID() + ";MODE=PostgreSQL") .addScript("db/migration/V1__risk_schema.sql") .build(); JdbcTemplate jdbcTemplate = new JdbcTemplate(database); @@ -36,6 +40,11 @@ void setUp() { new RiskDecisionRepository(jdbcTemplate)); } + @AfterEach + void tearDown() { + database.shutdown(); + } + @Test void evaluatesBlockDecisionWhenRuleMatches() { ruleRepository.create(new RiskRuleRequest( @@ -74,4 +83,25 @@ void listsRecentEventsNewestFirst() { assertThat(events.get(0).userId()).isEqualTo(4L); assertThat(events.get(0).eventType()).isEqualTo("SECKILL_SUBMIT"); } + + @Test + void summaryCountsDecisionsAndEvents() { + ruleRepository.create(new RiskRuleRequest( + "REVIEW_DEVICE", "review device", "review suspicious device", "device_id=review-device", RiskAction.REVIEW, true, 45)); + ruleRepository.create(new RiskRuleRequest( + "BLOCK_DEVICE", "block device", "block known bot device", "device_id=block-device", RiskAction.BLOCK, true, 90)); + + decisionService.evaluate(new RiskEventRequest( + 1L, 2L, 101L, "req-summary-pass", "SECKILL_SUBMIT", "127.0.0.1", "normal", "{}")); + decisionService.evaluate(new RiskEventRequest( + 1L, 2L, 102L, "req-summary-review", "SECKILL_SUBMIT", "127.0.0.1", "review-device", "{}")); + decisionService.evaluate(new RiskEventRequest( + 1L, 2L, 103L, "req-summary-block", "SECKILL_SUBMIT", "127.0.0.1", "block-device", "{}")); + + assertThat(decisionService.summary()) + .containsEntry("pass", 1L) + .containsEntry("review", 1L) + .containsEntry("block", 1L) + .containsEntry("events", 3L); + } } diff --git a/backend/sg-seckill-service/src/main/resources/application.yml b/backend/sg-seckill-service/src/main/resources/application.yml index 8f7238d..1ea03b6 100644 --- a/backend/sg-seckill-service/src/main/resources/application.yml +++ b/backend/sg-seckill-service/src/main/resources/application.yml @@ -13,6 +13,8 @@ spring: server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} namespace: ${NACOS_NAMESPACE:} file-extension: yaml + import-check: + enabled: false sentinel: transport: dashboard: ${SENTINEL_DASHBOARD_ADDR:192.168.101.130:8858} @@ -38,16 +40,12 @@ seata: service: vgroup-mapping: spikeguard-seata-group: default + grouplist: + default: ${SEATA_SERVER_ADDR:192.168.101.130:8091} registry: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file config: - type: nacos - nacos: - server-addr: ${NACOS_SERVER_ADDR:192.168.101.130:8848} - namespace: ${NACOS_NAMESPACE:} + type: file management: endpoints: @@ -64,7 +62,8 @@ management: sampling: probability: 1.0 zipkin: - base-url: http://${ZIPKIN_HOST:192.168.101.130}:9411 + tracing: + endpoint: http://${ZIPKIN_HOST:192.168.101.130}:9411/api/v2/spans spikeguard: seckill: diff --git a/deploy/postgres/init-databases.sql b/deploy/postgres/init-databases.sql new file mode 100644 index 0000000..d77e7c3 --- /dev/null +++ b/deploy/postgres/init-databases.sql @@ -0,0 +1,32 @@ +SELECT 'CREATE DATABASE spikeguard OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'spikeguard')\gexec + +SELECT 'CREATE DATABASE sg_auth OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_auth')\gexec + +SELECT 'CREATE DATABASE sg_product OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_product')\gexec + +SELECT 'CREATE DATABASE sg_activity OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_activity')\gexec + +SELECT 'CREATE DATABASE sg_risk OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_risk')\gexec + +SELECT 'CREATE DATABASE sg_order OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_order')\gexec + +SELECT 'CREATE DATABASE sg_payment OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_payment')\gexec + +SELECT 'CREATE DATABASE sg_inventory OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_inventory')\gexec + +SELECT 'CREATE DATABASE sg_seckill OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_seckill')\gexec + +SELECT 'CREATE DATABASE sg_ai_adapter OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_ai_adapter')\gexec + +SELECT 'CREATE DATABASE sg_audit OWNER spikeguard' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'sg_audit')\gexec diff --git a/deploy/vm/README.md b/deploy/vm/README.md index 6032a0c..9cff5ce 100644 --- a/deploy/vm/README.md +++ b/deploy/vm/README.md @@ -8,14 +8,61 @@ mkdir -p /home/kaduox/SpikeGuard cd /home/kaduox/SpikeGuard ``` -Copy or pull repository files, create `.env` from `.env.example`, fill runtime secrets locally on the VM, then run the middleware profile: +Copy or pull repository files, create `.env` from `.env.example`, and fill runtime secrets locally on the VM. Do not commit VM passwords or production middleware passwords. + +## Middleware ```bash -docker compose --profile middleware config -docker compose --profile middleware up -d mysql redis rabbitmq +docker compose --env-file .env --profile middleware config +docker compose --env-file .env --profile middleware up -d docker ps ``` -The default SpikeGuard VM host ports are MySQL `13306`, Redis `16379`, RabbitMQ AMQP `15674`, and RabbitMQ Management `15673`. +Default VM host ports: + +| Component | URL / Port | +| --- | --- | +| PostgreSQL | `192.168.101.130:15432` | +| Redis | `192.168.101.130:16379` | +| RabbitMQ AMQP | `192.168.101.130:15674` | +| RabbitMQ Management | `http://192.168.101.130:15673` | +| Nacos | `http://192.168.101.130:8848/nacos/` | +| Zipkin | `http://192.168.101.130:9411` | +| Prometheus | `http://192.168.101.130:9090` | +| Grafana | `http://192.168.101.130:3000` | +| Elasticsearch | `http://192.168.101.130:9200` | +| Kibana | `http://192.168.101.130:5601` | +| MinIO API | `http://192.168.101.130:9000` | +| MinIO Console | `http://192.168.101.130:9001` | +| Seata Console | `http://192.168.101.130:7091` | + +## Backend Services + +```bash +docker compose --env-file .env up -d --build \ + sg-auth-service \ + sg-product-service \ + sg-activity-service \ + sg-risk-service \ + sg-order-service \ + sg-payment-service \ + sg-inventory-service \ + sg-seckill-service \ + sg-ai-adapter-service \ + sg-audit-service \ + sg-monitor-service \ + sg-legacy-monolith \ + sg-gateway +``` + +Gateway is exposed on `http://192.168.101.130:18080`. Frontends should proxy `/api` to this address during VM integration. + +## Smoke Checks -Do not store VM passwords or production middleware passwords in Git. +```bash +curl http://192.168.101.130:18080/actuator/health +curl http://192.168.101.130:8848/nacos/ +curl http://192.168.101.130:9411/health +curl http://192.168.101.130:9090/-/healthy +curl http://192.168.101.130:9000/minio/health/live +``` diff --git a/docker-compose.yml b/docker-compose.yml index 43515d9..a1d73ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +x-spikeguard-jvm-env: &spikeguard-jvm-env + JAVA_TOOL_OPTIONS: "${JAVA_TOOL_OPTIONS:--Xms64m -Xmx256m -XX:MaxMetaspaceSize=192m -XX:+UseSerialGC}" + services: postgres: image: postgres:16 @@ -14,6 +17,7 @@ services: - "${POSTGRES_PORT}:5432" volumes: - ./docker-data/postgres:/var/lib/postgresql/data + - ./deploy/postgres/init-databases.sql:/docker-entrypoint-initdb.d/01-init-databases.sql:ro command: - --max_connections=200 @@ -57,6 +61,7 @@ services: ports: - "${AUTH_SERVICE_HOST_PORT}:8081" environment: + <<: *spikeguard-jvm-env AUTH_SERVICE_PORT: ${AUTH_SERVICE_PORT} JWT_SECRET: ${JWT_SECRET} TOKEN_TTL_MINUTES: ${TOKEN_TTL_MINUTES} @@ -69,6 +74,12 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} + MAIL_HOST: ${MAIL_HOST:-smtp.qq.com} + MAIL_PORT: ${MAIL_PORT:-465} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_APP_PASSWORD: ${MAIL_APP_PASSWORD:-} + MAIL_FROM: ${MAIL_FROM:-SpikeGuard } TZ: Asia/Shanghai sg-product-service: @@ -83,6 +94,7 @@ services: ports: - "${PRODUCT_SERVICE_HOST_PORT}:8082" environment: + <<: *spikeguard-jvm-env PRODUCT_SERVICE_PORT: ${PRODUCT_SERVICE_PORT} PRODUCT_DATASOURCE_URL: ${PRODUCT_DATASOURCE_URL} PRODUCT_DATASOURCE_USERNAME: ${PRODUCT_DATASOURCE_USERNAME} @@ -93,6 +105,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-activity-service: @@ -107,6 +120,7 @@ services: ports: - "${ACTIVITY_SERVICE_HOST_PORT}:8083" environment: + <<: *spikeguard-jvm-env ACTIVITY_SERVICE_PORT: ${ACTIVITY_SERVICE_PORT} ACTIVITY_DATASOURCE_URL: ${ACTIVITY_DATASOURCE_URL} ACTIVITY_DATASOURCE_USERNAME: ${ACTIVITY_DATASOURCE_USERNAME} @@ -127,6 +141,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-risk-service: @@ -141,6 +156,7 @@ services: ports: - "${RISK_SERVICE_HOST_PORT}:8084" environment: + <<: *spikeguard-jvm-env RISK_SERVICE_PORT: ${RISK_SERVICE_PORT} RISK_DATASOURCE_URL: ${RISK_DATASOURCE_URL} RISK_DATASOURCE_USERNAME: ${RISK_DATASOURCE_USERNAME} @@ -151,6 +167,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-order-service: @@ -165,6 +182,7 @@ services: ports: - "${ORDER_SERVICE_HOST_PORT}:8085" environment: + <<: *spikeguard-jvm-env ORDER_SERVICE_PORT: ${ORDER_SERVICE_PORT} ORDER_DATASOURCE_URL: ${ORDER_DATASOURCE_URL} ORDER_DATASOURCE_USERNAME: ${ORDER_DATASOURCE_USERNAME} @@ -185,6 +203,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-payment-service: @@ -199,6 +218,7 @@ services: ports: - "${PAYMENT_SERVICE_HOST_PORT}:8086" environment: + <<: *spikeguard-jvm-env PAYMENT_SERVICE_PORT: ${PAYMENT_SERVICE_PORT} PAYMENT_DATASOURCE_URL: ${PAYMENT_DATASOURCE_URL} PAYMENT_DATASOURCE_USERNAME: ${PAYMENT_DATASOURCE_USERNAME} @@ -214,6 +234,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-legacy-monolith: @@ -226,6 +247,7 @@ services: ports: - "${LEGACY_SERVICE_HOST_PORT}:18080" environment: + <<: *spikeguard-jvm-env SERVER_PORT: 18080 SPRING_DATASOURCE_URL: ${LEGACY_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} @@ -260,6 +282,7 @@ services: ports: - "${INVENTORY_SERVICE_HOST_PORT}:8087" environment: + <<: *spikeguard-jvm-env INVENTORY_SERVICE_PORT: ${INVENTORY_SERVICE_PORT} INVENTORY_DATASOURCE_URL: ${INVENTORY_DATASOURCE_URL} INVENTORY_DATASOURCE_USERNAME: ${INVENTORY_DATASOURCE_USERNAME} @@ -273,6 +296,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-seckill-service: @@ -287,6 +311,7 @@ services: ports: - "${SECKILL_SERVICE_HOST_PORT}:8088" environment: + <<: *spikeguard-jvm-env SECKILL_SERVICE_PORT: ${SECKILL_SERVICE_PORT} SECKILL_DATASOURCE_URL: ${SECKILL_DATASOURCE_URL} SECKILL_DATASOURCE_USERNAME: ${SECKILL_DATASOURCE_USERNAME} @@ -301,6 +326,7 @@ services: ES_HOST: ${ES_HOST:-192.168.101.130} ES_PORT: ${ES_PORT:-9200} SENTINEL_DASHBOARD_ADDR: ${SENTINEL_DASHBOARD_ADDR:-192.168.101.130:8858} + SEATA_SERVER_ADDR: ${SEATA_SERVER_ADDR:-192.168.101.130:8091} TZ: Asia/Shanghai sg-ai-adapter-service: @@ -315,6 +341,7 @@ services: ports: - "${AI_ADAPTER_SERVICE_HOST_PORT}:8089" environment: + <<: *spikeguard-jvm-env AI_ADAPTER_SERVICE_PORT: ${AI_ADAPTER_SERVICE_PORT} AI_ADAPTER_DATASOURCE_URL: ${AI_ADAPTER_DATASOURCE_URL} AI_ADAPTER_DATASOURCE_USERNAME: ${AI_ADAPTER_DATASOURCE_USERNAME} @@ -339,6 +366,7 @@ services: ports: - "${AUDIT_SERVICE_HOST_PORT}:8091" environment: + <<: *spikeguard-jvm-env AUDIT_SERVICE_PORT: ${AUDIT_SERVICE_PORT} AUDIT_DATASOURCE_URL: ${AUDIT_DATASOURCE_URL} AUDIT_DATASOURCE_USERNAME: ${AUDIT_DATASOURCE_USERNAME} @@ -363,6 +391,7 @@ services: ports: - "${MONITOR_SERVICE_HOST_PORT}:8092" environment: + <<: *spikeguard-jvm-env MONITOR_SERVICE_PORT: ${MONITOR_SERVICE_PORT} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR:-192.168.101.130:8848} ZIPKIN_HOST: ${ZIPKIN_HOST:-192.168.101.130} @@ -396,22 +425,23 @@ services: ports: - "${GATEWAY_HOST_PORT}:8080" environment: + <<: *spikeguard-jvm-env GATEWAY_PORT: ${GATEWAY_PORT} JWT_SECRET: ${JWT_SECRET} GATEWAY_INTERNAL_SECRET: ${GATEWAY_INTERNAL_SECRET} TOKEN_TTL_MINUTES: ${TOKEN_TTL_MINUTES} - AUTH_SERVICE_URL: ${AUTH_SERVICE_URL} - PRODUCT_SERVICE_URL: ${PRODUCT_SERVICE_URL} - ACTIVITY_SERVICE_URL: ${ACTIVITY_SERVICE_URL} - RISK_SERVICE_URL: ${RISK_SERVICE_URL} - ORDER_SERVICE_URL: ${ORDER_SERVICE_URL} - PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL} - LEGACY_SERVICE_URL: ${LEGACY_SERVICE_URL} - INVENTORY_SERVICE_URL: ${INVENTORY_SERVICE_URL} - SECKILL_SERVICE_URL: ${SECKILL_SERVICE_URL} - AI_ADAPTER_SERVICE_URL: ${AI_ADAPTER_SERVICE_URL} - AUDIT_SERVICE_URL: ${AUDIT_SERVICE_URL} - MONITOR_SERVICE_URL: ${MONITOR_SERVICE_URL} + AUTH_SERVICE_URL: ${AUTH_SERVICE_URL:-http://sg-auth-service:8081} + PRODUCT_SERVICE_URL: ${PRODUCT_SERVICE_URL:-http://sg-product-service:8082} + ACTIVITY_SERVICE_URL: ${ACTIVITY_SERVICE_URL:-http://sg-activity-service:8083} + RISK_SERVICE_URL: ${RISK_SERVICE_URL:-http://sg-risk-service:8084} + ORDER_SERVICE_URL: ${ORDER_SERVICE_URL:-http://sg-order-service:8085} + PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL:-http://sg-payment-service:8086} + LEGACY_SERVICE_URL: ${LEGACY_SERVICE_URL:-http://sg-legacy-monolith:18080} + INVENTORY_SERVICE_URL: ${INVENTORY_SERVICE_URL:-http://sg-inventory-service:8087} + SECKILL_SERVICE_URL: ${SECKILL_SERVICE_URL:-http://sg-seckill-service:8088} + AI_ADAPTER_SERVICE_URL: ${AI_ADAPTER_SERVICE_URL:-http://sg-ai-adapter-service:8089} + AUDIT_SERVICE_URL: ${AUDIT_SERVICE_URL:-http://sg-audit-service:8091} + MONITOR_SERVICE_URL: ${MONITOR_SERVICE_URL:-http://sg-monitor-service:8092} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR:-192.168.101.130:8848} ZIPKIN_HOST: ${ZIPKIN_HOST:-192.168.101.130} ES_HOST: ${ES_HOST:-192.168.101.130} @@ -458,10 +488,11 @@ services: - "${PROMETHEUS_PORT:-9090}:9090" grafana: - image: grafana/grafana:latest + image: ${GRAFANA_IMAGE:-grafana/grafana:11.5.2} container_name: spikeguard-grafana profiles: - middleware + user: "${GRAFANA_USER:-0}" restart: unless-stopped environment: GF_SECURITY_ADMIN_USER: admin @@ -473,6 +504,15 @@ services: depends_on: - prometheus + sentinel-dashboard: + image: bladex/sentinel-dashboard:1.8.8 + container_name: spikeguard-sentinel + profiles: + - middleware + restart: unless-stopped + ports: + - "${SENTINEL_DASHBOARD_PORT:-8858}:8858" + elasticsearch: image: elasticsearch:8.12.0 container_name: spikeguard-elasticsearch @@ -490,19 +530,15 @@ services: - "${ES_PORT:-9200}:9200" kibana: - image: kibana:8.12.0 + image: ${KIBANA_IMAGE:-docker.elastic.co/kibana/kibana:8.15.3} container_name: spikeguard-kibana profiles: - middleware restart: unless-stopped environment: - ELASTICSEARCH_HOSTS: http://elasticsearch:9200 - ELASTICSEARCH_USERNAME: kibana_system - ELASTICSEARCH_PASSWORD: ${ES_KIBANA_PASSWORD:-kibana_system_password} + ELASTICSEARCH_HOSTS: ${KIBANA_ELASTICSEARCH_HOSTS:-http://192.168.101.130:9200} ports: - "${KIBANA_PORT:-5601}:5601" - depends_on: - - elasticsearch minio: image: minio/minio:latest @@ -528,7 +564,8 @@ services: restart: unless-stopped environment: SEATA_PORT: 8091 - STORE_MODE: db + SEATA_IP: ${SEATA_IP:-192.168.101.130} + STORE_MODE: ${SEATA_STORE_MODE:-file} ports: - "${SEATA_PORT:-8091}:8091" - "${SEATA_CONSOLE_PORT:-7091}:7091" diff --git a/docs/01-prototype.md b/docs/01-prototype.md index 377c6b2..759fea0 100644 --- a/docs/01-prototype.md +++ b/docs/01-prototype.md @@ -72,7 +72,7 @@ SpikeGuard 采用 **双端分离** 架构,分别面向不同的用户群体: 详细步骤: 1. **浏览活动**:用户在商城前端浏览秒杀活动列表(`GET /api/activities`) -2. **查看详情**:进入活动详情查看 SKU 列表及价格(`GET /api/activities/:id`) +2. **查看详情**:登录后进入活动详情查看 SKU 列表及价格(`GET /api/activities/:id`,并通过 `GET /api/products` 补齐商品标题、封面和价格) 3. **获取 Token**:点击「立即抢购」,前端请求秒杀 Token(`POST /api/seckill/activities/:id/token`) 4. **风控评估**:系统自动进行多维度风险评估(IP / 用户 / 设备 / 频率) 5. **提交秒杀**:携带 Token 提交秒杀请求(`POST /api/seckill/activities/:id/submit`) diff --git a/docs/01-requirements.md b/docs/01-requirements.md index c43f582..37ad71c 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -20,7 +20,7 @@ SpikeGuard 是一个高并发智能秒杀风控平台,面向电商大促、票 - 通过对象存储管理商品图片等静态资源。 - 通过分布式事务保证秒杀链路跨服务数据一致性。 - 通过邮箱验证码完成用户注册(QQ SMTP 发送验证码),注册成功后自动登录。 -- 商城前端支持游客预览模式:首页和活动列表无需登录即可浏览,商品详情、秒杀、订单和个人中心需登录后访问。 +- 商城前端支持游客预览模式:首页和活动列表无需登录即可浏览,活动详情、秒杀、订单和个人中心需登录后访问。 ## 技术栈需求 @@ -44,7 +44,7 @@ SpikeGuard 是一个高并发智能秒杀风控平台,面向电商大促、票 | Prometheus + Grafana | 指标采集与监控告警 | latest | | Zipkin | 分布式链路追踪 | 3 | | Sentinel | 限流熔断 | — | -| Elasticsearch + Kibana | 日志聚合与检索 | 8.12.0 | +| Elasticsearch + Kibana | 日志聚合与检索 | Elasticsearch 8.12.0 / Kibana 8.15.3 | | MinIO | 对象存储(商品图片上传) | latest | | Seata AT | 分布式事务 | 2.0.0 | diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 7f62aa1..f6cc3b2 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -83,9 +83,9 @@ flowchart TB 商城前端(`frontend-mall`)支持游客浏览: - **无需登录**:首页、活动列表 -- **需要登录**:商品详情、秒杀活动、订单、个人中心 +- **需要登录**:活动详情、秒杀活动、订单、个人中心 -Gateway 放行路径包含静态资源与公开接口,商城首页和活动列表接口无需携带 JWT。 +Gateway 放行路径包含静态资源与公开接口,商城首页和活动列表接口无需携带 JWT。商品接口需要 JWT;商城活动详情与秒杀页在用户登录后通过 `GET /api/products` 补齐活动 SKU 的商品标题、封面和价格。 ## Gateway 公开放行路径 @@ -95,11 +95,12 @@ Gateway 放行路径包含静态资源与公开接口,商城首页和活动列 /api/auth/login /api/auth/register /api/auth/send-code -/api/products (GET) /api/activities (GET) /doc.html、/webjars/**、/v3/api-docs/** ``` +`GET /api/products` 由 Gateway JWT 保护,但当前 ProductController 未做角色限制,普通登录用户可读取商品列表。 + ## 关键秒杀链路 ```text @@ -152,7 +153,7 @@ Gateway 从 v1.0.0 的静态路由升级为 v1.1.0 的 Nacos 服务发现动态 | Zipkin 3 | 分布式链路追踪 | 9411 | | Sentinel | 限流熔断 | 随 Gateway | | Elasticsearch 8.12.0 | 日志存储与检索 | 9200 | -| Kibana 8.12.0 | 日志可视化 | 5601 | +| Kibana 8.15.3 | 日志可视化 | 5601 | | MinIO | 对象存储 | 9000 / 9001 | | Seata 2.0.0 | 分布式事务 | 8091 / 7091 | diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 0f5c822..548d063 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -8,8 +8,9 @@ | 功能 | 描述 | |------|------| -| 用户注册 | 用户名 + 密码 + 昵称注册,用户名 3-64 字符,密码 6-128 字符 | +| 用户注册 | 用户名 + 密码 + 昵称 + 邮箱验证码注册,用户名 3-64 字符,密码 6-128 字符 | | 用户登录 | 用户名 + 密码登录,返回 JWT Token | +| 邮箱验证码 | `POST /api/auth/send-code` 发送 QQ SMTP 验证码,注册时校验 `email` + `code` | | JWT 鉴权 | Gateway 层统一验证 JWT,解析用户 ID 和角色注入请求头 | | 用户信息查询 | 获取当前用户信息(`/api/users/me`)、角色上下文(`/api/users/context`) | @@ -17,7 +18,7 @@ | 功能 | 描述 | |------|------| -| 商品列表 | 查询所有商品 | +| 商品列表 | 登录后查询所有商品;商城活动详情和秒杀页用该接口补齐 SKU 商品信息 | | 创建商品 | 填写标题、封面、原价、秒杀价、状态、描述 | | 商品详情 | 按 ID 查询商品 | | 更新商品 | 修改商品信息 | @@ -118,7 +119,7 @@ | 路由转发 | 基于路径前缀将请求路由到对应微服务 | | JWT 验证 | 全局过滤器验证 JWT Token 有效性 | | 用户上下文注入 | 解析 JWT 后将 `X-User-Id`、`X-Roles` 注入下游请求头 | -| CORS 配置 | 全局跨域配置(允许所有来源) | +| CORS 配置 | 默认允许 `localhost` / `127.0.0.1`,可通过 `CORS_ALLOWED_ORIGINS` 配置允许来源 | | 内部服务认证 | Legacy 服务通过 `X-Gateway-Internal-Secret` 头认证 | ## 2. 非功能需求 diff --git a/docs/03-api-mapping.md b/docs/03-api-mapping.md index 8bfa3e8..6cee832 100644 --- a/docs/03-api-mapping.md +++ b/docs/03-api-mapping.md @@ -4,6 +4,8 @@ 所有 API 请求通过 `sg-gateway`(端口 8080)统一入口,路径前缀 `/api/` 路由到对应微服务。 +VM 联调入口为 `http://192.168.101.130:18080`,即 Gateway 宿主机端口 `GATEWAY_HOST_PORT=18080`。 + | 路由 ID | 路径匹配 | 目标服务 | 内部端口 | |---------|---------|---------|---------| | auth-service | `/api/auth/**`, `/api/users/**` | sg-auth-service | 8081 | @@ -52,7 +54,8 @@ | 方法 | 路径 | 请求参数 / Body | 响应 | |------|------|----------------|------| -| POST | `/api/auth/register` | `{ username: string, password: string, nickname?: string }` | `AuthResponse { token, expiresAt, user }` | +| POST | `/api/auth/send-code` | `{ email: string }` | `Void` | +| POST | `/api/auth/register` | `{ username: string, password: string, nickname?: string, email: string, code: string }` | `AuthResponse { token, expiresAt, user }` | | POST | `/api/auth/login` | `{ username: string, password: string }` | `AuthResponse { token, expiresAt, user }` | | GET | `/api/users/me` | Header: `X-User-Id` | `UserVO { id, username, nickname, roles, status }` | | GET | `/api/users/context` | Header: `X-Roles` | `Set` 角色集合 | @@ -67,6 +70,8 @@ | PUT | `/api/products/{id}` | Path: `id`,Body: `ProductRequest` | `ProductVO` | | DELETE | `/api/products/{id}` | Path: `id` | `Void` | +> `GET /api/products` 需要 JWT,但当前无角色限制。商城活动详情和秒杀页登录后调用该接口补齐活动 SKU 的商品标题、封面、原价和秒杀价。 + ### 3.3 活动服务(sg-activity-service :8083) | 方法 | 路径 | 请求参数 / Body | 响应 | @@ -82,6 +87,8 @@ | POST | `/api/activities/{id}/stock/{skuId}/confirm` | Path: `id, skuId`,Query: `quantity=1` | `ActivitySkuVO` | | POST | `/api/activities/{id}/stock/{skuId}/release` | Path: `id, skuId`,Query: `quantity=1` | `ActivitySkuVO` | +`ActivityVO` 包含 `skus: ActivitySkuVO[]`。活动状态为 `DRAFT`、`PUBLISHED`、`RUNNING`、`FINISHED`、`CANCELLED`;风险等级为 `NORMAL`、`HIGH`、`CRITICAL`;预热状态为 `NOT_WARMED`、`WARMING`、`WARMED`、`FAILED`。 + ### 3.4 风控服务(sg-risk-service :8084) **规则管理(RiskRuleController)** @@ -195,7 +202,7 @@ | 登录(LoginView) | `POST /api/auth/login`、`POST /api/auth/register`、`GET /api/users/me` | | 首页(HomeView) | `GET /api/activities` | | 活动列表(ActivitiesView) | `GET /api/activities` | -| 活动详情(ActivityDetailView) | `GET /api/activities/{id}` | -| 秒杀流程(SeckillView) | `POST /api/seckill/activities/{id}/token`、`POST /api/seckill/activities/{id}/submit` | +| 活动详情(ActivityDetailView) | `GET /api/activities/{id}`、`GET /api/products` | +| 秒杀流程(SeckillView) | `GET /api/activities/{id}`、`GET /api/products`、`POST /api/seckill/activities/{id}/token`、`POST /api/seckill/activities/{id}/submit` | | 订单列表(OrdersView) | `GET /api/orders` | | 用户中心(UserView) | `GET /api/users/me` | diff --git a/docs/03-database.md b/docs/03-database.md index e047bd5..fcb09bf 100644 --- a/docs/03-database.md +++ b/docs/03-database.md @@ -1,8 +1,8 @@ # 03 Database -数据库:`spikeguard`(PostgreSQL 16)。 +数据库:PostgreSQL 16。 -完整初始化脚本位于 `backend/src/main/resources/db/init.sql`,创建以下业务表: +`deploy/postgres/init-databases.sql` 负责在全新 PostgreSQL 数据目录中创建各服务数据库;业务表由各微服务的 Flyway 迁移脚本维护,路径为 `backend//src/main/resources/db/migration/`。 - `sg_user`、`sg_role`、`sg_user_role`、`sg_token_blacklist` — 用户与权限 - `sg_verification_code` — 邮箱验证码 @@ -16,7 +16,7 @@ - `sg_operation_log` — 操作日志 - `sg_metric_snapshot` — 监控指标快照 -所有表使用 `utf8mb4` 和 `utf8mb4_unicode_ci`。 +PostgreSQL 使用数据库默认编码(UTF-8)。不要再使用 MySQL 的 `utf8mb4`、`AUTO_INCREMENT` 或 `createDatabaseIfNotExist` 语法。 ## sg_user 表 @@ -48,9 +48,9 @@ ## 多服务数据库分布 -v1.1.0 微服务拆分后,各服务使用独立 Schema: +v1.1.0 微服务拆分后,各服务使用独立数据库: -| Schema | 所属服务 | +| 数据库 | 所属服务 | | --- | --- | | `spikeguard` | sg-legacy-monolith(兼容) | | `sg_auth` | sg-auth-service | @@ -64,6 +64,18 @@ v1.1.0 微服务拆分后,各服务使用独立 Schema: | `sg_ai_adapter` | sg-ai-adapter-service | | `sg_audit` | sg-audit-service | +## 当前演示数据(2026-07-03) + +联调 VM 已导入一批演示数据,用于双端冒烟测试和浏览器联调: + +| 数据 | 数量 | 固定 ID 范围 | +| --- | --- | --- | +| 商品 | 20 条 | `90001`-`90020` | +| 秒杀活动 | 6 场 | `91001`-`91006` | +| 活动 SKU | 20 条 | 绑定上述活动与商品 | + +当前 API 验证基线:登录后 `GET /api/products` 返回 20 条商品,公开 `GET /api/activities` 返回 6 场活动,登录后 `GET /api/activities/91006` 返回 3 个 SKU。 + ## Seata undo_log 表 参与 Seata AT 分布式事务的 4 个服务需要在各自 Schema 中创建 `undo_log` 表: diff --git a/docs/04-api.md b/docs/04-api.md index 9cd3bad..d066a1b 100644 --- a/docs/04-api.md +++ b/docs/04-api.md @@ -10,11 +10,23 @@ v1.1.0 集成 Knife4j,所有 13 个 Controller 添加 `@Tag` / `@Operation` 等 OpenAPI 3 注解。通过 Gateway 统一聚合,访问地址: -- **Knife4j 文档入口**:`http://localhost:8080/doc.html` +- **Knife4j 文档入口**:`http://192.168.101.130:18080/doc.html`(VM)/ `http://localhost:8080/doc.html`(本地) - 各服务独立文档:`http://localhost:{port}/doc.html` Gateway 自动发现 Nacos 中注册的所有服务并聚合其 API 文档。 +## 访问权限口径(2026-07-03) + +| 范围 | 访问要求 | +| --- | --- | +| `POST /api/auth/login`、`POST /api/auth/register`、`POST /api/auth/send-code` | 公开 | +| `GET /api/activities` | 公开,用于商城游客首页和活动列表 | +| `GET /api/products`、`GET /api/products/{id}` | 需要 JWT,当前无角色限制 | +| `GET /api/activities/{id}`、秒杀、订单、用户中心 | 需要 JWT | +| 管理后台写操作 | 需要登录;角色校验按各 Controller 注解执行 | + +商城活动详情与秒杀页在登录后同时读取 `ActivityVO.skus` 和 `GET /api/products`,将活动 SKU 映射为商品标题、封面、原价、秒杀价和库存展示。 + ## 认证(sg-auth-service) - `POST /api/auth/send-code` — 发送邮箱验证码(公开接口,无需登录) @@ -68,8 +80,8 @@ Gateway 自动发现 Nacos 中注册的所有服务并聚合其 API 文档。 | username | 是 | 用户名,3-64 字符 | | password | 是 | 密码,6-128 字符 | | nickname | 否 | 昵称 | -| email | 否 | 邮箱,需与验证码对应 | -| code | 否 | 6 位邮箱验证码 | +| email | 是 | 邮箱,需与验证码对应 | +| code | 是 | 6 位邮箱验证码 | **响应:** @@ -85,6 +97,20 @@ Gateway 自动发现 Nacos 中注册的所有服务并聚合其 API 文档。 - `PUT /api/products/{id}` — 更新商品 - `DELETE /api/products/{id}` — 删除商品 +### ProductVO + +```json +{ + "id": 90001, + "title": "商品名称", + "coverUrl": "https://example.com/image.jpg", + "originPrice": 5999.00, + "seckillPrice": 3999.00, + "status": "ON_SALE", + "description": "商品描述" +} +``` + ## 活动(sg-activity-service) - `GET /api/activities` — 活动列表 @@ -94,6 +120,37 @@ Gateway 自动发现 Nacos 中注册的所有服务并聚合其 API 文档。 - `POST /api/activities/{id}/publish` — 发布活动 - `POST /api/activities/{id}/warmup` — 库存预热 +### ActivityVO / ActivitySkuVO + +活动状态:`DRAFT`、`PUBLISHED`、`RUNNING`、`FINISHED`、`CANCELLED`。 + +风险等级:`NORMAL`、`HIGH`、`CRITICAL`。 + +预热状态:`NOT_WARMED`、`WARMING`、`WARMED`、`FAILED`。 + +```json +{ + "id": 91006, + "name": "限时秒杀", + "status": "RUNNING", + "perUserLimit": 1, + "riskLevel": "HIGH", + "warmupStatus": "WARMED", + "startTime": "2026-07-03T10:00:00", + "endTime": "2026-07-03T22:00:00", + "skus": [ + { + "id": 1, + "productId": 90001, + "totalStock": 100, + "availableStock": 86, + "frozenStock": 3, + "soldStock": 11 + } + ] +} +``` + ## 秒杀(sg-seckill-service) - `GET /api/seckill/activities/{id}` — 秒杀活动详情 diff --git a/docs/04-deployment.md b/docs/04-deployment.md index 30aa23b..c420201 100644 --- a/docs/04-deployment.md +++ b/docs/04-deployment.md @@ -2,130 +2,56 @@ ## 1. 环境要求 -### 1.1 虚拟机 / 服务器配置 - | 项目 | 最低配置 | 推荐配置 | -|------|---------|---------| +| --- | --- | --- | | CPU | 4 核 | 8 核+ | | 内存 | 8 GB | 16 GB+ | | 磁盘 | 50 GB | 100 GB+ SSD | | 操作系统 | Ubuntu 22.04 / CentOS 8+ | Ubuntu 24.04 LTS | -### 1.2 软件依赖 - | 软件 | 版本 | 用途 | -|------|------|------| +| --- | --- | --- | | Docker | 24.0+ | 容器运行时 | | Docker Compose | v2.20+ | 容器编排 | -| JDK | 21 | 后端编译运行(本地开发) | -| Maven | 3.9+ | 后端构建(本地开发) | -| Node.js | 18+ | 前端构建(本地开发) | +| JDK | 21 | 后端本地构建 | +| Maven | 3.9+ | 后端本地构建 | +| Node.js | 18+ | 前端构建 | | npm | 9+ | 前端依赖管理 | ---- - ## 2. 中间件部署 -使用 Docker Compose 部署中间件,通过 `middleware` profile 控制: +中间件通过 Docker Compose `middleware` profile 启动,当前 VM 为 `192.168.101.130`。 ```bash -# 启动中间件(MySQL + Redis + RabbitMQ) -docker compose --profile middleware up -d +cp .env.example .env +docker compose --env-file .env --profile middleware config +docker compose --env-file .env --profile middleware up -d +docker ps ``` -### 2.1 MySQL 8.0 - -| 配置项 | 值 | -|--------|-----| -| 容器名 | `spikeguard-mysql` | -| 端口 | `${MYSQL_PORT}:3306`(默认 13306) | -| Root 密码 | `${MYSQL_ROOT_PASSWORD}` | -| 业务用户 | `spikeguard` / `${MYSQL_PASSWORD}` | -| 字符集 | `utf8mb4_unicode_ci` | -| 数据卷 | `./docker-data/mysql:/var/lib/mysql` | -| 初始化脚本 | `backend/sg-legacy-monolith/src/main/resources/db/init.sql` | - -> **PostgreSQL 迁移说明**:数据库正在从 MySQL 8.0 向 PostgreSQL 16 迁移中。当前版本仍使用 MySQL,PostgreSQL 版本将在迁移完成后启用。 - -### 2.2 Redis 7 - -| 配置项 | 值 | -|--------|-----| -| 容器名 | `spikeguard-redis` | -| 端口 | `${REDIS_PORT}:6379`(默认 16379) | -| 密码 | `${REDIS_PASSWORD}` | -| 持久化 | AOF 模式(`appendonly yes`) | -| 数据卷 | `./docker-data/redis:/data` | - -### 2.3 RabbitMQ 3(Management) - -| 配置项 | 值 | -|--------|-----| -| 容器名 | `spikeguard-rabbitmq` | -| AMQP 端口 | `${RABBITMQ_PORT}:5672`(默认 15674) | -| 管理界面端口 | `${RABBITMQ_MANAGEMENT_PORT}:15672`(默认 15673) | -| 用户名 | `${RABBITMQ_USERNAME}`(默认 spikeguard) | -| 密码 | `${RABBITMQ_PASSWORD}` | -| 数据卷 | `./docker-data/rabbitmq:/var/lib/rabbitmq` | - -管理界面访问:`http://:15673` - ---- - -## 3. 数据库初始化 - -MySQL 启动后自动执行 `init.sql`,创建以下数据库和核心表: - -### 3.1 数据库列表 - -| 数据库名 | 所属服务 | 说明 | -|---------|---------|------| -| `spikeguard` | sg-legacy-monolith | 遗留单体数据库 | -| `sg_auth` | sg-auth-service | 认证与用户 | -| `sg_product` | sg-product-service | 商品数据 | -| `sg_activity` | sg-activity-service | 活动与 SKU | -| `sg_risk` | sg-risk-service | 风控规则与事件 | -| `sg_order` | sg-order-service | 订单数据 | -| `sg_payment` | sg-payment-service | 支付记录 | -| `sg_inventory` | sg-inventory-service | 库存数据 | -| `sg_seckill` | sg-seckill-service | 秒杀 Token | -| `sg_ai_adapter` | sg-ai-adapter-service | AI 会话与追踪 | -| `sg_audit` | sg-audit-service | 审计日志 | - -> 各微服务配置了 `createDatabaseIfNotExist=true`,首次连接时自动创建对应数据库。 - -### 3.2 核心表(init.sql 预创建) +| 容器 | 镜像 | 端口 | +| --- | --- | --- | +| `spikeguard-postgres` | `postgres:16` | `15432:5432` | +| `spikeguard-redis` | `redis:7` | `16379:6379` | +| `spikeguard-rabbitmq` | `rabbitmq:3-management` | `15674:5672`, `15673:15672` | +| `spikeguard-nacos` | `nacos/nacos-server:v2.3.0` | `8848:8848`, `9848:9848` | +| `spikeguard-zipkin` | `openzipkin/zipkin:3` | `9411:9411` | +| `spikeguard-prometheus` | `prom/prometheus:latest` | `9090:9090` | +| `spikeguard-grafana` | `grafana/grafana:11.5.2` | `3000:3000` | +| `spikeguard-elasticsearch` | `elasticsearch:8.12.0` | `9200:9200` | +| `spikeguard-kibana` | `docker.elastic.co/kibana/kibana:8.15.3` | `5601:5601` | +| `spikeguard-minio` | `minio/minio:latest` | `9000:9000`, `9001:9001` | +| `spikeguard-seata` | `seataio/seata-server:2.0.0` | `8091:8091`, `7091:7091` | -- `sg_user` — 用户表 -- `sg_role` — 角色表 -- `sg_user_role` — 用户角色关联 -- `sg_product` — 商品表 -- `sg_flash_activity` — 秒杀活动表 -- `sg_activity_sku` — 活动 SKU 表 -- `sg_inventory_snapshot` — 库存快照表 -- `sg_inventory_log` — 库存变动日志 -- `sg_risk_rule` — 风控规则表 -- `sg_risk_event` — 风控事件表 -- `sg_risk_decision` — 风控决策表 +PostgreSQL 首次初始化时会执行 `deploy/postgres/init-databases.sql`,创建 `spikeguard`、`sg_auth`、`sg_product`、`sg_activity`、`sg_risk`、`sg_order`、`sg_payment`、`sg_inventory`、`sg_seckill`、`sg_ai_adapter`、`sg_audit` 等数据库。已有 `docker-data/postgres` 数据目录不会被官方镜像重复初始化。 ---- - -## 4. 后端服务部署 - -### 4.1 构建 JAR 包 +## 3. 后端部署 ```bash cd backend mvn clean package -DskipTests -``` - -每个模块生成 `target/-0.1.0.jar`。 - -### 4.2 Docker Compose 启动全部服务 - -```bash -# 启动全部后端微服务 -docker compose up -d \ +cd .. +docker compose --env-file .env up -d --build \ sg-auth-service \ sg-product-service \ sg-activity-service \ @@ -141,193 +67,108 @@ docker compose up -d \ sg-gateway ``` -### 4.3 服务端口映射 - -| 服务 | 容器端口 | 宿主机端口(默认) | -|------|---------|------------------| -| sg-gateway | 8080 | 8080 | -| sg-auth-service | 8081 | 8081 | -| sg-product-service | 8082 | 8082 | -| sg-activity-service | 8083 | 8083 | -| sg-risk-service | 8084 | 8084 | -| sg-order-service | 8085 | 8085 | -| sg-payment-service | 8086 | 8086 | -| sg-inventory-service | 8087 | 8087 | -| sg-seckill-service | 8088 | 8088 | -| sg-ai-adapter-service | 8089 | 8089 | -| sg-audit-service | 8091 | 8091 | -| sg-monitor-service | 8092 | 8092 | -| sg-legacy-monolith | 18080 | 18080 | +| 服务 | 容器端口 | VM 宿主机端口 | +| --- | --- | --- | +| sg-gateway | 8080 | 18080 | +| sg-auth-service | 8081 | 18081 | +| sg-product-service | 8082 | 18082 | +| sg-activity-service | 8083 | 18083 | +| sg-risk-service | 8084 | 18084 | +| sg-order-service | 8085 | 18085 | +| sg-payment-service | 8086 | 18086 | +| sg-legacy-monolith | 18080 | 18087 | +| sg-inventory-service | 8087 | 18093 | +| sg-seckill-service | 8088 | 18088 | +| sg-ai-adapter-service | 8089 | 18089 | +| sg-audit-service | 8091 | 18091 | +| sg-monitor-service | 8092 | 18092 | -### 4.4 Dockerfile(spring-service.Dockerfile) +外部调用统一走 Gateway:`http://192.168.101.130:18080`。不要直接把前端代理到各微服务宿主机端口。 -所有 Spring 微服务共用统一 Dockerfile: +## 4. 前端部署 -```dockerfile -FROM eclipse-temurin:21-jre -ARG MODULE -WORKDIR /app -COPY backend/${MODULE}/target/${MODULE}-0.1.0.jar /app/app.jar -ENTRYPOINT ["java", "-jar", "/app/app.jar"] -``` - ---- - -## 5. 前端部署 - -### 5.1 管理后台(frontend) +管理后台: ```bash cd frontend npm install -npm run build # 输出到 dist/ +npm run build +npm run dev -- --host 127.0.0.1 --port 5183 ``` -### 5.2 商城前端(frontend-mall) +商城前端: ```bash cd frontend-mall npm install -npm run build # 输出到 dist/ +npm run build +npm run dev -- --host 127.0.0.1 --port 5184 ``` -### 5.3 Nginx 反向代理配置 - -```nginx -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # 前端 SPA 路由 - location / { - try_files $uri $uri/ /index.html; - } +前端同源 `/api` 请求由 Vite 代理到 Gateway。联调 VM 时使用: - # API 请求代理到 Gateway - location /api/ { - proxy_pass http://sg-gateway:8080/api/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } -} +```bash +VITE_API_BASE_URL= +VITE_API_PROXY_TARGET=http://192.168.101.130:18080 ``` -### 5.4 前端 Docker 镜像 - -```dockerfile -FROM node:22-alpine AS build -WORKDIR /app -COPY frontend/package*.json ./ -RUN npm install -COPY frontend ./ -RUN npm run build +生产部署可用 Nginx 托管 `dist/`,并将 `/api/` 反向代理到 Gateway。 -FROM nginx:1.27-alpine -COPY deploy/nginx/spikeguard.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 +```nginx +location /api/ { + proxy_pass http://sg-gateway:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} ``` ---- +## 5. 关键环境变量 -## 6. 环境变量说明 - -所有配置通过 `.env` 文件注入,**不要将 `.env` 提交到 Git**。参考 `.env.example` 创建。 - -### 6.1 中间件密码 +所有真实配置写入 VM 本地 `.env`,仓库只提交 `.env.example`。 | 变量 | 说明 | 示例 | -|------|------|------| -| `MYSQL_ROOT_PASSWORD` | MySQL root 密码 | `change-me-root` | -| `MYSQL_PASSWORD` | MySQL 业务用户密码 | `change-me-mysql` | +| --- | --- | --- | +| `POSTGRES_PASSWORD` | PostgreSQL 密码 | `change-me-postgres` | +| `POSTGRES_PORT` | PostgreSQL 宿主机端口 | `15432` | | `REDIS_PASSWORD` | Redis 密码 | `change-me-redis` | +| `REDIS_PORT` | Redis 宿主机端口 | `16379` | +| `RABBITMQ_USERNAME` | RabbitMQ 用户 | `spikeguard` | | `RABBITMQ_PASSWORD` | RabbitMQ 密码 | `change-me-rabbitmq` | -| `RABBITMQ_USERNAME` | RabbitMQ 用户名 | `spikeguard` | - -### 6.2 安全配置 - -| 变量 | 说明 | 示例 | -|------|------|------| -| `JWT_SECRET` | JWT 签名密钥(≥32 字节) | `local-dev-secret-32-bytes-minimum-value` | -| `GATEWAY_INTERNAL_SECRET` | Gateway 内部认证密钥 | `local-dev-gateway-32-bytes` | -| `TOKEN_TTL_MINUTES` | Token 有效期(分钟) | `720` | - -### 6.3 服务端口(容器内 / 宿主机映射) - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `GATEWAY_PORT` / `GATEWAY_HOST_PORT` | 8080 | Gateway | -| `AUTH_SERVICE_PORT` / `AUTH_SERVICE_HOST_PORT` | 8081 | 认证服务 | -| `PRODUCT_SERVICE_PORT` / `PRODUCT_SERVICE_HOST_PORT` | 8082 | 商品服务 | -| `ACTIVITY_SERVICE_PORT` / `ACTIVITY_SERVICE_HOST_PORT` | 8083 | 活动服务 | -| `RISK_SERVICE_PORT` / `RISK_SERVICE_HOST_PORT` | 8084 | 风控服务 | -| `ORDER_SERVICE_PORT` / `ORDER_SERVICE_HOST_PORT` | 8085 | 订单服务 | -| `PAYMENT_SERVICE_PORT` / `PAYMENT_SERVICE_HOST_PORT` | 8086 | 支付服务 | -| `INVENTORY_SERVICE_PORT` / `INVENTORY_SERVICE_HOST_PORT` | 8087 | 库存服务 | -| `SECKILL_SERVICE_PORT` / `SECKILL_SERVICE_HOST_PORT` | 8088 | 秒杀服务 | -| `AI_ADAPTER_SERVICE_PORT` / `AI_ADAPTER_SERVICE_HOST_PORT` | 8089 | AI 适配器 | -| `AUDIT_SERVICE_PORT` / `AUDIT_SERVICE_HOST_PORT` | 8091 | 审计服务 | -| `MONITOR_SERVICE_PORT` / `MONITOR_SERVICE_HOST_PORT` | 8092 | 监控服务 | - -### 6.4 功能开关 - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `SPIKEGUARD_REDIS_ENABLED` | `true` | 启用 Redis 缓存 | -| `SPIKEGUARD_RABBITMQ_ENABLED` | `true` | 启用 RabbitMQ 消息 | -| `SPIKEGUARD_INVENTORY_REDIS_ENABLED` | `true` | 启用库存 Redis 扣减 | -| `SPIKEGUARD_MESSAGING_ORDER_RABBIT_ENABLED` | `true` | 启用订单 MQ 消息 | -| `SPIKEGUARD_MESSAGING_PAYMENT_RABBIT_ENABLED` | `true` | 启用支付 MQ 消息 | -| `ORDER_TIMEOUT_ENABLED` | `true` | 启用订单超时自动取消 | -| `ORDER_PAYMENT_TIMEOUT_MINUTES` | `15` | 支付超时时间(分钟) | -| `SECKILL_TOKEN_TTL_SECONDS` | `120` | 秒杀 Token 有效期(秒) | - ---- - -## 7. 健康检查 - -### 7.1 Gateway Actuator - -Gateway 暴露 Spring Boot Actuator 端点: - -```bash -# 健康检查 -curl http://localhost:8080/actuator/health - -# 服务信息 -curl http://localhost:8080/actuator/info - -# 指标查询 -curl http://localhost:8080/actuator/metrics -``` - -### 7.2 各服务健康检查 - -每个微服务均暴露 Actuator 端点(内部端口): +| `JWT_SECRET` | JWT 签名密钥,至少 32 字节 | `local-dev-secret-32-bytes-minimum-value` | +| `GATEWAY_INTERNAL_SECRET` | Legacy 内部调用密钥 | `local-dev-gateway-secret-32-bytes` | +| `GATEWAY_HOST_PORT` | Gateway 宿主机端口 | `18080` | +| `NACOS_SERVER_ADDR` | Nacos 地址 | `192.168.101.130:8848` | +| `ZIPKIN_HOST` | Zipkin 主机 | `192.168.101.130` | +| `ES_HOST` | Elasticsearch 主机 | `192.168.101.130` | +| `SEATA_SERVER_ADDR` | Seata TC 地址 | `192.168.101.130:8091` | +| `MAIL_USERNAME` | QQ SMTP 邮箱 | `your-email@qq.com` | +| `MAIL_APP_PASSWORD` | QQ SMTP 授权码 | `change-me-mail-app-password` | + +## 6. 控制台与健康检查 + +| 项目 | 地址 | +| --- | --- | +| Gateway | `http://192.168.101.130:18080/actuator/health` | +| Knife4j | `http://192.168.101.130:18080/doc.html` | +| Nacos | `http://192.168.101.130:8848/nacos/` | +| RabbitMQ | `http://192.168.101.130:15673` | +| Zipkin | `http://192.168.101.130:9411` | +| Prometheus | `http://192.168.101.130:9090` | +| Grafana | `http://192.168.101.130:3000` | +| Kibana | `http://192.168.101.130:5601` | +| MinIO Console | `http://192.168.101.130:9001` | +| Seata Console | `http://192.168.101.130:7091` | ```bash -# 认证服务 -curl http://localhost:8081/actuator/health - -# 商品服务 -curl http://localhost:8082/actuator/health - -# ... 其他服务同理 +docker exec spikeguard-postgres pg_isready -U spikeguard +docker exec spikeguard-redis redis-cli -a "$REDIS_PASSWORD" ping +docker exec spikeguard-rabbitmq rabbitmqctl status +curl http://192.168.101.130:18080/actuator/health ``` -### 7.3 中间件健康检查 - -```bash -# MySQL -docker exec spikeguard-mysql mysqladmin ping -u root -p${MYSQL_ROOT_PASSWORD} - -# Redis -docker exec spikeguard-redis redis-cli -a ${REDIS_PASSWORD} ping +## 7. 安全约定 -# RabbitMQ -docker exec spikeguard-rabbitmq rabbitmqctl status -``` +- 不提交 `.env`、VM 密码、真实数据库密码、Redis 密码、RabbitMQ 密码、邮箱授权码、GitHub Token 或模型 API Key。 +- 测试账号密码由维护者通过安全渠道提供,不写入仓库文档。 +- `docker-data/`、日志、构建产物、前端 `node_modules/` 和截图文件不纳入 Git。 diff --git a/docs/05-dev-workflow.md b/docs/05-dev-workflow.md index 174973b..9d9a5f5 100644 --- a/docs/05-dev-workflow.md +++ b/docs/05-dev-workflow.md @@ -67,7 +67,7 @@ cd SpikeGuard # 复制环境变量 cp .env.example .env -# 启动 MySQL + Redis + RabbitMQ +# 启动 PostgreSQL + Redis + RabbitMQ + Nacos/Zipkin/Prometheus/Grafana/Sentinel/ES/Kibana/MinIO/Seata docker compose --profile middleware up -d ``` @@ -94,14 +94,16 @@ java -jar sg-gateway/target/sg-gateway-0.1.0.jar # 管理后台 cd frontend npm install -npm run dev # http://localhost:5173 +npm run dev -- --host 127.0.0.1 --port 5183 # 商城前端(新终端) cd frontend-mall npm install -npm run dev # http://localhost:5173(需修改端口避免冲突) +npm run dev -- --host 127.0.0.1 --port 5184 ``` +当前 VM 联调 Gateway 为 `http://192.168.101.130:18080`。前端保持 `VITE_API_BASE_URL` 为空,通过 Vite `/api` 代理访问 Gateway;如需代理到 VM,设置 `VITE_API_PROXY_TARGET=http://192.168.101.130:18080`。 + ### 2.5 AI 服务启动(可选) ```bash @@ -205,7 +207,7 @@ mvn test -pl sg-order-service -Dtest=OrderServiceTest ```bash cd frontend -npm run dev # 启动开发服务器(热更新) +npm run dev -- --host 127.0.0.1 --port 5183 npm run build # 构建生产版本 npm run preview # 预览构建结果 ``` @@ -224,7 +226,7 @@ npm run preview # 预览构建结果 ```bash cd frontend-mall -npm run dev # 启动开发服务器 +npm run dev -- --host 127.0.0.1 --port 5184 npm run build # 构建生产版本 npm run preview # 预览构建结果 ``` diff --git a/docs/07-deployment.md b/docs/07-deployment.md index d0d2089..ec3907e 100644 --- a/docs/07-deployment.md +++ b/docs/07-deployment.md @@ -12,26 +12,30 @@ L:\Develop\apache-maven-3.9.12\bin\mvn.cmd -f backend\pom.xml clean package docker compose --env-file .env up -d --build ``` +`docker-compose.yml` mounts `deploy/postgres/init-databases.sql` into the PostgreSQL container so a fresh data directory creates all service databases (`sg_auth`, `sg_product`, `sg_activity`, `sg_risk`, `sg_order`, `sg_payment`, `sg_inventory`, `sg_seckill`, `sg_ai_adapter`, `sg_audit`). Existing PostgreSQL data directories are not reinitialized by the official image. + ### 服务端口列表 -| 服务 | 容器名 | 端口 | -| --- | --- | --- | -| Gateway | spikeguard-gateway | 8080 | -| Auth Service | spikeguard-auth-service | 8081 | -| Product Service | spikeguard-product-service | 8082 | -| Activity Service | spikeguard-activity-service | 8083 | -| Risk Service | spikeguard-risk-service | 8084 | -| Order Service | spikeguard-order-service | 8085 | -| Payment Service | spikeguard-payment-service | 8086 | -| Inventory Service | spikeguard-inventory-service | 8087 | -| Seckill Service | spikeguard-seckill-service | 8088 | -| AI Adapter Service | spikeguard-ai-adapter-service | 8089 | -| Legacy Monolith | spikeguard-legacy-monolith | 18080 | -| Audit Service | spikeguard-audit-service | 8091 | -| Monitor Service | spikeguard-monitor-service | 8092 | -| AI Service | ai-service | 8090 | -| Frontend(管理后台) | frontend | 5173(dev) | -| Frontend(商城) | frontend-mall | 5174(dev) | +| 服务 | 容器名 | 容器端口 | VM 宿主机端口 | +| --- | --- | --- | --- | +| Gateway | spikeguard-gateway | 8080 | 18080 | +| Auth Service | spikeguard-auth-service | 8081 | 18081 | +| Product Service | spikeguard-product-service | 8082 | 18082 | +| Activity Service | spikeguard-activity-service | 8083 | 18083 | +| Risk Service | spikeguard-risk-service | 8084 | 18084 | +| Order Service | spikeguard-order-service | 8085 | 18085 | +| Payment Service | spikeguard-payment-service | 8086 | 18086 | +| Legacy Monolith | spikeguard-legacy-monolith | 18080 | 18087 | +| Inventory Service | spikeguard-inventory-service | 8087 | 18093 | +| Seckill Service | spikeguard-seckill-service | 8088 | 18088 | +| AI Adapter Service | spikeguard-ai-adapter-service | 8089 | 18089 | +| Audit Service | spikeguard-audit-service | 8091 | 18091 | +| Monitor Service | spikeguard-monitor-service | 8092 | 18092 | +| AI Service | ai-service | 8090 | 8090 | +| Frontend(管理后台) | frontend | 5173(脚本默认) | 5183(当前联调) | +| Frontend(商城) | frontend-mall | 5173(Vite 默认) | 5184(当前联调) | + +外部访问后端请统一走 Gateway:`http://192.168.101.130:18080`。 ## VM 中间件部署 @@ -47,9 +51,9 @@ docker compose --env-file .env up -d --build | **spikeguard-nacos** | nacos/nacos-server:v2.3.0 | **8848:8848, 9848:9848** | | **spikeguard-zipkin** | openzipkin/zipkin:3 | **9411:9411** | | **spikeguard-prometheus** | prom/prometheus:latest | **9090:9090** | -| **spikeguard-grafana** | grafana/grafana:latest | **3000:3000** | +| **spikeguard-grafana** | grafana/grafana:11.5.2 | **3000:3000** | | **spikeguard-elasticsearch** | elasticsearch:8.12.0 | **9200:9200** | -| **spikeguard-kibana** | kibana:8.12.0 | **5601:5601** | +| **spikeguard-kibana** | docker.elastic.co/kibana/kibana:8.15.3 | **5601:5601** | | **spikeguard-minio** | minio/minio:latest | **9000:9000, 9001:9001** | | **spikeguard-seata** | seataio/seata-server:2.0.0 | **8091:8091, 7091:7091** | @@ -65,7 +69,7 @@ docker compose --env-file .env up -d --build | Kibana 日志 | `http://192.168.101.130:5601` | | MinIO 控制台 | `http://192.168.101.130:9001`(minioadmin/minioadmin) | | Seata 控制台 | `http://192.168.101.130:7091` | -| Knife4j API 文档 | `http://localhost:8080/doc.html` | +| Knife4j API 文档 | `http://192.168.101.130:18080/doc.html` | ### 环境变量(新增中间件相关) @@ -79,10 +83,15 @@ NACOS_GRPC_PORT=9848 ZIPKIN_HOST=192.168.101.130 ZIPKIN_PORT=9411 +# JVM limits for constrained VM deployments +JAVA_TOOL_OPTIONS=-Xms64m -Xmx256m -XX:MaxMetaspaceSize=192m -XX:+UseSerialGC + # Prometheus PROMETHEUS_PORT=9090 # Grafana +GRAFANA_IMAGE=grafana/grafana:11.5.2 +GRAFANA_USER=0 GRAFANA_PORT=3000 GRAFANA_ADMIN_PASSWORD=admin @@ -91,6 +100,8 @@ ES_HOST=192.168.101.130 ES_PORT=9200 # Kibana +KIBANA_IMAGE=docker.elastic.co/kibana/kibana:8.15.3 +KIBANA_ELASTICSEARCH_HOSTS=http://192.168.101.130:9200 KIBANA_PORT=5601 # MinIO @@ -101,13 +112,20 @@ MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin # Seata +SEATA_IP=192.168.101.130 +SEATA_SERVER_ADDR=192.168.101.130:8091 SEATA_PORT=8091 SEATA_CONSOLE_PORT=7091 +SEATA_STORE_MODE=file + +# Frontend dev proxy +VITE_API_BASE_URL= +VITE_API_PROXY_TARGET=http://192.168.101.130:18080 # Mail Service(QQ SMTP) MAIL_USERNAME=your-email@qq.com -MAIL_APP_PASSWORD=your-app-password -MAIL_FROM=SpikeGuard +MAIL_APP_PASSWORD=change-me-mail-app-password +MAIL_FROM=SpikeGuard MAIL_HOST=smtp.qq.com MAIL_PORT=465 ``` @@ -134,10 +152,12 @@ ssh kaduox@192.168.101.130 mkdir -p /home/kaduox/SpikeGuard cd /home/kaduox/SpikeGuard # 上传 docker-compose.yml、deploy/、scripts/ 等文件 -docker compose --profile middleware config +docker compose --env-file .env --profile middleware config bash scripts/deploy-middleware.sh ``` +On shared VMs, existing standard-port middleware can be reused by setting the same host values in `.env` (for example Nacos `192.168.101.130:8848`, Zipkin `192.168.101.130:9411`, Elasticsearch `192.168.101.130:9200`, MinIO `192.168.101.130:9000`). Avoid starting duplicate middleware on the same host ports. + 仅交互式输入 VM 凭据,不得将密码写入仓库文件。 ## 统一日志模板 diff --git a/docs/08-test-and-benchmark.md b/docs/08-test-and-benchmark.md index ba31317..142637d 100644 --- a/docs/08-test-and-benchmark.md +++ b/docs/08-test-and-benchmark.md @@ -11,8 +11,27 @@ conda run -n opspilot python -m compileall . cd ..\frontend npm install npm run build +cd ..\frontend-mall +npm install +npm run build ``` +## 2026-07-03 联调验证快照 + +| 验证项 | 结果 | +| --- | --- | +| 后端单元测试 | `L:\Develop\apache-maven-3.9.12\bin\mvn.cmd test` 通过,Maven `BUILD SUCCESS` | +| 管理后台构建 | `frontend npm run build` 通过 | +| 商城前端构建 | `frontend-mall npm run build` 通过 | +| API 登录 | VM Gateway `POST /api/auth/login` 返回 200 | +| 商品数据 | 登录后 `GET /api/products` 返回 20 条商品 | +| 活动数据 | 公开 `GET /api/activities` 返回 6 场活动 | +| 活动详情 | 登录后 `GET /api/activities/91006` 返回 3 个 SKU | +| 商城浏览器冒烟 | 活动列表、活动详情、秒杀确认流程可访问,无 console error | +| 管理后台浏览器冒烟 | 登录、商品页 20 行、活动页 6 行可访问,无 console error | + +测试账号由维护者另行提供;真实密码不写入仓库文档。 + ## 模块测试 v1.1.0 包含 15 个 Maven 模块,全量构建验证通过: @@ -78,6 +97,8 @@ powershell -ExecutionPolicy Bypass -File scripts\smoke-microservices-phase1.ps1 | --- | --- | | 游客浏览首页 | 未登录访问商城首页,确认正常加载 | | 游客浏览活动列表 | 未登录访问活动列表,确认正常加载 | -| 游客访问商品详情 | 未登录访问商品详情,确认跳转登录 | +| 游客访问活动详情 | 未登录访问活动详情,确认跳转登录 | | 游客访问秒杀 | 未登录访问秒杀活动,确认跳转登录 | | 游客访问订单 | 未登录访问订单页,确认跳转登录 | + +商城活动详情与秒杀页登录后还会调用 `GET /api/products`,用于把 `ActivitySkuVO.productId` 映射为商品标题、封面、原价和秒杀价。 diff --git a/docs/09-github-handoff.md b/docs/09-github-handoff.md index 38197b5..98158fc 100644 --- a/docs/09-github-handoff.md +++ b/docs/09-github-handoff.md @@ -9,7 +9,9 @@ git@github.com:kaduoxzero/SpikeGuard.git ## 版本信息 - 当前版本:v1.1.0 -- 分支:`main` +- 当前工作分支:`codex/full-quality-optimization` +- 远端分支:`origin/codex/full-quality-optimization` +- 最新代码提交:`8a1d1ee fix: map mall activity skus to product data` ## v1.1.0 变更摘要 @@ -24,22 +26,25 @@ git@github.com:kaduoxzero/SpikeGuard.git - MinIO 对象存储(商品图片上传) - Sentinel 限流熔断保护 -## 最新变更(邮箱验证码注册 + 商城预览模式) +## 最新变更(邮箱验证码注册 + 商城预览模式 + 2026-07-03 联调稳定) - 新增邮箱验证码注册功能:注册需邮箱验证码(QQ SMTP),新增 `sg_verification_code` 表,`sg_user` 新增 `email` 字段 -- 新增商城预览模式:首页和活动列表无需登录可浏览,商品详情/秒杀/订单/个人中心需登录 +- 新增商城预览模式:首页和活动列表无需登录可浏览,活动详情/秒杀/订单/个人中心需登录 - 新增接口 `POST /api/auth/send-code`(发送验证码) - 修改接口 `POST /api/auth/register`(新增 email + code 参数) - Gateway 放行 `/api/auth/send-code` 公开路径 +- 商城活动详情和秒杀页通过 `GET /api/activities/{id}` + 登录后 `GET /api/products` 映射真实商品数据 +- VM 演示数据:商品 20 条、活动 6 场、活动 SKU 20 条 +- 2026-07-03 验证:后端 `mvn test`、管理后台构建、商城构建、API 冒烟和双端浏览器冒烟通过 ## 推荐交接流程 ```powershell git remote -v -git branch -M main +git switch codex/full-quality-optimization git add . -git commit -m "feat: SpikeGuard v1.1.0 - middleware enhancement (Nacos, Knife4j, Prometheus, Zipkin, Sentinel, ES+Kibana, MinIO, Seata)" -git push -u origin main +git commit -m "docs: sync deployment and test handoff" +git push -u origin codex/full-quality-optimization ``` 推送前确认 `.env` 已被忽略,仅 `.env.example` 被跟踪。 diff --git a/docs/10-final-report.md b/docs/10-final-report.md index 9354862..64838c2 100644 --- a/docs/10-final-report.md +++ b/docs/10-final-report.md @@ -4,7 +4,7 @@ SpikeGuard 是一个高并发智能秒杀风控平台。包含 Spring Boot 微服务后端、Vue 3 管理后台与商城前端、FastAPI AI 风控工作流、Docker Compose 中间件集群、部署脚本、压测脚本和完整文档体系。 -**当前版本:v1.1.0** +**当前版本:v1.1.0(2026-07-03 全链路联调稳定版)** ## 2. 文件变更 @@ -26,7 +26,7 @@ SpikeGuard 是一个高并发智能秒杀风控平台。包含 Spring Boot 微 | Prometheus + Grafana | micrometer-registry-prometheus,各服务 /actuator/prometheus | | Zipkin 3 | micrometer-tracing-bridge-brave + zipkin-reporter-brave 全链路追踪 | | Sentinel | Gateway 全局限流 + 关键接口 @SentinelResource | -| ES + Kibana 8.12.0 | 统一 logback 模板含 traceId,日志聚合检索 | +| ES + Kibana | Elasticsearch 8.12.0 + Kibana 8.15.3,统一 logback 模板含 traceId,日志聚合检索 | | MinIO | 商品图片上传 /api/files/upload | | Seata 2.0.0 AT | 秒杀链路 @GlobalTransactional,4 服务参与(seckill/order/payment/inventory) | @@ -48,8 +48,9 @@ SpikeGuard 是一个高并发智能秒杀风控平台。包含 Spring Boot 微 ### 商城预览模式 - 首页和活动列表无需登录即可浏览。 -- 商品详情、秒杀活动、订单和个人中心需登录后访问。 -- Gateway 放行 `/api/products`(GET)和 `/api/activities`(GET)公开路径。 +- 活动详情、秒杀活动、订单和个人中心需登录后访问。 +- Gateway 放行 `GET /api/activities` 公开路径;`GET /api/products` 需要 JWT,但当前无角色限制。 +- 商城活动详情和秒杀页通过 `ActivityVO.skus` + 登录后 `GET /api/products` 映射真实商品标题、封面、原价和秒杀价。 ## 6. AI 服务状态 @@ -69,23 +70,27 @@ v1.1.0 中间件容器列表(11 个): - `spikeguard-prometheus`(Prometheus) - `spikeguard-grafana`(Grafana) - `spikeguard-elasticsearch`(Elasticsearch 8.12.0) -- `spikeguard-kibana`(Kibana 8.12.0) +- `spikeguard-kibana`(Kibana 8.15.3) - `spikeguard-minio`(MinIO) - `spikeguard-seata`(Seata 2.0.0) ## 8. 构建验证结果 -- 后端:`L:\Develop\apache-maven-3.9.12\bin\mvn.cmd -f backend\pom.xml clean package` 通过,15 模块全量构建成功。 +- 后端:`L:\Develop\apache-maven-3.9.12\bin\mvn.cmd test` 通过,15 模块测试 `BUILD SUCCESS`。 - Compose:`docker compose --env-file .env.example config --quiet` 和 `docker compose --env-file .env.example --profile middleware config --quiet` 均通过。 - AI 服务:`conda run -n opspilot python -m compileall .` 通过。 - 前端依赖:`npm install` 完成,0 漏洞。 -- 前端构建:`npm run build` 通过。 +- 管理后台构建:`frontend npm run build` 通过。 +- 商城前端构建:`frontend-mall npm run build` 通过。 +- API 冒烟:登录 200,登录后商品 20 条,公开活动 6 场,活动 `91006` 返回 3 个 SKU。 +- 浏览器冒烟:管理后台登录/商品/活动页正常;商城活动列表/活动详情/秒杀确认流程正常,无 console error。 - 安全卫生:`.env` 已忽略,`.env.example` 已跟踪。 ## 9. GitHub 信息 - 远程:`git@github.com:kaduoxzero/SpikeGuard.git` -- 分支:`main` +- 分支:`codex/full-quality-optimization` +- 最新代码提交:`8a1d1ee fix: map mall activity skus to product data` - 版本:v1.1.0 ## 10. 代码审查修复记录 diff --git a/docs/superpowers/plans/2026-06-30-full-quality-optimization.md b/docs/superpowers/plans/2026-06-30-full-quality-optimization.md new file mode 100644 index 0000000..ec19044 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-full-quality-optimization.md @@ -0,0 +1,508 @@ +# Full Quality Optimization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve SpikeGuard repository hygiene, registration consistency, monitor aggregation correctness, frontend proxy configuration, and admin build output without changing the product scope. + +**Architecture:** Keep the existing Spring Boot microservice and Vue/Vite architecture. Add small backend endpoints where monitor aggregation already expects service summaries, align registration with the documented email-code flow, and keep frontend changes scoped to configuration and the registration form. + +**Tech Stack:** Java 21, Spring Boot 3.3.6, JUnit 5, Vue 3, Vite 6, TypeScript, Element Plus, Vant, Docker Compose. + +--- + +## File Structure + +- Modify `.gitignore` to ignore generated screenshots and review diff dumps. +- Modify `.env.example` to remove real-looking mail credentials and document Vite proxy variables. +- Remove from Git tracking: `01-login-page.png`, `test-02-home.png`, `test-03-activities.png`, `test-04-activity-detail.png`, `test-05-seckill.png`, `test-06-orders.png`, `test-07-login-mall.png`, `test-08-nav-activities.png`, `test-09-nav-orders.png`, `test-10-home-final.png`, `review_diff.txt`. +- Modify `backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java` and tests to require email verification at registration. +- Modify `frontend/src/stores/auth.ts` and `frontend/src/views/LoginView.vue` to support email-code registration in the admin frontend. +- Modify `backend/sg-order-service`, `backend/sg-payment-service`, and `backend/sg-risk-service` to provide count/summary endpoints for monitor aggregation. +- Modify `backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java` and tests to call actual service endpoints. +- Modify `frontend-mall/vite.config.ts` to use environment-driven proxy target. +- Modify `frontend/vite.config.ts` to split large vendor chunks. +- Modify `README.md` and `docs/07-deployment.md` to explain the Vite proxy variables and safe mail example values. + +## Task 1: Repository Hygiene And Safe Example Env + +**Files:** +- Modify: `.gitignore` +- Modify: `.env.example` +- Modify: `README.md` +- Modify: `docs/07-deployment.md` +- Untrack: root screenshots and `review_diff.txt` + +- [ ] **Step 1: Inspect tracked artifacts and secret-shaped examples** + +Run: +```powershell +git ls-files '*.png' 'review_diff.txt' '.env.example' +rg -n "MAIL_USERNAME|MAIL_APP_PASSWORD" .env.example README.md docs/07-deployment.md +``` + +Expected: screenshots and `review_diff.txt` are tracked; `.env.example` contains concrete mail values. + +- [ ] **Step 2: Untrack generated artifacts** + +Run: +```powershell +git rm --cached 01-login-page.png test-02-home.png test-03-activities.png test-04-activity-detail.png test-05-seckill.png test-06-orders.png test-07-login-mall.png test-08-nav-activities.png test-09-nav-orders.png test-10-home-final.png review_diff.txt +``` + +Expected: files are staged as deleted from Git but may remain in the working directory. + +- [ ] **Step 3: Add ignore patterns** + +Patch `.gitignore` with: +```gitignore +review_diff.txt +*-browser-qa*.png +01-login-page.png +test-*.png +``` + +- [ ] **Step 4: Sanitize `.env.example` mail values** + +Replace the mail section with: +```dotenv +# Mail Service +MAIL_USERNAME=your-email@qq.com +MAIL_APP_PASSWORD=change-me-mail-app-password +MAIL_FROM=SpikeGuard +MAIL_HOST=smtp.qq.com +MAIL_PORT=465 +``` + +- [ ] **Step 5: Update docs for safe example values** + +In `README.md` and `docs/07-deployment.md`, keep the existing mail setup guidance but ensure examples use `your-email@qq.com` and `change-me-mail-app-password`, never a real account or app password. + +- [ ] **Step 6: Verify hygiene** + +Run: +```powershell +git ls-files '*.png' 'review_diff.txt' +rg -n "known-real-mail-username|known-real-mail-app-password" . -g "!**/node_modules/**" -g "!**/target/**" -g "!review_diff.txt" +docker compose --env-file .env.example config --quiet +``` + +Expected: no tracked screenshots or review diff; no real-looking mail credential remnants; compose config succeeds. + +- [ ] **Step 7: Commit** + +```powershell +git add .gitignore .env.example README.md docs/07-deployment.md +git add -u +git commit -m "chore: clean generated artifacts and example secrets" +``` + +## Task 2: Registration Contract Alignment + +**Files:** +- Modify: `backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java` +- Modify: `backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java` +- Modify: `frontend/src/stores/auth.ts` +- Modify: `frontend/src/views/LoginView.vue` +- Modify: `docs/04-api.md` + +- [ ] **Step 1: Write failing backend tests** + +In `AuthServiceTest`, replace null-code registration tests with explicit verification setup: +```java +@Test +void registrationRequiresEmailAndCode() { + assertThatThrownBy(() -> authService.register(new RegisterRequest("alice", "secret123", "Alice", null, null))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("邮箱和验证码不能为空"); +} + +@Test +void validVerificationCodeRegistersAndCanLogin() { + authService.sendVerificationCode("alice@example.com", "123456"); + + var registered = authService.register(new RegisterRequest("alice", "secret123", "Alice", "alice@example.com", "123456")); + + assertThat(registered.token()).isNotBlank(); + assertThat(registered.user().email()).isEqualTo("alice@example.com"); + assertThat(registered.user().roles()).contains("ADMIN", "USER"); + var loggedIn = authService.login(new LoginRequest("alice", "secret123")); + assertThat(loggedIn.user().id()).isEqualTo(registered.user().id()); +} + +@Test +void usedVerificationCodeIsRejected() { + authService.sendVerificationCode("bob@example.com", "123456"); + authService.register(new RegisterRequest("bob", "secret123", "Bob", "bob@example.com", "123456")); + + assertThatThrownBy(() -> authService.register(new RegisterRequest("bobby", "secret123", "Bobby", "bob@example.com", "123456"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("验证码无效"); +} +``` + +- [ ] **Step 2: Run backend auth tests to verify failure** + +Run: +```powershell +mvn "-Dmaven.repo.local=L:\Develop\apache-maven-3.9.12\repository" -pl sg-auth-service -am -Dtest=AuthServiceTest "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +Expected: fail because registration currently permits null email/code. + +- [ ] **Step 3: Require email/code in `AuthService.register`** + +Use this logic before querying verification code: +```java +String email = request.email(); +String code = request.code(); +if (email == null || email.isBlank() || code == null || code.isBlank()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "邮箱和验证码不能为空"); +} +var vcRecord = verificationCodeRepository + .findTopByEmailAndCodeAndUsedFalseOrderByCreatedAtDesc(email, code) + .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "验证码无效")); +if (vcRecord.expiresAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "验证码已过期"); +} +verificationCodeRepository.markUsed(vcRecord.id()); +``` + +- [ ] **Step 4: Update admin auth store** + +Change `frontend/src/stores/auth.ts` registration action to: +```ts +async register(username: string, password: string, nickname: string, email: string, code: string) { + const response = await postData("/api/auth/register", { username, password, nickname, email, code }); + this.token = response.token; + this.user = response.user; + localStorage.setItem("spikeguard-token", response.token); +} + +async sendCode(email: string) { + await postData("/api/auth/send-code", { email }); +} +``` + +- [ ] **Step 5: Update admin registration form** + +In `frontend/src/views/LoginView.vue`, extend form state: +```ts +const form = reactive({ username: "", password: "", nickname: "", email: "", code: "" }); +const codeSending = ref(false); +const countdown = ref(0); +let countdownTimer: ReturnType | null = null; +``` + +Add email and code form fields only when `mode === "register"`, matching the mall pattern. Add `sendCode()` that validates email, calls `auth.sendCode(form.email.trim())`, shows Element Plus success/error messages, and starts a 60-second countdown. + +Update submit registration call: +```ts +await auth.register(form.username, form.password, form.nickname || form.username, form.email, form.code); +``` + +- [ ] **Step 6: Update API docs** + +In `docs/04-api.md`, change `email` and `code` rows for register to required `是`. + +- [ ] **Step 7: Verify registration alignment** + +Run: +```powershell +mvn "-Dmaven.repo.local=L:\Develop\apache-maven-3.9.12\repository" -pl sg-auth-service -am -Dtest=AuthServiceTest "-Dsurefire.failIfNoSpecifiedTests=false" test +cd frontend +npm run build +``` + +Expected: auth tests pass and admin frontend builds. + +- [ ] **Step 8: Commit** + +```powershell +git add backend/sg-auth-service/src/main/java/com/kaduox/spikeguard/auth/service/AuthService.java backend/sg-auth-service/src/test/java/com/kaduox/spikeguard/auth/AuthServiceTest.java frontend/src/stores/auth.ts frontend/src/views/LoginView.vue docs/04-api.md +git commit -m "fix: require email verification for registration" +``` + +## Task 3: Monitor Aggregation Contracts + +**Files:** +- Modify: `backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/web/OrderController.java` +- Modify: `backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/service/OrderService.java` +- Modify: `backend/sg-order-service/src/main/java/com/kaduox/spikeguard/order/repository/OrderRepository.java` +- Modify: `backend/sg-payment-service/src/main/java/com/kaduox/spikeguard/payment/web/PaymentController.java` +- Modify: `backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/web/RiskDecisionController.java` +- Modify: `backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/service/RiskDecisionService.java` +- Modify: `backend/sg-risk-service/src/main/java/com/kaduox/spikeguard/risk/repository/RiskDecisionRepository.java` +- Modify: `backend/sg-monitor-service/src/main/java/com/kaduox/spikeguard/monitor/service/MonitorService.java` +- Test: focused service/controller tests in affected modules + +- [ ] **Step 1: Write failing path test for monitor service** + +Create `backend/sg-monitor-service/src/test/java/com/kaduox/spikeguard/monitor/MonitorServiceHttpTest.java`. Use JDK `com.sun.net.httpserver.HttpServer` to record request paths and return `ApiResponse` JSON payloads: +```java +class MonitorServiceHttpTest { + private HttpServer orderServer; + private HttpServer paymentServer; + private HttpServer riskServer; + private final List orderPaths = new CopyOnWriteArrayList<>(); + private final List paymentPaths = new CopyOnWriteArrayList<>(); + private final List riskPaths = new CopyOnWriteArrayList<>(); + + @AfterEach + void tearDown() { + Stream.of(orderServer, paymentServer, riskServer) + .filter(Objects::nonNull) + .forEach(server -> server.stop(0)); + } + + @Test + void overviewCallsDocumentedServicePathsAndUnwrapsApiResponses() throws Exception { + orderServer = server(orderPaths, Map.of( + "/api/orders/count", "{\"code\":0,\"message\":\"success\",\"data\":3}", + "/api/orders/queue-summary", "{\"code\":0,\"message\":\"success\",\"data\":{\"createdOrders\":3,\"pendingOrders\":1}}")); + paymentServer = server(paymentPaths, Map.of( + "/api/payments/count", "{\"code\":0,\"message\":\"success\",\"data\":2}")); + riskServer = server(riskPaths, Map.of( + "/api/risk/summary", "{\"code\":0,\"message\":\"success\",\"data\":{\"pass\":1,\"review\":1,\"block\":0,\"events\":2}}")); + + var service = new MonitorService( + RestClient.builder().baseUrl(baseUrl(orderServer)).build(), + RestClient.builder().baseUrl(baseUrl(paymentServer)).build(), + RestClient.builder().baseUrl(baseUrl(riskServer)).build()); + + var snapshot = service.overview(); + + assertThat(snapshot.orders()).isEqualTo(3); + assertThat(snapshot.payments()).isEqualTo(2); + assertThat(snapshot.queue()).containsEntry("pendingOrders", 1); + assertThat(snapshot.risk()).containsEntry("events", 2); + assertThat(orderPaths).containsExactlyInAnyOrder("/api/orders/count", "/api/orders/queue-summary"); + assertThat(paymentPaths).containsExactly("/api/payments/count"); + assertThat(riskPaths).containsExactly("/api/risk/summary"); + } +} +``` + +- [ ] **Step 2: Add order count and queue summary endpoints** + +Expose: +```java +@GetMapping("/count") +ApiResponse count() { + return ApiResponse.success(orderService.count()); +} + +@GetMapping("/queue-summary") +ApiResponse> queueSummary() { + return ApiResponse.success(orderService.queueSummary()); +} +``` + +Use repository count: +```java +public long count() { + Long count = jdbcTemplate.queryForObject("select count(*) from sg_order", Long.class); + return count == null ? 0L : count; +} +``` + +Use queue summary: +```java +public Map queueSummary() { + return Map.of("createdOrders", count(), "pendingOrders", countByStatus("CREATED")); +} +``` + +- [ ] **Step 3: Add payment count endpoint** + +Expose in `PaymentController`: +```java +@GetMapping("/count") +ApiResponse count() { + return ApiResponse.success(paymentService.count()); +} +``` + +- [ ] **Step 4: Add risk summary endpoint** + +Expose in `RiskDecisionController`: +```java +@GetMapping("/summary") +ApiResponse> summary() { + return ApiResponse.success(riskDecisionService.summary()); +} +``` + +Service/repository summary should count `PASS`, `REVIEW`, and `BLOCK` decisions and total events using existing risk tables. + +- [ ] **Step 5: Update monitor paths and response unwrapping** + +Change paths in `MonitorService`: +```java +"/api/risk/summary" +"/api/orders/queue-summary" +"/api/orders/count" +"/api/payments/count" +``` + +Remote endpoints return `ApiResponse`, so add helpers that unwrap the `data` field using `ParameterizedTypeReference>` for maps and a numeric helper for count responses. + +- [ ] **Step 6: Verify backend focused tests** + +Run: +```powershell +mvn "-Dmaven.repo.local=L:\Develop\apache-maven-3.9.12\repository" -pl sg-order-service,sg-payment-service,sg-risk-service,sg-monitor-service -am test +``` + +Expected: all affected backend module tests pass. + +- [ ] **Step 7: Commit** + +```powershell +git add backend/sg-order-service backend/sg-payment-service backend/sg-risk-service backend/sg-monitor-service +git commit -m "fix: align monitor aggregation service contracts" +``` + +## Task 4: Frontend Proxy Consistency + +**Files:** +- Modify: `frontend-mall/vite.config.ts` +- Modify: `.env.example` +- Modify: `README.md` + +- [ ] **Step 1: Update mall Vite proxy** + +Change proxy target to: +```ts +target: process.env.VITE_API_PROXY_TARGET || process.env.VITE_API_BASE_URL || 'http://localhost:8080', +``` + +- [ ] **Step 2: Ensure `.env.example` has proxy docs values** + +Keep: +```dotenv +VITE_API_BASE_URL= +VITE_API_PROXY_TARGET=http://localhost:8080 +``` + +- [ ] **Step 3: Update README wording** + +Explain that local Vite dev servers should keep `VITE_API_BASE_URL` empty and set `VITE_API_PROXY_TARGET` when proxying to a remote gateway. + +- [ ] **Step 4: Verify frontend builds** + +Run: +```powershell +cd frontend-mall +npm run build +cd ..\frontend +npm run build +``` + +Expected: both builds pass. + +- [ ] **Step 5: Commit** + +```powershell +git add frontend-mall/vite.config.ts .env.example README.md +git commit -m "chore: align frontend proxy configuration" +``` + +## Task 5: Admin Bundle Splitting + +**Files:** +- Modify: `frontend/vite.config.ts` + +- [ ] **Step 1: Add manual chunks** + +Add `build.rollupOptions.output.manualChunks`: +```ts +build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes("node_modules")) return undefined; + if (id.includes("/echarts/")) return "echarts"; + if (id.includes("/element-plus/") || id.includes("/@element-plus/")) return "element-plus"; + if (id.includes("/vue/") || id.includes("/vue-router/") || id.includes("/pinia/")) return "vue-vendor"; + return "vendor"; + } + } + } +} +``` + +Use normalized IDs if Windows backslashes appear: +```ts +const normalizedId = id.replaceAll("\\", "/"); +``` + +- [ ] **Step 2: Verify admin build** + +Run: +```powershell +cd frontend +npm run build +``` + +Expected: build passes and chunks are split into named vendor groups with no avoidable chunk-size warning. + +- [ ] **Step 3: Commit** + +```powershell +git add frontend/vite.config.ts +git commit -m "chore: split admin frontend vendor chunks" +``` + +## Task 6: Final Verification And Push + +**Files:** +- No new source changes unless verification exposes a defect. + +- [ ] **Step 1: Run full verification** + +Run: +```powershell +docker compose --env-file .env.example config --quiet +cd backend +mvn "-Dmaven.repo.local=L:\Develop\apache-maven-3.9.12\repository" clean package +cd ..\frontend +npm run build +cd ..\frontend-mall +npm run build +cd .. +git diff --check +``` + +Expected: all commands pass. + +- [ ] **Step 2: Browser smoke against the VM gateway** + +Start admin and mall dev servers against the VM gateway with: +```powershell +$env:VITE_API_PROXY_TARGET='http://192.168.101.130:18080' +``` + +Then validate: +- Admin registration page shows email/code fields and send-code button. +- Mall preview home and activity list load without login. +- Login/register routes render without console errors. + +- [ ] **Step 3: Inspect final Git state** + +Run: +```powershell +git status --short --branch +git log --oneline -8 +git remote -v +``` + +Expected: branch is `main`, working tree clean, remote points to the intended repository. + +- [ ] **Step 4: Push** + +Run: +```powershell +git push origin main +``` + +Expected: push succeeds. diff --git a/docs/superpowers/specs/2026-06-30-full-quality-optimization-design.md b/docs/superpowers/specs/2026-06-30-full-quality-optimization-design.md new file mode 100644 index 0000000..5e6cadd --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-full-quality-optimization-design.md @@ -0,0 +1,131 @@ +# SpikeGuard Full Quality Optimization Design + +## Context + +SpikeGuard v1.1.0 is currently on `main` and the baseline verification passes: + +- `docker compose --env-file .env.example config --quiet` +- `mvn -Dmaven.repo.local=L:\Develop\apache-maven-3.9.12\repository clean package` in `backend` +- `npm run build` in `frontend` +- `npm run build` in `frontend-mall` + +The codebase still has quality debt that affects maintainability, security posture, and production confidence: + +- Git tracks browser test screenshots and `review_diff.txt`, which are generated artifacts rather than source. +- `.env.example` contains concrete mail account and app-password-shaped values instead of placeholders. +- Documentation says email verification registration is supported, but the admin frontend still registers without email/code and the backend allows email verification to be skipped. +- `sg-monitor-service` fetches non-existent summary/count paths, logs warnings, and silently falls back to empty/default metrics. +- `frontend-mall` has a hard-coded Vite proxy target while `frontend` supports environment-driven proxying. +- `frontend` production build passes but emits large chunk warnings. + +## Goals + +1. Reduce repository leakage and artifact noise without deleting useful documentation. +2. Make registration behavior consistent across backend, admin frontend, mall frontend, and docs. +3. Make monitor aggregation use real service contracts instead of failing remote calls. +4. Normalize frontend dev proxy configuration for both frontends. +5. Improve admin frontend bundle splitting enough to remove avoidable large chunk warnings. +6. Preserve the current user-facing workflows and keep changes easy to verify with existing build/test commands. + +## Non-Goals + +- No new product modules or major UI redesign. +- No replacement of Nacos, Seata, Prometheus, Zipkin, ES, MinIO, or RabbitMQ integration. +- No migration away from the current Vue/Spring Boot stack. +- No broad package upgrades unless needed for the scoped fixes. + +## Approach Options + +### Option A: Quality And Security First + +Clean tracked artifacts/secrets, align registration semantics, fix monitor service contracts, normalize frontend proxy config, and split admin chunks. This is the recommended approach because it removes real release risk while keeping the blast radius bounded. + +### Option B: Runtime Integration First + +Focus only on VM/container runtime, smoke tests, browser tests, and service health. This gives deployment confidence but leaves repository hygiene and inconsistent registration behavior unresolved. + +### Option C: Frontend Experience First + +Prioritize visual polish, responsive layouts, and bundle size. This improves user experience but leaves backend monitor correctness and secret hygiene risks in place. + +## Selected Design + +Use Option A. The work is split into five independent units so each can be tested and committed separately. + +## Design Units + +### 1. Repository Hygiene And Example Secrets + +Remove tracked generated assets from Git while keeping local files untouched unless deletion is explicitly part of cleanup. Add ignore patterns for screenshot artifacts and review diff dumps. Replace concrete mail credentials in `.env.example` with placeholders such as `MAIL_USERNAME=your-email@qq.com` and `MAIL_APP_PASSWORD=change-me-mail-app-password`. + +The repository should continue tracking `.env.example`, but it must only contain safe example values. + +### 2. Registration Contract Alignment + +Backend `POST /api/auth/register` should require `email` and `code` when email verification is part of the documented registration flow. Tests should cover: + +- Missing email/code is rejected. +- Invalid or expired code is rejected. +- Valid unused code registers the user and marks the code used. +- Login still works after registration. + +Admin frontend registration should add email and verification code fields plus a send-code action matching the mall frontend. The shared auth store should accept email/code and call the existing backend API. Mall behavior should remain unchanged. + +### 3. Monitor Aggregation Contracts + +Add or align lightweight service endpoints used by `sg-monitor-service`: + +- Orders count and queue summary should come from `sg-order-service` under `/api/orders/**`. +- Payments count should come from `sg-payment-service` under `/api/payments/**`. +- Risk summary should come from `sg-risk-service` under `/api/risk/**`. + +`MonitorService` should call actual paths and tests should verify request paths with mocked `RestClient` behavior or focused service/repository tests. Graceful degradation remains, but expected paths should not fail during normal operation. + +### 4. Frontend Proxy Consistency + +`frontend-mall/vite.config.ts` should use `VITE_API_PROXY_TARGET || VITE_API_BASE_URL || "http://localhost:8080"` like the admin frontend. Runtime browser API base should stay same-origin by default so local development avoids CORS. + +Docs should mention: + +- `VITE_API_BASE_URL` is for direct browser API base overrides. +- `VITE_API_PROXY_TARGET` is for local Vite proxy target. + +### 5. Admin Bundle Splitting + +Configure Vite/Rollup manual chunks for the admin frontend so large vendor libraries are split predictably: + +- `vue-vendor`: Vue, Router, Pinia. +- `element-plus`: Element Plus and icons. +- `echarts`: ECharts. +- `vendor`: remaining node_modules. + +Keep dynamic route imports as they are. Build output should pass without avoidable chunk-size warnings, or the remaining warning should be justified in the final verification. + +## Error Handling + +- Registration should return existing `ApiResponse.failure` format through the existing `BusinessException` handler. +- Send-code failures continue to surface as user-visible frontend errors. +- Monitor remote failures continue to degrade to defaults, but only after attempting documented, existing endpoints. +- Frontend proxy misconfiguration should fail visibly during browser/API smoke tests rather than requiring CORS workarounds. + +## Testing Plan + +Run tests incrementally after each unit: + +- Backend focused tests for auth registration verification. +- Backend focused tests for monitor aggregation and new summary/count endpoints. +- `docker compose --env-file .env.example config --quiet`. +- Backend full `mvn clean package`. +- Admin `npm run build`. +- Mall `npm run build`. +- Browser smoke tests for admin registration/login and mall preview/login if a local gateway is available. + +## Acceptance Criteria + +- No tracked root screenshots or `review_diff.txt` remain. +- `.env.example` has no real-looking mail app password or personal mail account. +- Admin and mall registration both use email verification. +- Backend rejects registration without required verification data. +- Monitor service no longer calls `/api/order/*`, `/api/payment/*`, or `/api/risk/summary` paths unless those paths actually exist. +- Both frontends can proxy to a configured gateway via `VITE_API_PROXY_TARGET`. +- All baseline verification commands pass. diff --git a/frontend-mall/src/api/activity.ts b/frontend-mall/src/api/activity.ts index c0b6121..c3dae52 100644 --- a/frontend-mall/src/api/activity.ts +++ b/frontend-mall/src/api/activity.ts @@ -6,16 +6,26 @@ export interface Activity { description?: string startTime: string endTime: string - status: 'UPCOMING' | 'ACTIVE' | 'ENDED' - productCount: number + status: 'DRAFT' | 'PUBLISHED' | 'RUNNING' | 'FINISHED' | 'CANCELLED' + perUserLimit: number + riskLevel: 'NORMAL' | 'HIGH' | 'CRITICAL' + warmupStatus: 'NOT_WARMED' | 'WARMING' | 'WARMED' | 'FAILED' + skus: ActivitySku[] } -export interface ActivityDetail extends Activity { - skus: SkuItem[] -} +export type ActivityDetail = Activity -export interface SkuItem { +export interface ActivitySku { id: number + activityId: number + productId: number + totalStock: number + availableStock: number + frozenStock: number + soldStock: number +} + +export interface SkuItem extends ActivitySku { name: string image?: string originalPrice: number diff --git a/frontend-mall/src/api/client.ts b/frontend-mall/src/api/client.ts index caf939d..d587590 100644 --- a/frontend-mall/src/api/client.ts +++ b/frontend-mall/src/api/client.ts @@ -1,6 +1,12 @@ import axios from 'axios' import router from '../router' +interface ApiResponse { + code: number + message?: string + data: T +} + const client = axios.create({ baseURL: '', timeout: 15000, @@ -21,9 +27,22 @@ client.interceptors.request.use( (error) => Promise.reject(error) ) +function isApiResponse(value: unknown): value is ApiResponse { + return !!value && typeof value === 'object' && typeof (value as ApiResponse).code === 'number' +} + // Response interceptor - handle 401 client.interceptors.response.use( - (response) => response, + (response) => { + if (isApiResponse(response.data)) { + const payload = response.data + if (payload.code !== 0) { + return Promise.reject(new Error(payload.message || 'Request failed')) + } + response.data = payload.data + } + return response + }, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token') diff --git a/frontend-mall/src/api/product.ts b/frontend-mall/src/api/product.ts new file mode 100644 index 0000000..4e7f0eb --- /dev/null +++ b/frontend-mall/src/api/product.ts @@ -0,0 +1,15 @@ +import client from './client' + +export interface Product { + id: number + title: string + coverUrl?: string + originPrice: number + seckillPrice: number + status: string + description?: string +} + +export function listProducts() { + return client.get('/api/products') +} diff --git a/frontend-mall/src/views/ActivitiesView.vue b/frontend-mall/src/views/ActivitiesView.vue index a4d7088..4fc86b5 100644 --- a/frontend-mall/src/views/ActivitiesView.vue +++ b/frontend-mall/src/views/ActivitiesView.vue @@ -14,7 +14,7 @@

{{ act.name }}

{{ formatTime(act.startTime) }} - {{ formatTime(act.endTime) }}

-

共 {{ act.productCount }} 件商品

+

共 {{ activityProductCount(act) }} 件商品

{{ statusText(act.status) }} @@ -50,15 +50,31 @@ function goDetail(id: number) { } function statusText(status: string) { - const map: Record = { UPCOMING: '即将开始', ACTIVE: '进行中', ENDED: '已结束' } + const map: Record = { + DRAFT: '草稿', + PUBLISHED: '即将开始', + RUNNING: '进行中', + FINISHED: '已结束', + CANCELLED: '已取消', + } return map[status] || status } function statusType(status: string) { - const map: Record = { UPCOMING: 'warning', ACTIVE: 'danger', ENDED: 'default' } + const map: Record = { + DRAFT: 'default', + PUBLISHED: 'warning', + RUNNING: 'danger', + FINISHED: 'default', + CANCELLED: 'default', + } return (map[status] || 'default') as 'warning' | 'danger' | 'default' } +function activityProductCount(activity: Activity) { + return activity.skus?.length || 0 +} + function formatTime(t: string) { return new Date(t).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } diff --git a/frontend-mall/src/views/ActivityDetailView.vue b/frontend-mall/src/views/ActivityDetailView.vue index 764a584..5bb3db5 100644 --- a/frontend-mall/src/views/ActivityDetailView.vue +++ b/frontend-mall/src/views/ActivityDetailView.vue @@ -51,12 +51,13 @@ ¥{{ sku.originalPrice }} +

库存 {{ sku.stock }} / {{ sku.stockLimit }}