package com.xuekairui.gateway; import org.junit.jupiter.api.*; import org.springframework.http.MediaType; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** * 运营管理接口测试 * 覆盖:邀请配置管理、爬虫次数手动发放、发放记录查询 */ @DisplayName("运营管理接口测试") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class AdminControllerTest extends GatewayBaseTest { private String adminToken() { return generateAccessToken(1L, "13800138000"); } // ==================== 邀请配置管理 ==================== @Test @Order(1) @DisplayName("GET /api/admin/invite/config - 应返回邀请配置") void getInviteConfig_shouldReturnConfig() throws Exception { mockMvc.perform(get("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.rewardCrawlerCount").isNumber()) .andExpect(jsonPath("$.data.maxDailyReward").isNumber()) .andExpect(jsonPath("$.data.inviteCodeExpireDays").isNumber()) .andExpect(jsonPath("$.data.maxInvitePerDay").isNumber()); } @Test @Order(2) @DisplayName("GET /api/admin/invite/config - 无Token应返回401") void getInviteConfig_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/invite/config")) .andExpect(status().isUnauthorized()); } @Test @Order(3) @DisplayName("GET /api/admin/invite/config - 配置应包含多渠道字段") void getInviteConfig_shouldContainMultiChannelFields() throws Exception { mockMvc.perform(get("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").isNumber()); } @Test @Order(4) @DisplayName("PUT /api/admin/invite/config - 更新邀请配置") void updateInviteConfig_shouldSucceed() throws Exception { String body = """ { "rewardCrawlerCount": 5, "maxDailyReward": 50, "inviteCodeExpireDays": 30, "maxInvitePerDay": 10, "landingTitle": "测试标题", "appName": "智价云(药店版)" } """; mockMvc.perform(put("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @Order(5) @DisplayName("PUT /api/admin/invite/config - 更新含多渠道配置") void updateInviteConfig_withChannels_shouldSucceed() throws Exception { String body = """ { "rewardCrawlerCount": 5, "maxDailyReward": 50, "inviteCodeExpireDays": 30, "maxInvitePerDay": 10, "appDownloadUrl": "https://download.example.com/app.exe", "miniappPath": "/pages/download/index", "miniappAppId": "wx1234567890", "wechatRedirectUrl": "https://mp.weixin.qq.com/xxx", "dingtalkAppId": "dingxxx", "feishuAppId": "feishuxxx", "landingTitle": "邀请你加入", "landingDesc": "高效数据采集", "appName": "智价云(药店版)" } """; mockMvc.perform(put("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @Order(6) @DisplayName("PUT /api/admin/invite/config - 无Token应返回401") void updateInviteConfig_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(put("/api/admin/invite/config") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } @Test @Order(7) @DisplayName("GET /api/admin/invite/configs - 应返回分页配置列表") void listInviteConfigs_shouldReturnPage() throws Exception { mockMvc.perform(get("/api/admin/invite/configs") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.records").isArray()) .andExpect(jsonPath("$.data.total").isNumber()) .andExpect(jsonPath("$.data.current").value(1)) .andExpect(jsonPath("$.data.size").value(20)); } @Test @Order(8) @DisplayName("GET /api/admin/invite/config/{id} - 根据ID获取配置") void getInviteConfigById_shouldReturnConfig() throws Exception { mockMvc.perform(get("/api/admin/invite/config/1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.rewardCrawlerCount").isNumber()); } @Test @Order(9) @DisplayName("GET /api/admin/invite/config/{id} - 不存在应返回错误") void getInviteConfigById_notFound_shouldReturnError() throws Exception { mockMvc.perform(get("/api/admin/invite/config/99999") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").isNumber()); } @Test @Order(10) @DisplayName("POST /api/admin/invite/config - 创建新配置") void createInviteConfig_shouldSucceed() throws Exception { String body = """ { "rewardCrawlerCount": 3, "rewardType": "MEMBERSHIP", "rewardMonths": 2, "maxDailyReward": 30, "inviteCodeExpireDays": 15, "maxInvitePerDay": 5, "maxTotalInvites": 20, "landingTitle": "新配置标题", "appName": "智价云(药店版)" } """; mockMvc.perform(post("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.id").isNumber()) .andExpect(jsonPath("$.data.landingTitle").value("新配置标题")); } @Test @Order(11) @DisplayName("PUT /api/admin/invite/config/{id} - 更新指定ID的配置") void updateInviteConfigById_shouldSucceed() throws Exception { String body = """ { "rewardMonths": 3, "landingTitle": "更新后的标题" } """; mockMvc.perform(put("/api/admin/invite/config/1") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @Order(12) @DisplayName("PUT /api/admin/invite/config/{id} - 不存在应返回错误") void updateInviteConfigById_notFound_shouldReturnError() throws Exception { mockMvc.perform(put("/api/admin/invite/config/99999") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"rewardMonths\": 3}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").isNumber()); } @Test @Order(13) @DisplayName("DELETE /api/admin/invite/config/{id} - 删除配置") void deleteInviteConfig_shouldSucceed() throws Exception { // 先创建一个临时配置用于删除 String createBody = """ { "rewardMonths": 1, "landingTitle": "待删除配置" } """; String createResult = mockMvc.perform(post("/api/admin/invite/config") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(createBody)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); int newId = mapper.readTree(createResult).get("data").get("id").asInt(); mockMvc.perform(delete("/api/admin/invite/config/" + newId) .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); // 再次查询应返回错误 mockMvc.perform(get("/api/admin/invite/config/" + newId) .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").isNumber()); } // ==================== 爬虫次数手动发放 ==================== @Test @Order(10) @DisplayName("POST /api/admin/crawler/grant - 手动发放爬虫次数") void grantCrawlerQuota_shouldSucceed() throws Exception { String body = """ { "userId": 1, "quotaCount": 50, "expireDays": 30, "remark": "测试发放" } """; mockMvc.perform(post("/api/admin/crawler/grant") .header("Authorization", "Bearer " + adminToken()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @Order(11) @DisplayName("POST /api/admin/crawler/grant - 无Token应返回401") void grantCrawlerQuota_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(post("/api/admin/crawler/grant") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":1,\"quotaCount\":50}")) .andExpect(status().isUnauthorized()); } @Test @Order(12) @DisplayName("GET /api/admin/crawler/grants - 应返回发放记录列表") void listGrants_shouldReturnList() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(13) @DisplayName("GET /api/admin/crawler/grants?userId=1 - 按用户筛选") void listGrants_byUserId_shouldFilter() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(14) @DisplayName("GET /api/admin/crawler/grants?grantType=ADMIN - 按类型筛选") void listGrants_byGrantType_shouldFilter() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants?grantType=ADMIN") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(15) @DisplayName("GET /api/admin/crawler/grants?grantType=INVITE - 查看邀请奖励记录") void listGrants_inviteType_shouldReturnList() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants?grantType=INVITE") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(16) @DisplayName("GET /api/admin/crawler/grants?grantType=PURCHASE - 查看购买奖励记录") void listGrants_purchaseType_shouldReturnList() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants?grantType=PURCHASE") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()); } // ==================== 邀请记录查看 ==================== @Test @Order(20) @DisplayName("GET /api/admin/invite/rewards?userId=1 - 查看用户邀请记录") void listInviteRewards_byUserId_shouldReturnList() throws Exception { mockMvc.perform(get("/api/admin/invite/rewards?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(21) @DisplayName("GET /api/admin/invite/rewards - 无userId应返回空列表") void listInviteRewards_noUserId_shouldReturnEmpty() throws Exception { mockMvc.perform(get("/api/admin/invite/rewards") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()); } @Test @Order(22) @DisplayName("GET /api/admin/invite/rewards - 无Token应返回401") void listInviteRewards_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/invite/rewards")) .andExpect(status().isUnauthorized()); } // ==================== 用户爬虫详情查询(新增)==================== @Test @Order(30) @DisplayName("GET /api/admin/crawler/user-detail?userId=1 - 应返回用户爬虫详情") void getCrawlerUserDetail_shouldReturnDetail() throws Exception { mockMvc.perform(get("/api/admin/crawler/user-detail?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.userId").value(1)) .andExpect(jsonPath("$.data.nickname").exists()) .andExpect(jsonPath("$.data.phone").exists()) .andExpect(jsonPath("$.data.todayTotalUsage").isNumber()) .andExpect(jsonPath("$.data.monthlyTotalUsage").isNumber()) .andExpect(jsonPath("$.data.platformStats").isArray()); } @Test @Order(31) @DisplayName("GET /api/admin/crawler/user-detail - 平台统计应包含各字段") void getCrawlerUserDetail_platformStats_shouldContainFields() throws Exception { mockMvc.perform(get("/api/admin/crawler/user-detail?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.platformStats").isArray()) .andExpect(result -> { // 解析JSON验证平台统计 String content = result.getResponse().getContentAsString(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.JsonNode json = mapper.readTree(content); com.fasterxml.jackson.databind.JsonNode stats = json.get("data").get("platformStats"); // 如果有平台统计数据,验证每个元素的字段 if (stats.isArray() && stats.size() > 0) { for (int i = 0; i < stats.size(); i++) { var stat = stats.get(i); // 验证必填字段存在且不为null assert stat.has("platformCode") && !stat.get("platformCode").isNull() : "platformCode不能为空"; assert stat.has("platformName") && !stat.get("platformName").isNull() : "platformName不能为空"; assert stat.has("todayUsage") && stat.get("todayUsage").isNumber() : "todayUsage必须是数字"; assert stat.has("monthlyUsage") && stat.get("monthlyUsage").isNumber() : "monthlyUsage必须是数字"; assert stat.has("totalUsage") && stat.get("totalUsage").isNumber() : "totalUsage必须是数字"; // 验证数值>=0 assert stat.get("todayUsage").asInt() >= 0 : "todayUsage不能为负数"; assert stat.get("monthlyUsage").asInt() >= 0 : "monthlyUsage不能为负数"; assert stat.get("totalUsage").asInt() >= 0 : "totalUsage不能为负数"; // 验证平台代码有效(已知枚举或容错显示为“其他平台”) String platformCode = stat.get("platformCode").asText(); String platformName = stat.get("platformName").asText(); com.xuekairui.user.entity.CrawlerPlatform enumVal = com.xuekairui.user.entity.CrawlerPlatform.findByCode(platformCode); if (enumVal != null) { assert platformName.equals(enumVal.getName()) : "平台名称不匹配: " + platformName + " vs " + enumVal.getName(); } else { assert platformName.startsWith("其他平台") : "未知平台应显示'其他平台(xxx)': " + platformName; } } // 验证按总使用次数降序排列 if (stats.size() > 1) { for (int i = 1; i < stats.size(); i++) { int prevTotal = stats.get(i - 1).get("totalUsage").asInt(); int currTotal = stats.get(i).get("totalUsage").asInt(); assert prevTotal >= currTotal : "平台统计应按总使用次数降序排列,但索引" + (i-1) + "的" + prevTotal + " < 索引" + i + "的" + currTotal; } } } }); } @Test @Order(32) @DisplayName("GET /api/admin/crawler/user-detail - 手机号应脱敏") void getCrawlerUserDetail_phoneShouldBeMasked() throws Exception { mockMvc.perform(get("/api/admin/crawler/user-detail?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.phone").value("138****8000")); } @Test @Order(33) @DisplayName("GET /api/admin/crawler/user-detail - 无Token应返回401") void getCrawlerUserDetail_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/crawler/user-detail?userId=1")) .andExpect(status().isUnauthorized()); } @Test @Order(34) @DisplayName("GET /api/admin/crawler/user-detail?userId=999 - 不存在的用户应返回错误") void getCrawlerUserDetail_nonExistentUser_shouldReturnError() throws Exception { mockMvc.perform(get("/api/admin/crawler/user-detail?userId=999") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").isNumber()); } // ==================== 邀请转化统计(新增)==================== @Test @Order(40) @DisplayName("GET /api/admin/invite/conversion-stats?userId=1 - 应返回转化统计") void getInviteConversionStats_shouldReturnStats() throws Exception { mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.inviteCode").exists()) .andExpect(jsonPath("$.data.clickedCount").isNumber()) .andExpect(jsonPath("$.data.totalInvited").isNumber()) .andExpect(jsonPath("$.data.registeredCount").isNumber()) .andExpect(jsonPath("$.data.pendingCount").isNumber()) .andExpect(jsonPath("$.data.conversionRate").isNumber()) .andExpect(jsonPath("$.data.totalReward").isNumber()); } @Test @Order(41) @DisplayName("GET /api/admin/invite/conversion-stats - 转化率应在0-100之间") void getInviteConversionStats_conversionRateShouldBeInRange() throws Exception { mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.conversionRate").value(org.hamcrest.Matchers.greaterThanOrEqualTo(0.0))) .andExpect(jsonPath("$.data.conversionRate").value(org.hamcrest.Matchers.lessThanOrEqualTo(100.0))); } @Test @Order(42) @DisplayName("GET /api/admin/invite/conversion-stats - 待注册人数应>=0") void getInviteConversionStats_pendingCountShouldBeNonNegative() throws Exception { mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1") .header("Authorization", "Bearer " + adminToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.pendingCount").value(org.hamcrest.Matchers.greaterThanOrEqualTo(0))); } @Test @Order(43) @DisplayName("GET /api/admin/invite/conversion-stats - 无Token应返回401") void getInviteConversionStats_withoutToken_shouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1")) .andExpect(status().isUnauthorized()); } }