diff --git a/docker-compose.yml b/docker-compose.yml index 3b0b1e8..3301837 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,7 +145,34 @@ services: start_period: 40s retries: 3 + # ─────────── 监控 ─────────── + prometheus: + image: prom/prometheus:latest + container_name: fruitshorts-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana:latest + container_name: fruitshorts-grafana + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: # 命名卷:跟着 compose 项目走,`docker compose down -v` 才会删 db-data: redis-data: + prometheus-data: + grafana-data: diff --git a/fruitshorts-admin-api/src/main/resources/application.yml b/fruitshorts-admin-api/src/main/resources/application.yml index 3202aef..29fcfa8 100644 --- a/fruitshorts-admin-api/src/main/resources/application.yml +++ b/fruitshorts-admin-api/src/main/resources/application.yml @@ -66,3 +66,15 @@ springdoc: logging: level: com.fruitshorts: debug + +management: + endpoints: + web: + exposure: + include: prometheus,health + endpoint: + health: + show-details: always + metrics: + tags: + application: ${spring.application.name} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java index 05036b8..c514761 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -4,6 +4,7 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.vo.GoogleLoginReqVO; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.redis.RateLimit; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.web.WebUtils; import com.fruitshorts.user.service.AppAuthService; @@ -36,6 +37,7 @@ public class AppAuthController { /** 发送验证码。频控在服务端,客户端不需要自己限流。 */ @Operation(summary = "发送验证码(频控在服务端)") + @RateLimit(limit = 20, windowSeconds = 60, key = "otp") @PostMapping("/otp") public Result sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) { otpService.send(req, WebUtils.getClientIp(request)); @@ -50,6 +52,7 @@ public class AppAuthController { } @Operation(summary = "登录(验证码或密码)") + @RateLimit(limit = 10, windowSeconds = 60, key = "login") @PostMapping("/login") public Result login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppEventController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppEventController.java new file mode 100644 index 0000000..a16855d --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppEventController.java @@ -0,0 +1,35 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.service.AppEventService; +import com.fruitshorts.app.vo.AppEventReqVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.web.WebUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 客户端埋点上报。 */ +@Tag(name = "埋点") +@RestController +@RequestMapping("/app") +@RequiredArgsConstructor +public class AppEventController { + + private final AppEventService eventService; + + @Operation(summary = "埋点事件批量上报") + @PostMapping("/events") + public Result report(@Valid @RequestBody AppEventReqVO req, HttpServletRequest request) { + eventService.save(req, SecurityUtils.getUserIdOrNull(), WebUtils.getClientIp(request)); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/entity/AppEventLog.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/entity/AppEventLog.java new file mode 100644 index 0000000..ea49306 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/entity/AppEventLog.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 埋点事件落库记录,为后续分析/漏斗预留原始数据。 */ +@Data +@TableName("app_event_log") +public class AppEventLog { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String eventName; + private LocalDateTime eventTime; + private Long userId; + private String deviceId; + private String sessionId; + private String page; + private String properties; + private String clientIp; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/mapper/AppEventLogMapper.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/mapper/AppEventLogMapper.java new file mode 100644 index 0000000..4959566 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/dal/mapper/AppEventLogMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.app.dal.entity.AppEventLog; + +/** 埋点事件 Mapper。 */ +public interface AppEventLogMapper extends BaseMapper { +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEventService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEventService.java new file mode 100644 index 0000000..aec18a8 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEventService.java @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fruitshorts.app.dal.entity.AppEventLog; +import com.fruitshorts.app.dal.mapper.AppEventLogMapper; +import com.fruitshorts.app.vo.AppEventReqVO; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +/** 埋点事件接收:批量落库,事件名与属性暂不校验、透传,口径后续与运营对齐。 */ +@Service +@RequiredArgsConstructor +public class AppEventService { + + private final AppEventLogMapper eventLogMapper; + private final ObjectMapper objectMapper; + + @SneakyThrows + public void save(AppEventReqVO req, Long userId, String clientIp) { + for (AppEventReqVO.EventItem item : req.getEvents()) { + AppEventLog log = new AppEventLog(); + log.setEventName(item.getEventName()); + log.setEventTime(item.getEventTime() == null ? LocalDateTime.now() : item.getEventTime()); + log.setUserId(userId); + log.setDeviceId(req.getDeviceId()); + log.setSessionId(req.getSessionId()); + log.setPage(item.getPage()); + log.setProperties(item.getProperties() == null ? null + : objectMapper.writeValueAsString(item.getProperties())); + log.setClientIp(clientIp); + log.setCreatedAt(LocalDateTime.now()); + eventLogMapper.insert(log); + } + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEventReqVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEventReqVO.java new file mode 100644 index 0000000..60bba95 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEventReqVO.java @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** 埋点事件批量上报请求。 */ +@Data +public class AppEventReqVO { + + @Schema(description = "设备ID") + private String deviceId; + + @Schema(description = "会话ID") + private String sessionId; + + @Schema(description = "事件列表") + @NotEmpty(message = "事件列表不能为空") + @Size(max = 50, message = "单次最多上报 50 条事件") + @Valid + private List events; + + @Data + public static class EventItem { + + @Schema(description = "事件名,如 playback_start/playback_end/pay_success") + @NotBlank(message = "事件名不能为空") + private String eventName; + + @Schema(description = "事件发生时间,缺省用服务端接收时间") + private LocalDateTime eventTime; + + @Schema(description = "页面/场景标识") + private String page; + + @Schema(description = "事件属性(key-value)") + private Map properties; + } +} diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index ae8eefe..c43fa8f 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -18,6 +18,10 @@ spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} mybatis-plus: configuration: @@ -93,3 +97,15 @@ fruitshorts: logging: level: com.fruitshorts: info + +management: + endpoints: + web: + exposure: + include: prometheus,health + endpoint: + health: + show-details: always + metrics: + tags: + application: ${spring.application.name} diff --git a/fruitshorts-framework/pom.xml b/fruitshorts-framework/pom.xml index a22de97..bafe214 100644 --- a/fruitshorts-framework/pom.xml +++ b/fruitshorts-framework/pom.xml @@ -66,5 +66,19 @@ org.springdoc springdoc-openapi-starter-webmvc-ui + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java new file mode 100644 index 0000000..c052cf7 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java @@ -0,0 +1,25 @@ +// Author: fruit.com.cn + +package com.fruitshorts.framework.redis; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 接口级固定窗口限流。基于 Redis INCR,key 按客户端 IP 维度隔离。 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + + /** 窗口内允许的最大请求次数。 */ + int limit() default 10; + + /** 窗口时长(秒)。 */ + int windowSeconds() default 60; + + /** 限流维度前缀,默认取方法名。 */ + String key() default ""; +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java new file mode 100644 index 0000000..5554e1b --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +package com.fruitshorts.framework.redis; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.Duration; + +/** + * 固定窗口限流切面:每次命中注解先 INCR,超阈值即拒绝。 + */ +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final RedisService redisService; + + @Before("@annotation(rateLimit)") + public void check(RateLimit rateLimit) { + long count = redisService.increment( + buildKey(rateLimit), Duration.ofSeconds(rateLimit.windowSeconds())); + if (count > rateLimit.limit()) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "请求过于频繁,请稍后再试"); + } + } + + private String buildKey(RateLimit rateLimit) { + String scope = rateLimit.key().isBlank() ? "default" : rateLimit.key(); + return "rate:" + scope + ":" + clientIp(); + } + + private String clientIp() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return "unknown"; + } + HttpServletRequest request = attrs.getRequest(); + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java new file mode 100644 index 0000000..62a3cdb --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java @@ -0,0 +1,45 @@ +// Author: fruit.com.cn + +package com.fruitshorts.framework.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Redis 字符串操作封装,供缓存与限流复用。 + * 统一在这里兜底过期策略,业务侧不直接接触 RedisTemplate。 + */ +@Component +@RequiredArgsConstructor +public class RedisService { + + private final StringRedisTemplate redisTemplate; + + public void set(String key, String value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + public String get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public void delete(String key) { + redisTemplate.delete(key); + } + + /** + * 固定窗口计数:自增并保证首次写入时设置过期时间。 + * 用于接口限流,返回当前窗口内的累计次数。 + */ + public long increment(String key, Duration window) { + Long count = redisTemplate.opsForValue().increment(key); + long value = count == null ? 1 : count; + if (value == 1) { + redisTemplate.expire(key, window); + } + return value; + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 0d6248a..34c7f8f 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -44,6 +44,8 @@ public class SecurityConfig { /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ "/app/config", "/app/notices", + /* 埋点上报:游客浏览也要记录,匿名可访问 */ + "/app/events", /* 媒资:封面与视频用 /