diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java index fce18e6..b0bee96 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java @@ -110,4 +110,5 @@ public interface MemberErrorCode { ErrorCode RIDE_PLAN_ALREADY_JOINED = new ErrorCode(1_004_020_003, "已加入该发车局,请勿重复加入"); ErrorCode RIDE_PLAN_NOT_JOINED = new ErrorCode(1_004_020_004, "尚未加入该发车局"); ErrorCode RIDE_TAG_NOT_EXISTS = new ErrorCode(1_004_020_005, "骑行标签不存在或已停用"); + ErrorCode RIDE_RECORD_NOT_EXISTS = new ErrorCode(1_004_020_006, "骑行战报不存在或已下架"); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java new file mode 100644 index 0000000..eb63ca2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; +import cn.iocoder.yudao.module.member.service.rideplan.MemberRideRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.validation.Valid; +import javax.validation.constraints.Size; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 骑遇骑行记录(战报): + * - 战报墙 page / 详情 get / 天梯榜 rank @PermitAll 免登录,游客可浏览,RespVO 已做字段脱敏(只暴露昵称/头像) + * - 上报 create / 点赞 like / 举报 report 需登录(/app-api/** 默认拦截,未带有效 token 返回 401) + */ +@Tag(name = "用户 APP - 骑遇骑行记录") +@RestController +@RequestMapping("/member/ride-record") +@Validated +public class AppRideRecordController { + + @Resource + private MemberRideRecordService rideRecordService; + + @PostMapping("/create") + @Operation(summary = "上报骑行战报(需登录)", description = "完骑后提交;cornerName 有值则参与天梯榜") + public CommonResult create(@Valid @RequestBody AppRideRecordCreateReqVO reqVO) { + return success(rideRecordService.createRideRecord(getLoginUserId(), reqVO)); + } + + @GetMapping("/page") + @Operation(summary = "战报墙分页(免登录)", description = "游客可查;登录后返回 likedByMe") + @PermitAll + public CommonResult> page(@Valid AppRideRecordPageReqVO reqVO) { + return success(rideRecordService.getRideRecordPage(reqVO, getLoginUserId())); + } + + @GetMapping("/my") + @Operation(summary = "我的骑行记录分页(需登录)") + public CommonResult> my(@Valid AppRideRecordPageReqVO reqVO) { + return success(rideRecordService.getMyRideRecordPage(getLoginUserId(), reqVO)); + } + + @GetMapping("/get") + @Operation(summary = "战报详情(免登录,含轨迹)") + @Parameter(name = "id", description = "战报编号", required = true, example = "1") + @PermitAll + public CommonResult get(@RequestParam("id") Long id) { + return success(rideRecordService.getRideRecord(id, getLoginUserId())); + } + + @GetMapping("/rank") + @Operation(summary = "弯道天梯榜(免登录)", description = "按路线+弯道分组,组内按压弯角度降序;支持过滤") + @PermitAll + public CommonResult> rank( + @RequestParam(value = "roadName", required = false) + @Size(max = 50, message = "路线名长度不能超过 50") String roadName, + @RequestParam(value = "cornerName", required = false) + @Size(max = 50, message = "弯道名长度不能超过 50") String cornerName) { + return success(rideRecordService.getCornerRank(roadName, cornerName)); + } + + @PostMapping("/like") + @Operation(summary = "点赞/取消点赞(需登录)", description = "幂等切换,返回操作后是否处于点赞状态") + @Parameter(name = "recordId", description = "战报编号", required = true, example = "1") + public CommonResult like(@RequestParam("recordId") Long recordId) { + return success(rideRecordService.toggleLike(getLoginUserId(), recordId)); + } + + @PostMapping("/report") + @Operation(summary = "举报战报(需登录)", description = "达到阈值自动下架") + @Parameter(name = "recordId", description = "战报编号", required = true, example = "1") + public CommonResult report(@RequestParam("recordId") Long recordId) { + rideRecordService.reportRideRecord(getLoginUserId(), recordId); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java new file mode 100644 index 0000000..d05c3f5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.math.BigDecimal; + +@Schema(description = "用户 APP - 骑行战报上报 Request VO(完骑后提交,需登录)") +@Data +public class AppRideRecordCreateReqVO { + + @Schema(description = "战报标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "南昆山 63 连弯,今天左弯终于不虚了") + @NotBlank(message = "战报标题不能为空") + @Size(max = 100, message = "战报标题不能超过 100 字") + private String title; + + @Schema(description = "路线名(可选)", example = "南昆山") + @Size(max = 50, message = "路线名不能超过 50 字") + private String roadName; + + @Schema(description = "弯道名(可选,有值参与天梯榜)", example = "观音潭右弯") + @Size(max = 50, message = "弯道名不能超过 50 字") + private String cornerName; + + @Schema(description = "里程(公里)", requiredMode = Schema.RequiredMode.REQUIRED, example = "87.3") + @NotNull(message = "里程不能为空") + @DecimalMin(value = "0.1", message = "里程至少 0.1 公里") + @DecimalMax(value = "5000", message = "里程异常") + private BigDecimal distanceKm; + + @Schema(description = "骑行时长(秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5760") + @NotNull(message = "骑行时长不能为空") + @Min(value = 30, message = "骑行时长太短") + private Integer durationSec; + + @Schema(description = "极速(km/h)", requiredMode = Schema.RequiredMode.REQUIRED, example = "138") + @NotNull(message = "极速不能为空") + @DecimalMin(value = "0", message = "极速不能为负") + @DecimalMax(value = "500", message = "极速异常") + private BigDecimal topSpeedKmh; + + @Schema(description = "过弯次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "63") + @NotNull(message = "过弯次数不能为空") + @Min(value = 0, message = "过弯次数不能为负") + @Max(value = 10000, message = "过弯次数异常") + private Integer curveCount; + + @Schema(description = "最大压弯角度(度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "47.2") + @NotNull(message = "最大压弯角度不能为空") + @DecimalMin(value = "0", message = "压弯角度不能为负") + @DecimalMax(value = "90", message = "压弯角度异常") + private BigDecimal maxLean; + + @Schema(description = "轨迹点 JSON:[{\"lat\":23.1,\"lng\":113.3,\"speed\":88}],可选,仅详情接口回显") + @Size(max = 2_000_000, message = "轨迹数据过大") + private String trackJson; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java new file mode 100644 index 0000000..bfd3eda --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.Size; + +@Schema(description = "用户 APP - 骑行战报分页查询 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class AppRideRecordPageReqVO extends PageParam { + + @Schema(description = "按骑手过滤(查某人战报)", example = "1024") + private Long userId; + + @Schema(description = "路线名过滤", example = "南昆山") + @Size(max = 50, message = "路线名长度不能超过 50") + private String roadName; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java new file mode 100644 index 0000000..ffb5ed1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 APP - 骑行战报 Response VO(列表不含轨迹,详情含)") +@Data +public class AppRideRecordRespVO { + + @Schema(description = "战报编号", example = "1") + private Long id; + + @Schema(description = "战报标题", example = "南昆山 63 连弯,今天左弯终于不虚了") + private String title; + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "里程(公里)", example = "87.3") + private BigDecimal distanceKm; + + @Schema(description = "骑行时长(秒)", example = "5760") + private Integer durationSec; + + @Schema(description = "极速(km/h)", example = "138") + private BigDecimal topSpeedKmh; + + @Schema(description = "过弯次数", example = "63") + private Integer curveCount; + + @Schema(description = "最大压弯角度(度)", example = "47.2") + private BigDecimal maxLean; + + @Schema(description = "轨迹点 JSON,仅详情接口返回") + private String trackJson; + + @Schema(description = "点赞数", example = "128") + private Integer likeCount; + + @Schema(description = "当前登录用户是否已点赞(游客恒为 false)") + private Boolean likedByMe; + + // —— 骑手(只暴露昵称/头像,不含手机号等敏感字段)—— + + @Schema(description = "骑手编号", example = "1024") + private Long userId; + + @Schema(description = "骑手昵称", example = "山路信天游") + private String nickname; + + @Schema(description = "骑手头像") + private String avatar; + + @Schema(description = "发布时间") + private LocalDateTime createTime; + + @Schema(description = "天梯榜条目(rank 接口专用)") + @Data + public static class RankItem { + + @Schema(description = "名次,从 1 开始", example = "1") + private Integer rank; + + @Schema(description = "战报编号", example = "5") + private Long recordId; + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "最大压弯角度(度)", example = "52.0") + private BigDecimal maxLean; + + @Schema(description = "极速(km/h)", example = "62") + private BigDecimal topSpeedKmh; + + @Schema(description = "骑手编号", example = "1024") + private Long userId; + + @Schema(description = "骑手昵称", example = "压弯不压车") + private String nickname; + + @Schema(description = "骑手头像") + private String avatar; + + @Schema(description = "骑行时间") + private LocalDateTime createTime; + + } + + @Schema(description = "弯道榜单(rank 接口返回,按弯道分组)") + @Data + public static class CornerBoard { + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "该弯道上榜条目(已按压弯角度降序)") + private List entries; + + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java new file mode 100644 index 0000000..4e787d5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * 骑遇骑行记录(战报)DO + */ +@TableName("member_ride_record") +@KeySequence("member_ride_record_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideRecordDO extends TenantBaseDO { + + @TableId + private Long id; + /** 骑手编号 member_user.id */ + private Long userId; + /** 战报标题 */ + private String title; + /** 路线名(如:南昆山) */ + private String roadName; + /** 弯道名(如:观音潭右弯),为空表示非跑山记录 */ + private String cornerName; + /** 里程(公里) */ + private BigDecimal distanceKm; + /** 骑行时长(秒) */ + private Integer durationSec; + /** 极速(km/h) */ + private BigDecimal topSpeedKmh; + /** 过弯次数 */ + private Integer curveCount; + /** 最大压弯角度(度) */ + private BigDecimal maxLean; + /** 轨迹点 JSON([{"lat":..,"lng":..,"speed":..}]),仅详情接口返回 */ + private String trackJson; + /** 点赞数(冗余计数) */ + private Integer likeCount; + /** 被举报次数 */ + private Integer reportCount; + /** + * 状态:0 正常 1 已下架 + */ + private Integer status; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java new file mode 100644 index 0000000..edb1192 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 骑行战报点赞 DO(一条记录一个用户只能赞一次,重复点赞视为取消) + */ +@TableName("member_ride_record_like") +@KeySequence("member_ride_record_like_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideRecordLikeDO extends TenantBaseDO { + + @TableId + private Long id; + /** 战报编号 member_ride_record.id */ + private Long recordId; + /** 点赞人编号 member_user.id */ + private Long userId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java new file mode 100644 index 0000000..65a82b2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordLikeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface MemberRideRecordLikeMapper extends BaseMapperX { + + default MemberRideRecordLikeDO selectByRecordIdAndUserId(Long recordId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRideRecordLikeDO::getRecordId, recordId) + .eq(MemberRideRecordLikeDO::getUserId, userId)); + } + + default List selectListByUserIdAndRecordIds(Long userId, Collection recordIds) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRideRecordLikeDO::getUserId, userId) + .in(MemberRideRecordLikeDO::getRecordId, recordIds)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java new file mode 100644 index 0000000..e55b224 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberRideRecordMapper extends BaseMapperX { + + /** + * 战报墙分页(免登录可查):只查正常状态,按发布时间倒序 + */ + default PageResult selectPage(AppRideRecordPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MemberRideRecordDO::getUserId, reqVO.getUserId()) + .eqIfPresent(MemberRideRecordDO::getRoadName, reqVO.getRoadName()); + wrapper.eq(MemberRideRecordDO::getStatus, 0); + wrapper.orderByDesc(MemberRideRecordDO::getId); + return selectPage(reqVO, wrapper); + } + + /** + * 我的历史骑行(分页) + */ + default PageResult selectMyPage(Long userId, AppRideRecordPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eq(MemberRideRecordDO::getUserId, userId) + .eq(MemberRideRecordDO::getStatus, 0); + wrapper.orderByDesc(MemberRideRecordDO::getId); + return selectPage(reqVO, wrapper); + } + + /** + * 弯道天梯榜:取最大压弯角度最高的前 N 条(有弯道名的跑山记录) + */ + default List selectRank(String roadName, String cornerName, int limit) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MemberRideRecordDO::getRoadName, roadName) + .eqIfPresent(MemberRideRecordDO::getCornerName, cornerName); + wrapper.eq(MemberRideRecordDO::getStatus, 0); + wrapper.isNotNull(MemberRideRecordDO::getCornerName); + wrapper.ne(MemberRideRecordDO::getCornerName, ""); + wrapper.orderByDesc(MemberRideRecordDO::getMaxLean); + wrapper.last("LIMIT " + limit); + return selectList(wrapper); + } + + default MemberRideRecordDO selectByIdAndStatus(Long id, Integer status) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRideRecordDO::getId, id) + .eq(MemberRideRecordDO::getStatus, status)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java new file mode 100644 index 0000000..6ee9a10 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; + +import java.util.List; + +/** + * 骑遇骑行记录(战报)Service + */ +public interface MemberRideRecordService { + + /** + * 上报一次骑行战报(需登录) + */ + Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO); + + /** + * 战报墙分页(免登录;登录后返回 likedByMe) + */ + PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId); + + /** + * 我的骑行记录分页(需登录) + */ + PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO); + + /** + * 战报详情(免登录,含轨迹) + */ + AppRideRecordRespVO getRideRecord(Long id, Long loginUserId); + + /** + * 弯道天梯榜(免登录):按 road+corner 分组,组内按压弯角度降序 + */ + List getCornerRank(String roadName, String cornerName); + + /** + * 点赞/取消点赞(需登录,幂等切换),返回操作后是否处于点赞状态 + */ + boolean toggleLike(Long userId, Long recordId); + + /** + * 举报战报(需登录),达到阈值自动下架 + */ + void reportRideRecord(Long userId, Long recordId); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java new file mode 100644 index 0000000..83f36a0 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java @@ -0,0 +1,215 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordDO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordLikeDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordLikeMapper; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_RECORD_NOT_EXISTS; + +@Service +@Validated +public class MemberRideRecordServiceImpl implements MemberRideRecordService { + + /** 举报自动下架阈值 */ + private static final int REPORT_LIMIT = 5; + /** 状态:正常 */ + private static final int STATUS_NORMAL = 0; + /** 状态:已下架 */ + private static final int STATUS_OFFLINE = 1; + /** 天梯榜单弯道最多展示条数 */ + private static final int RANK_LIMIT = 100; + /** 每条弯道榜单最多人数 */ + private static final int CORNER_BOARD_SIZE = 5; + + @Resource + private MemberRideRecordMapper rideRecordMapper; + @Resource + private MemberRideRecordLikeMapper rideRecordLikeMapper; + @Resource + private MemberUserMapper userMapper; + + @Override + public Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO) { + MemberRideRecordDO record = MemberRideRecordDO.builder() + .userId(userId) + .title(reqVO.getTitle()) + .roadName(StrUtil.nullToEmpty(reqVO.getRoadName())) + .cornerName(StrUtil.nullToEmpty(reqVO.getCornerName())) + .distanceKm(reqVO.getDistanceKm()) + .durationSec(reqVO.getDurationSec()) + .topSpeedKmh(reqVO.getTopSpeedKmh()) + .curveCount(reqVO.getCurveCount()) + .maxLean(reqVO.getMaxLean()) + .trackJson(reqVO.getTrackJson()) + .likeCount(0) + .reportCount(0) + .status(STATUS_NORMAL) + .build(); + rideRecordMapper.insert(record); + return record.getId(); + } + + @Override + public PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId) { + PageResult pageResult = rideRecordMapper.selectPage(reqVO); + return new PageResult<>(convertList(pageResult.getList(), loginUserId, false), pageResult.getTotal()); + } + + @Override + public PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO) { + reqVO.setUserId(null); // my 接口以登录身份为准,忽略过滤参数 + PageResult pageResult = rideRecordMapper.selectMyPage(userId, reqVO); + return new PageResult<>(convertList(pageResult.getList(), userId, false), pageResult.getTotal()); + } + + @Override + public AppRideRecordRespVO getRideRecord(Long id, Long loginUserId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(id, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + return convertList(Collections.singletonList(record), loginUserId, true).get(0); + } + + @Override + public List getCornerRank(String roadName, String cornerName) { + List records = rideRecordMapper.selectRank(roadName, cornerName, RANK_LIMIT); + if (records.isEmpty()) { + return Collections.emptyList(); + } + // 骑手资料 + Set userIds = records.stream() + .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, u -> u)); + // 按 road+corner 分组(保持 maxLean 降序,selectRank 已排序) + Map> grouped = new LinkedHashMap<>(); + for (MemberRideRecordDO record : records) { + grouped.computeIfAbsent(record.getRoadName() + "§" + record.getCornerName(), + k -> new ArrayList<>()).add(record); + } + List boards = new ArrayList<>(grouped.size()); + for (List group : grouped.values()) { + MemberRideRecordDO first = group.get(0); + AppRideRecordRespVO.CornerBoard board = new AppRideRecordRespVO.CornerBoard(); + board.setRoadName(first.getRoadName()); + board.setCornerName(first.getCornerName()); + board.setEntries(group.stream().limit(CORNER_BOARD_SIZE) + .map(r -> { + AppRideRecordRespVO.RankItem item = new AppRideRecordRespVO.RankItem(); + item.setRecordId(r.getId()); + item.setRoadName(r.getRoadName()); + item.setCornerName(r.getCornerName()); + item.setMaxLean(r.getMaxLean()); + item.setTopSpeedKmh(r.getTopSpeedKmh()); + item.setUserId(r.getUserId()); + item.setCreateTime(r.getCreateTime()); + MemberUserDO u = userMap.get(r.getUserId()); + if (u != null) { + item.setNickname(u.getNickname()); + item.setAvatar(u.getAvatar()); + } + return item; + }).collect(Collectors.toList())); + for (int i = 0; i < board.getEntries().size(); i++) { + board.getEntries().get(i).setRank(i + 1); + } + boards.add(board); + } + return boards; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean toggleLike(Long userId, Long recordId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + MemberRideRecordLikeDO like = rideRecordLikeMapper.selectByRecordIdAndUserId(recordId, userId); + if (like != null) { + rideRecordLikeMapper.deleteById(like.getId()); + record.setLikeCount(Math.max(0, record.getLikeCount() - 1)); + rideRecordMapper.updateById(record); + return false; + } + rideRecordLikeMapper.insert(MemberRideRecordLikeDO.builder() + .recordId(recordId).userId(userId).build()); + record.setLikeCount(record.getLikeCount() + 1); + rideRecordMapper.updateById(record); + return true; + } + + @Override + public void reportRideRecord(Long userId, Long recordId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + record.setReportCount(record.getReportCount() + 1); + if (record.getReportCount() >= REPORT_LIMIT) { + record.setStatus(STATUS_OFFLINE); + } + rideRecordMapper.updateById(record); + } + + // ─── 私有:DO → RespVO 批量转换(补骑手资料、likedByMe)────── + + private List convertList(List records, Long loginUserId, + boolean withTrack) { + if (records == null || records.isEmpty()) { + return Collections.emptyList(); + } + // 骑手资料 + Set userIds = records.stream() + .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, u -> u)); + // 当前用户对这批战报的点赞 + Set likedRecordIds = Collections.emptySet(); + if (loginUserId != null) { + List recordIds = records.stream().map(MemberRideRecordDO::getId).collect(Collectors.toList()); + likedRecordIds = rideRecordLikeMapper.selectListByUserIdAndRecordIds(loginUserId, recordIds).stream() + .map(MemberRideRecordLikeDO::getRecordId).collect(Collectors.toSet()); + } + Set finalLiked = likedRecordIds; + return records.stream().map(record -> { + AppRideRecordRespVO vo = BeanUtils.toBean(record, AppRideRecordRespVO.class); + if (!withTrack) { + vo.setTrackJson(null); // 列表不带轨迹,减小响应体 + } + MemberUserDO user = userMap.get(record.getUserId()); + if (user != null) { + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + } + vo.setLikedByMe(loginUserId != null && finalLiked.contains(record.getId())); + return vo; + }).collect(Collectors.toList()); + } + +}