liujie
2026-07-31 0cf54692efc067adca7fa4308af241f723930b58
update
5个文件已添加
10个文件已修改
394 ■■■■■ 已修改文件
backend/src/main/java/com/tourguide/modules/order/dto/VisitorOrderStatRow.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/mapper/TourOrderMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/review/dto/VisitorReviewStatRow.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/review/mapper/OrderReviewMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/user/dto/MiniUserDetailResponse.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/user/dto/MiniUserResponse.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/user/service/GuideReceivingService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/user/service/MiniUserService.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/user/service/GuideReceivingServiceTest.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/user/service/MiniUserServiceProfileTest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/user/service/MiniUserServiceStatsTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/views/GuideUserDetailView.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_guide_app/pages/rating/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_guide_app/pages/workbench/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_guide_app/utils/rating.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/dto/VisitorOrderStatRow.java
New file
@@ -0,0 +1,16 @@
package com.tourguide.modules.order.dto;
import java.math.BigDecimal;
public class VisitorOrderStatRow {
    private Long visitorId;
    private Long orderCount;
    private BigDecimal totalConsumption;
    public Long getVisitorId() { return visitorId; }
    public void setVisitorId(Long visitorId) { this.visitorId = visitorId; }
    public Long getOrderCount() { return orderCount; }
    public void setOrderCount(Long orderCount) { this.orderCount = orderCount; }
    public BigDecimal getTotalConsumption() { return totalConsumption; }
    public void setTotalConsumption(BigDecimal totalConsumption) { this.totalConsumption = totalConsumption; }
}
backend/src/main/java/com/tourguide/modules/order/mapper/TourOrderMapper.java
@@ -3,11 +3,13 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.dto.GuideTodayStatsResponse;
import com.tourguide.modules.order.dto.VisitorOrderStatRow;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
public interface TourOrderMapper extends BaseMapper<TourOrder> {
@@ -23,6 +25,18 @@
    @Select("SELECT DISTINCT scenic_name FROM tour_order WHERE deleted = 0 AND scenic_name IS NOT NULL AND scenic_name <> '' ORDER BY scenic_name")
    List<String> selectScenicOptions();
    @Select({
            "<script>",
            "SELECT visitor_id AS visitorId, COUNT(*) AS orderCount, ",
            "COALESCE(SUM(CASE WHEN status NOT IN ('PENDING_PAYMENT', 'CANCELLED', 'REFUNDED') ",
            "THEN COALESCE(payment_amount, amount) ELSE 0 END), 0) AS totalConsumption ",
            "FROM tour_order WHERE deleted = 0 AND visitor_id IN ",
            "<foreach collection='visitorIds' item='visitorId' open='(' separator=',' close=')'>#{visitorId}</foreach>",
            "GROUP BY visitor_id",
            "</script>"
    })
    List<VisitorOrderStatRow> selectVisitorOrderStats(@Param("visitorIds") Collection<Long> visitorIds);
    @Select("SELECT COALESCE(SUM(guide_earning_amount), 0) FROM tour_order " +
            "WHERE deleted = 0 AND guide_id = #{guideId} AND status = 'COMPLETED' " +
            "AND guide_earning_amount IS NOT NULL " +
backend/src/main/java/com/tourguide/modules/review/dto/VisitorReviewStatRow.java
New file
@@ -0,0 +1,11 @@
package com.tourguide.modules.review.dto;
public class VisitorReviewStatRow {
    private Long visitorId;
    private Long reviewCount;
    public Long getVisitorId() { return visitorId; }
    public void setVisitorId(Long visitorId) { this.visitorId = visitorId; }
    public Long getReviewCount() { return reviewCount; }
    public void setReviewCount(Long reviewCount) { this.reviewCount = reviewCount; }
}
backend/src/main/java/com/tourguide/modules/review/mapper/OrderReviewMapper.java
@@ -2,8 +2,11 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tourguide.modules.review.entity.OrderReview;
import com.tourguide.modules.review.dto.VisitorReviewStatRow;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
public interface OrderReviewMapper extends BaseMapper<OrderReview> {
@@ -13,6 +16,16 @@
    @Select("SELECT COUNT(*) FROM tour_order_review WHERE order_id = #{orderId} AND deleted = 0")
    long countByOrderId(Long orderId);
    @Select({
            "<script>",
            "SELECT visitor_id AS visitorId, COUNT(*) AS reviewCount ",
            "FROM tour_order_review WHERE deleted = 0 AND status = 1 AND visitor_id IN ",
            "<foreach collection='visitorIds' item='visitorId' open='(' separator=',' close=')'>#{visitorId}</foreach>",
            "GROUP BY visitor_id",
            "</script>"
    })
    List<VisitorReviewStatRow> selectVisitorReviewStats(@Param("visitorIds") Collection<Long> visitorIds);
    @Select("SELECT COALESCE(AVG(score), 0) FROM tour_order_review WHERE guide_id = #{guideId} AND status = 1 AND deleted = 0")
    BigDecimal selectAverageScore(Long guideId);
}
backend/src/main/java/com/tourguide/modules/user/dto/MiniUserDetailResponse.java
@@ -21,7 +21,7 @@
    private final BigDecimal totalConsumption;
    private final long reviewCount;
    private MiniUserDetailResponse(MiniUser user) {
    private MiniUserDetailResponse(MiniUser user, long orderCount, BigDecimal totalConsumption, long reviewCount) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.nickname = user.getNickname();
@@ -32,12 +32,15 @@
        this.realNameVerified = StringUtils.hasText(user.getRealName()) && StringUtils.hasText(user.getIdCardNo());
        this.status = user.getStatus();
        this.createdAt = user.getCreatedAt();
        this.orderCount = 0L;
        this.totalConsumption = BigDecimal.ZERO;
        this.reviewCount = 0L;
        this.orderCount = orderCount;
        this.totalConsumption = totalConsumption == null ? BigDecimal.ZERO : totalConsumption;
        this.reviewCount = reviewCount;
    }
    public static MiniUserDetailResponse from(MiniUser user) { return new MiniUserDetailResponse(user); }
    public static MiniUserDetailResponse from(MiniUser user) { return from(user, 0L, BigDecimal.ZERO, 0L); }
    public static MiniUserDetailResponse from(MiniUser user, long orderCount, BigDecimal totalConsumption, long reviewCount) {
        return new MiniUserDetailResponse(user, orderCount, totalConsumption, reviewCount);
    }
    public Long getId() { return id; }
    public String getUsername() { return username; }
    public String getNickname() { return nickname; }
backend/src/main/java/com/tourguide/modules/user/dto/MiniUserResponse.java
@@ -18,7 +18,7 @@
    private final LocalDateTime createdAt;
    private final long orderCount;
    private MiniUserResponse(MiniUser user) {
    private MiniUserResponse(MiniUser user, long orderCount) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.nickname = user.getNickname();
@@ -29,9 +29,10 @@
        this.realNameVerified = StringUtils.hasText(user.getRealName()) && StringUtils.hasText(user.getIdCardNo());
        this.status = user.getStatus();
        this.createdAt = user.getCreatedAt();
        this.orderCount = 0L;
        this.orderCount = orderCount;
    }
    public static MiniUserResponse from(MiniUser user) { return new MiniUserResponse(user); }
    public static MiniUserResponse from(MiniUser user) { return from(user, 0L); }
    public static MiniUserResponse from(MiniUser user, long orderCount) { return new MiniUserResponse(user, orderCount); }
    public Long getId() { return id; }
    public String getUsername() { return username; }
    public String getNickname() { return nickname; }
backend/src/main/java/com/tourguide/modules/user/service/GuideReceivingService.java
@@ -216,6 +216,9 @@
    }
    private boolean premiumEnabled(GuideUser guide, PlatformSettingsResponse settings) {
        if (guide.getAutoDisableWhitelisted() != null && guide.getAutoDisableWhitelisted() == 1) {
            return true;
        }
        long orderCount = guide.getAcceptedOrderCount() == null ? 0 : guide.getAcceptedOrderCount();
        BigDecimal rating = guide.getRating() == null ? BigDecimal.ZERO : guide.getRating();
        BigDecimal requiredRating = settings.getPreciseDispatchRating() == null ? BigDecimal.ZERO : settings.getPreciseDispatchRating();
backend/src/main/java/com/tourguide/modules/user/service/MiniUserService.java
@@ -5,6 +5,10 @@
import com.tourguide.common.api.PageResponse;
import com.tourguide.common.exception.BusinessException;
import com.tourguide.common.validation.ChineseIdCardValidator;
import com.tourguide.modules.order.dto.VisitorOrderStatRow;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.review.dto.VisitorReviewStatRow;
import com.tourguide.modules.review.mapper.OrderReviewMapper;
import com.tourguide.modules.user.dto.MiniProfileUpdateRequest;
import com.tourguide.modules.user.dto.MiniRealNameUpdateRequest;
import com.tourguide.modules.user.dto.MiniUserDetailResponse;
@@ -14,15 +18,26 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** 仅处理小程序用户,管理后台只能通过本服务执行查询或状态管理。 */
@Service
public class MiniUserService {
    private final MiniUserMapper userMapper;
    private final TourOrderMapper orderMapper;
    private final OrderReviewMapper reviewMapper;
    public MiniUserService(MiniUserMapper userMapper) { this.userMapper = userMapper; }
    public MiniUserService(MiniUserMapper userMapper, TourOrderMapper orderMapper, OrderReviewMapper reviewMapper) {
        this.userMapper = userMapper;
        this.orderMapper = orderMapper;
        this.reviewMapper = reviewMapper;
    }
    public MiniUser findEnabledById(Long id) {
        MiniUser user = userMapper.selectById(id);
@@ -55,14 +70,55 @@
                .lt(endDate != null, MiniUser::getCreatedAt, endDate == null ? null : endDate.plusDays(1).atStartOfDay())
                .orderByDesc(MiniUser::getId);
        Page<MiniUser> result = userMapper.selectPage(new Page<MiniUser>(page, size), query);
        return new PageResponse<MiniUserResponse>(result.getRecords().stream().map(MiniUserResponse::from).collect(Collectors.toList()),
        List<MiniUser> users = result.getRecords();
        Map<Long, UserStats> statsByUserId = loadUserStats(
                users.stream().map(MiniUser::getId).collect(Collectors.toList()));
        return new PageResponse<MiniUserResponse>(users.stream()
                .map(user -> MiniUserResponse.from(user, statsOf(statsByUserId, user.getId()).orderCount))
                .collect(Collectors.toList()),
                result.getTotal(), result.getCurrent(), result.getSize());
    }
    public MiniUserDetailResponse detail(Long id) {
        MiniUser user = userMapper.selectById(id);
        if (user == null) { throw new BusinessException(404, "游客不存在"); }
        return MiniUserDetailResponse.from(user);
        UserStats stats = statsOf(loadUserStats(Collections.singletonList(id)), id);
        return MiniUserDetailResponse.from(user, stats.orderCount, stats.totalConsumption, stats.reviewCount);
    }
    private Map<Long, UserStats> loadUserStats(List<Long> visitorIds) {
        if (visitorIds.isEmpty()) { return Collections.emptyMap(); }
        Map<Long, UserStats> statsByUserId = new HashMap<Long, UserStats>();
        for (Long visitorId : visitorIds) {
            statsByUserId.put(visitorId, new UserStats());
        }
        List<VisitorOrderStatRow> orderRows = orderMapper.selectVisitorOrderStats(visitorIds);
        if (orderRows != null) {
            for (VisitorOrderStatRow row : orderRows) {
                if (row.getVisitorId() == null) { continue; }
                UserStats stats = statsByUserId.computeIfAbsent(row.getVisitorId(), ignored -> new UserStats());
                stats.orderCount = defaultLong(row.getOrderCount());
                stats.totalConsumption = row.getTotalConsumption() == null ? BigDecimal.ZERO : row.getTotalConsumption();
            }
        }
        List<VisitorReviewStatRow> reviewRows = reviewMapper.selectVisitorReviewStats(visitorIds);
        if (reviewRows != null) {
            for (VisitorReviewStatRow row : reviewRows) {
                if (row.getVisitorId() == null) { continue; }
                statsByUserId.computeIfAbsent(row.getVisitorId(), ignored -> new UserStats())
                        .reviewCount = defaultLong(row.getReviewCount());
            }
        }
        return statsByUserId;
    }
    private static UserStats statsOf(Map<Long, UserStats> statsByUserId, Long userId) {
        UserStats stats = statsByUserId.get(userId);
        return stats == null ? new UserStats() : stats;
    }
    private static long defaultLong(Long value) {
        return value == null ? 0L : value;
    }
    @Transactional
@@ -106,4 +162,10 @@
        user.setStatus(status);
        userMapper.updateById(user);
    }
    private static final class UserStats {
        private long orderCount;
        private BigDecimal totalConsumption = BigDecimal.ZERO;
        private long reviewCount;
    }
}
backend/src/test/java/com/tourguide/modules/user/service/GuideReceivingServiceTest.java
New file
@@ -0,0 +1,133 @@
package com.tourguide.modules.user.service;
import com.tourguide.modules.operation.dto.PlatformSettingsResponse;
import com.tourguide.modules.operation.entity.OperationScenicSpot;
import com.tourguide.modules.operation.entity.ScenicPricing;
import com.tourguide.modules.operation.mapper.OperationScenicSpotMapper;
import com.tourguide.modules.operation.mapper.ScenicMeetingPointMapper;
import com.tourguide.modules.operation.mapper.ScenicPricingMapper;
import com.tourguide.modules.operation.service.PlatformSettingsService;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.user.dto.GuideReceivingOptionResponse;
import com.tourguide.modules.user.entity.GuideScenicConfig;
import com.tourguide.modules.user.entity.GuideUser;
import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper;
import com.tourguide.modules.user.mapper.GuideReceivingSessionMapper;
import com.tourguide.modules.user.mapper.GuideScenicConfigMapper;
import com.tourguide.modules.user.mapper.GuideTeamScenicConfigMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class GuideReceivingServiceTest {
    private GuideUserService guideUserService;
    private GuideScenicConfigMapper guideScenicMapper;
    private OperationScenicSpotMapper scenicMapper;
    private ScenicMeetingPointMapper meetingPointMapper;
    private ScenicPricingMapper pricingMapper;
    private GuideReceivingSessionMapper sessionMapper;
    private GuideRealtimeStatusMapper realtimeStatusMapper;
    private PlatformSettingsService platformSettingsService;
    private GuideReceivingService service;
    @BeforeEach
    void setUp() {
        guideUserService = mock(GuideUserService.class);
        guideScenicMapper = mock(GuideScenicConfigMapper.class);
        GuideTeamScenicConfigMapper teamScenicMapper = mock(GuideTeamScenicConfigMapper.class);
        scenicMapper = mock(OperationScenicSpotMapper.class);
        meetingPointMapper = mock(ScenicMeetingPointMapper.class);
        pricingMapper = mock(ScenicPricingMapper.class);
        sessionMapper = mock(GuideReceivingSessionMapper.class);
        realtimeStatusMapper = mock(GuideRealtimeStatusMapper.class);
        platformSettingsService = mock(PlatformSettingsService.class);
        TourOrderMapper orderMapper = mock(TourOrderMapper.class);
        service = new GuideReceivingService(guideUserService, guideScenicMapper, teamScenicMapper, scenicMapper,
                meetingPointMapper, pricingMapper, sessionMapper, realtimeStatusMapper, platformSettingsService,
                orderMapper);
    }
    @Test
    void shouldEnablePremiumOptionsForWhitelistedGuideBelowRuleThresholds() {
        when(guideUserService.findEnabledById(8L)).thenReturn(whitelistedGuide());
        when(platformSettingsService.getSettings()).thenReturn(strictSettings());
        when(guideScenicMapper.selectList(any())).thenReturn(Collections.singletonList(scenicConfig()));
        when(scenicMapper.selectList(any())).thenReturn(Collections.singletonList(scenicSpot()));
        when(meetingPointMapper.selectList(any())).thenReturn(Collections.emptyList());
        when(pricingMapper.selectList(any())).thenReturn(Collections.singletonList(premiumPricing()));
        GuideReceivingOptionResponse response = service.options(8L);
        assertThat(response.isPremiumEnabled()).isTrue();
    }
    @Test
    void shouldDisablePremiumOptionsForNonWhitelistedGuideBelowRuleThresholds() {
        when(guideUserService.findEnabledById(8L)).thenReturn(nonWhitelistedGuide());
        when(platformSettingsService.getSettings()).thenReturn(strictSettings());
        when(guideScenicMapper.selectList(any())).thenReturn(Collections.singletonList(scenicConfig()));
        when(scenicMapper.selectList(any())).thenReturn(Collections.singletonList(scenicSpot()));
        when(meetingPointMapper.selectList(any())).thenReturn(Collections.emptyList());
        when(pricingMapper.selectList(any())).thenReturn(Collections.singletonList(premiumPricing()));
        GuideReceivingOptionResponse response = service.options(8L);
        assertThat(response.isPremiumEnabled()).isFalse();
    }
    private GuideUser whitelistedGuide() {
        GuideUser guide = new GuideUser();
        guide.setId(8L);
        guide.setAcceptedOrderCount(0L);
        guide.setRating(BigDecimal.ZERO);
        guide.setAutoDisableWhitelisted(1);
        guide.setStatus(GuideUserService.STATUS_ENABLED);
        return guide;
    }
    private GuideUser nonWhitelistedGuide() {
        GuideUser guide = whitelistedGuide();
        guide.setAutoDisableWhitelisted(0);
        return guide;
    }
    private GuideScenicConfig scenicConfig() {
        GuideScenicConfig config = new GuideScenicConfig();
        config.setGuideId(8L);
        config.setScenicName("Test Scenic");
        return config;
    }
    private OperationScenicSpot scenicSpot() {
        OperationScenicSpot scenic = new OperationScenicSpot();
        scenic.setId(3L);
        scenic.setScenicName("Test Scenic");
        scenic.setStatus(1);
        return scenic;
    }
    private ScenicPricing premiumPricing() {
        ScenicPricing pricing = new ScenicPricing();
        pricing.setId(9L);
        pricing.setScenicSpotId(3L);
        pricing.setServiceType("GROUP_PREMIUM");
        pricing.setDurationMinutes(60);
        pricing.setMinParticipants(20);
        pricing.setMaxParticipants(30);
        return pricing;
    }
    private PlatformSettingsResponse strictSettings() {
        PlatformSettingsResponse settings = new PlatformSettingsResponse();
        settings.setPreciseDispatchOrderCount(10);
        settings.setPreciseDispatchRating(new BigDecimal("4.8"));
        return settings;
    }
}
backend/src/test/java/com/tourguide/modules/user/service/MiniUserServiceProfileTest.java
@@ -5,6 +5,8 @@
import com.tourguide.modules.user.dto.MiniUserResponse;
import com.tourguide.modules.user.entity.MiniUser;
import com.tourguide.modules.user.mapper.MiniUserMapper;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.review.mapper.OrderReviewMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -16,13 +18,17 @@
class MiniUserServiceProfileTest {
    private MiniUserMapper userMapper;
    private TourOrderMapper orderMapper;
    private OrderReviewMapper reviewMapper;
    private MiniUserService userService;
    private MiniUser user;
    @BeforeEach
    void setUp() {
        userMapper = mock(MiniUserMapper.class);
        userService = new MiniUserService(userMapper);
        orderMapper = mock(TourOrderMapper.class);
        reviewMapper = mock(OrderReviewMapper.class);
        userService = new MiniUserService(userMapper, orderMapper, reviewMapper);
        user = new MiniUser();
        user.setId(1L);
        user.setUsername("wx_user");
backend/src/test/java/com/tourguide/modules/user/service/MiniUserServiceStatsTest.java
New file
@@ -0,0 +1,91 @@
package com.tourguide.modules.user.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tourguide.common.api.PageResponse;
import com.tourguide.modules.order.dto.VisitorOrderStatRow;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.review.dto.VisitorReviewStatRow;
import com.tourguide.modules.review.mapper.OrderReviewMapper;
import com.tourguide.modules.user.dto.MiniUserDetailResponse;
import com.tourguide.modules.user.dto.MiniUserResponse;
import com.tourguide.modules.user.entity.MiniUser;
import com.tourguide.modules.user.mapper.MiniUserMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MiniUserServiceStatsTest {
    private MiniUserMapper userMapper;
    private TourOrderMapper orderMapper;
    private OrderReviewMapper reviewMapper;
    private MiniUserService userService;
    @BeforeEach
    void setUp() {
        userMapper = mock(MiniUserMapper.class);
        orderMapper = mock(TourOrderMapper.class);
        reviewMapper = mock(OrderReviewMapper.class);
        userService = new MiniUserService(userMapper, orderMapper, reviewMapper);
    }
    @Test
    void pageShouldReturnVisitorOrderCount() {
        MiniUser user = miniUser(7L);
        Page<MiniUser> page = new Page<MiniUser>(1, 10);
        page.setRecords(Collections.singletonList(user));
        page.setTotal(1L);
        when(userMapper.selectPage(any(), any())).thenReturn(page);
        when(orderMapper.selectVisitorOrderStats(any())).thenReturn(Collections.singletonList(orderStat(7L, 1L)));
        when(reviewMapper.selectVisitorReviewStats(any())).thenReturn(Collections.emptyList());
        PageResponse<MiniUserResponse> response = userService.page(1, 10, null, null, null, null);
        assertThat(response.getRecords()).hasSize(1);
        assertThat(response.getRecords().get(0).getOrderCount()).isEqualTo(1L);
    }
    @Test
    void detailShouldReturnVisitorConsumptionStats() {
        MiniUser user = miniUser(7L);
        when(userMapper.selectById(7L)).thenReturn(user);
        when(orderMapper.selectVisitorOrderStats(any())).thenReturn(Collections.singletonList(orderStat(7L, 1L)));
        when(reviewMapper.selectVisitorReviewStats(any())).thenReturn(Collections.singletonList(reviewStat(7L, 2L)));
        MiniUserDetailResponse response = userService.detail(7L);
        assertThat(response.getOrderCount()).isEqualTo(1L);
        assertThat(response.getTotalConsumption()).isEqualByComparingTo("36.00");
        assertThat(response.getReviewCount()).isEqualTo(2L);
    }
    private MiniUser miniUser(Long id) {
        MiniUser user = new MiniUser();
        user.setId(id);
        user.setUsername("mini_" + id);
        user.setNickname("Visitor " + id);
        user.setStatus(1);
        return user;
    }
    private VisitorOrderStatRow orderStat(Long visitorId, Long orderCount) {
        VisitorOrderStatRow row = new VisitorOrderStatRow();
        row.setVisitorId(visitorId);
        row.setOrderCount(orderCount);
        row.setTotalConsumption(new BigDecimal("36.00"));
        return row;
    }
    private VisitorReviewStatRow reviewStat(Long visitorId, Long reviewCount) {
        VisitorReviewStatRow row = new VisitorReviewStatRow();
        row.setVisitorId(visitorId);
        row.setReviewCount(reviewCount);
        return row;
    }
}
frontend/src/views/GuideUserDetailView.vue
@@ -186,8 +186,7 @@
              </div>
              <div class="document-actions">
                <el-link v-if="user[item.key]" type="primary" :href="user[item.key]" target="_blank">查看大图</el-link>
                <el-input v-model="user[item.key]" clearable placeholder="请输入图片地址" />
                <el-button type="primary" plain :icon="Upload" @click="ElMessage.info('图片上传服务接入后可直接上传,当前请填写图片地址')">上传</el-button>
                <el-button type="primary" plain :icon="Upload" @click="ElMessage.info('图片上传服务接入后可直接上传')">上传</el-button>
              </div>
            </div>
          </div>
mingdaojingjiang_guide_app/pages/rating/index.vue
@@ -43,6 +43,7 @@
<script>
import GuideNavBar from '../../components/guide-nav-bar/guide-nav-bar.vue'
import { getGuideRatings } from '../../services/rating'
import { formatRatingStars } from '../../utils/rating'
import { backOrHome } from '../../utils/router'
export default {
@@ -88,8 +89,7 @@
        })
    },
    starText(score) {
      const count = Math.max(0, Math.min(5, Math.round(Number(score) || 0)))
      return `${'★'.repeat(count)}${'☆'.repeat(5 - count)}`
      return formatRatingStars(score)
    },
    goBack() {
      if (this.isLeaving) {
mingdaojingjiang_guide_app/pages/workbench/index.vue
@@ -97,7 +97,7 @@
              <text class="guide-name">{{ guide.name || '赵四' }}</text>
              <text v-if="auditStatus !== 'approved'" class="audit-tag">{{ auditStatusText }}</text>
            </view>
            <view v-if="auditStatus === 'approved'" class="stars">★★★★★</view>
            <view v-if="auditStatus === 'approved'" class="stars">{{ drawerRatingStars }}</view>
          </view>
          <image v-if="auditStatus === 'approved'" class="drawer-arrow" src="/static/icons/chevron-right-white.svg" mode="aspectFit" />
        </view>
@@ -140,6 +140,7 @@
import { playNotificationAlert } from '../../services/message'
import { getWorkbenchData } from '../../services/workbench'
import { formatDuration, maskPhone } from '../../utils/format'
import { formatRatingStars } from '../../utils/rating'
import { routeTo } from '../../utils/router'
import { getStatusBarHeight, upxToPx } from '../../utils/system'
@@ -239,6 +240,9 @@
      }
      return this.drawerMenus.filter((item) => item.text === '设置')
    },
    drawerRatingStars() {
      return formatRatingStars(this.guide.rating)
    },
    isReceiving() {
      return ['online', 'serving'].includes(this.guide.onlineStatus) || this.receivingOrders.length > 0
    },
mingdaojingjiang_guide_app/utils/rating.js
New file
@@ -0,0 +1,4 @@
export function formatRatingStars(score) {
  const count = Math.max(0, Math.min(5, Math.round(Number(score) || 0)))
  return `${'★'.repeat(count)}${'☆'.repeat(5 - count)}`
}