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-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index b0711b3..d491a68 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 @@ -41,6 +41,8 @@ public class SecurityConfig { "/app/dramas/**", "/app/episodes/*/play", "/app/config/**", + /* 埋点上报:游客浏览也要记录,匿名可访问 */ + "/app/events", /* 媒资:封面与视频用 /