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:
1 parent
7513ac06bc
commit
8d19a0cc0a
24 files changed
+1761
-48
No files matched your search
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+116
@@ -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));
|
||||
}
|
||||
}
|
||||
+122
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user