feat(admin): 商品配置与系统公告管理接口,id 以字符串下发

- /admin/products 六个接口 + 校验(code 唯一、store_product_id 同平台唯一、
  COIN_PACK 需 coin_amount>0、VIP 需 vip_days>0、VIP 只建不启)
- /admin/notices 六个接口,与 C 端 /app/notices 共用 OpsNoticeService.pageForApp 一套筛选口径
- 权限点 product:list/product:update/notice:list/notice:update + 菜单 31/32/33(sql/data-phase1.sql 幂等)
- 雪花 id 超过 JS 安全整数范围:id 以字符串下发,避免浏览器四舍五入导致回查 200002
- C 端商品查询收敛到 ProductService.findForStore,OrderService 不再自带一份
- 测试:module-commerce 33 / admin-api 16 / app-api 49 / worker 12,共 110 条全过
This commit is contained in:
jianghaifeng committed 2026-10-16 16:33:15 +08:00
1 parent 7513ac06bc
commit 8d19a0cc0a
24 files changed
+1761 -48

No files matched your search

@@ -0,0 +1,61 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 权限点注解检查:新增的写接口必须带 `@PreAuthorize`,且权限点与契约一致。
*
* <p>为什么用反射而不是起 Spring 上下文:{@code @PreAuthorize} 靠方法代理生效,
* 切片测试(standalone MockMvc)不会走代理,跑出来是"永远通过"的假绿;
* 直接检查注解,能真正确认"忘写权限点"这类问题(无权限点被拒的前提是**有**权限点)。
*/
class AdminPermissionAnnotationTest {
@Test
void should_declare_expected_permissions_when_checking_new_admin_endpoints() {
assertThat(permissionsOf(ProductAdminController.class))
.containsExactlyInAnyOrder("product:list", "product:list",
"product:update", "product:update", "product:update", "product:update");
assertThat(permissionsOf(NoticeAdminController.class))
.containsExactlyInAnyOrder("notice:list", "notice:list",
"notice:update", "notice:update", "notice:update", "notice:update");
}
/** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */
private List<String> permissionsOf(Class<?> controller) {
List<String> permissions = new ArrayList<>();
for (Method method : controller.getDeclaredMethods()) {
if (!isEndpoint(method)) {
continue;
}
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
permissions.add(annotation == null ? "MISSING" : extract(annotation.value()));
}
return permissions;
}
private boolean isEndpoint(Method method) {
return method.isAnnotationPresent(GetMapping.class) || method.isAnnotationPresent(PostMapping.class)
|| method.isAnnotationPresent(PutMapping.class) || method.isAnnotationPresent(DeleteMapping.class);
}
/** 从 `@ss.hasPermission('product:list')` 里取出权限码。 */
private String extract(String expression) {
int start = expression.indexOf('\'');
int end = expression.lastIndexOf('\'');
return start >= 0 && end > start ? expression.substring(start + 1, end) : expression;
}
}
@@ -0,0 +1,116 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.service.OpsNoticeService;
import com.fruitshorts.system.vo.NoticeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** `/admin/notices` 接入层契约。 */
class NoticeAdminControllerContractTest {
private OpsNoticeService opsNoticeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
opsNoticeService = mock(OpsNoticeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new NoticeAdminController(opsNoticeService));
}
@Test
void should_return_paged_notices_when_page_is_requested() throws Exception {
NoticeRespVO vo = new NoticeRespVO();
vo.setId(1L);
vo.setTitle("开服公告");
vo.setTarget("ALL");
vo.setTargetText("全部用户");
vo.setType("SYSTEM");
vo.setTypeText("系统");
vo.setStatus(1);
vo.setStatusText("已发布");
given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/notices/page").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].title").value("开服公告"))
.andExpect(jsonPath("$.data.list[0].statusText").value("已发布"))
.andExpect(jsonPath("$.data.list[0].targetText").value("全部用户"));
}
@Test
void should_return_new_id_when_creating_notice() throws Exception {
given(opsNoticeService.create(any())).willReturn(66L);
mockMvc.perform(post("/admin/notices")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"开服公告\",\"content\":\"欢迎\",\"target\":\"ALL\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
// id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入
.andExpect(jsonPath("$.data").value("66"));
}
@Test
void should_send_string_id_when_notice_id_exceeds_js_safe_integer() throws Exception {
NoticeRespVO vo = new NoticeRespVO();
vo.setId(2111011206851596290L);
vo.setTitle("开服公告");
given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/notices/page"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("2111011206851596290"))
.andExpect(jsonPath("$.data.list[0].id").isString());
}
@Test
void should_return_bad_request_when_title_is_missing() throws Exception {
mockMvc.perform(post("/admin/notices")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"content\":\"没有标题\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()));
}
@Test
void should_map_not_found_when_notice_does_not_exist() throws Exception {
given(opsNoticeService.detail(any())).willThrow(
new ServiceException(ErrorCode.DATA_NOT_FOUND, "公告不存在"));
mockMvc.perform(get("/admin/notices/404"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.message").value("公告不存在"));
}
@Test
void should_pass_status_when_publishing_or_taking_down_notice() throws Exception {
mockMvc.perform(put("/admin/notices/9/status").param("status", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(opsNoticeService).updateStatus(eq(9L), eq(0));
}
}
@@ -0,0 +1,122 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** `/admin/products` 接入层契约:路由、参数绑定、统一响应体、错误码映射。 */
class ProductAdminControllerContractTest {
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
productService = mock(ProductService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService));
}
@Test
void should_return_paged_products_when_page_is_requested() throws Exception {
ProductRespVO vo = new ProductRespVO();
vo.setId(1L);
vo.setCode("coin_100");
vo.setName("100 金币");
vo.setType("COIN_PACK");
vo.setTypeText("金币包");
vo.setStatus(1);
vo.setStatusText("已上架");
vo.setTotalCoin(100L);
given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/products/page").param("type", "COIN_PACK"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].code").value("coin_100"))
// 状态中文由服务端给(前端不写死)
.andExpect(jsonPath("$.data.list[0].statusText").value("已上架"))
.andExpect(jsonPath("$.data.list[0].typeText").value("金币包"));
}
@Test
void should_return_new_id_when_creating_product() throws Exception {
given(productService.create(any())).willReturn(88L);
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"coin_100\",\"name\":\"100 金币\",\"coinAmount\":100}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
// id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入
.andExpect(jsonPath("$.data").value("88"));
}
@Test
void should_send_string_id_when_product_id_exceeds_js_safe_integer() throws Exception {
ProductRespVO vo = new ProductRespVO();
// 一个真实量级的雪花 id(> 2^53-1),前端按字符串原样回传才不会被四舍五入
vo.setId(2111010131922784257L);
vo.setCode("coin_big_id");
given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/products/page"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("2111010131922784257"))
.andExpect(jsonPath("$.data.list[0].id").isString());
}
@Test
void should_return_bad_request_when_code_is_missing() throws Exception {
// @NotBlank 校验失败 → 全局异常处理器给出 400 信封
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"没有编码\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()));
}
@Test
void should_map_data_exists_when_store_product_id_is_duplicated() throws Exception {
given(productService.create(any())).willThrow(
new ServiceException(ErrorCode.DATA_EXISTS, "该平台下商店商品 id 已被占用:coin_100"));
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"coin_x\",\"name\":\"X\",\"storeProductId\":\"coin_100\",\"coinAmount\":100}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode()))
.andExpect(jsonPath("$.message").value("该平台下商店商品 id 已被占用:coin_100"));
}
@Test
void should_pass_status_when_updating_product_status() throws Exception {
mockMvc.perform(put("/admin/products/8/status").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(productService).updateStatus(eq(8L), eq(1));
}
}