费用提交

This commit is contained in:
2026-10-26 13:41:43 +08:00
parent 0fe1d10566
commit 761fe3686e
171 changed files with 8750 additions and 386 deletions
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.SharedFeeCatalogService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 公共费用目录内部只读入口,禁止借用平台员工令牌跨服务查询。 */
@RestController
@RequestMapping("/internal/firstleg/v1/fee-items")
public class InternalFeeCatalogController {
private final SharedFeeCatalogService service;
private final byte[] expectedToken;
/** 绑定费用目录服务与内部身份凭证,空凭证始终拒绝访问。 */
public InternalFeeCatalogController(SharedFeeCatalogService service,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
/** 返回适用海外仓收付方向的启用项目。 */
@GetMapping
public ApiResponse<List<SharedFeeCatalogService.Item>> list(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
requireToken(token);
return ApiResponse.success(service.list(direction));
}
/** 在费用创建时验证实时项目资格。 */
@GetMapping("/{code}")
public ApiResponse<SharedFeeCatalogService.Item> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable String code, @RequestParam String direction) {
requireToken(token);
return ApiResponse.success(service.requireActive(code, direction));
}
/** 使用常量时间比较内部凭证,未配置凭证时关闭入口。 */
private void requireToken(String token) {
if (expectedToken.length == 0 || token == null
|| !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -54,6 +54,18 @@ public class FeeItemRepository {
.eq(FeeItemEntity::getFeeCode, feeCode));
}
/** 海外录费仅提供平台启用的国际仓库收付费项目,不复制费用目录。 */
public List<FeeItemEntity> listActiveInternationalWarehouseItems(String direction) {
return feeItems.selectList(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
.eq(FeeItemEntity::getStatus, "ACTIVE")
.eq(FeeItemEntity::getTradeScope, "INTERNATIONAL")
.eq("RECEIVABLE".equals(direction), FeeItemEntity::getWarehouseReceivable, true)
.eq("PAYABLE".equals(direction), FeeItemEntity::getWarehousePayable, true)
.orderByAsc(FeeItemEntity::getCategory)
.orderByAsc(FeeItemEntity::getFeeTypeName));
}
public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) {
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID)
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/** 向海外业务提供公共费用目录的只读切片,项目维护仍归头程主数据。 */
@Service
@RequiredArgsConstructor
public class SharedFeeCatalogService {
private final FeeItemRepository repository;
/** 按收付方向返回当前可使用的费用项目。 */
public List<Item> list(String direction) {
requireDirection(direction);
return repository.listActiveInternationalWarehouseItems(direction).stream()
.map(SharedFeeCatalogService::snapshot).toList();
}
/** 写入业务费用前重新验证项目资格,停用或方向变化不得沿用旧选项。 */
public Item requireActive(String code, String direction) {
requireDirection(direction);
FeeItemEntity item = repository.findItemByCode(code);
if (item == null || !"ACTIVE".equals(item.getStatus())
|| !"INTERNATIONAL".equals(item.getTradeScope())
|| !("RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getWarehousePayable()))) {
throw new BizException(ResultCode.CONFLICT, "费用项目未启用或不适用于本次收费");
}
return snapshot(item);
}
/** 校验显式收付方向,防止空方向绕过目录过滤。 */
private static void requireDirection(String direction) {
if (!"RECEIVABLE".equals(direction) && !"PAYABLE".equals(direction)) {
throw new BizException(ResultCode.BAD_REQUEST, "请选择费用收付方向");
}
}
/** 返回有限字段,不向其他服务暴露目录实体或审计属性。 */
private static Item snapshot(FeeItemEntity item) {
return new Item(item.getFeeCode(), item.getFeeTypeName(), item.getDefaultPrice());
}
public record Item(String code, String name, BigDecimal defaultPrice) { }
}
@@ -0,0 +1,24 @@
openapi: 3.0.3
info: { title: 公共费用目录内部查询, version: 1.0.0 }
security: [{ internalToken: [] }]
paths:
/internal/firstleg/v1/fee-items:
get:
summary: 查询当前启用的国际海外仓费用项目
parameters:
- { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } }
responses:
'200': { description: '统一响应data为费用项目数组,项目含code、name、defaultPrice;defaultPrice币种固定CNY,可为空。' }
'403': { description: 内部凭证缺失、错误或服务未配置凭证 }
/internal/firstleg/v1/fee-items/{code}:
get:
summary: 校验录费使用的当前费用项目资格
parameters:
- { in: path, name: code, required: true, schema: { type: string } }
- { in: query, name: direction, required: true, schema: { type: string, enum: [RECEIVABLE, PAYABLE] } }
responses:
'200': { description: 统一响应data为项目code、name、defaultPrice }
'409': { description: 项目不存在、停用或不适用于所选方向 }
components:
securitySchemes:
internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.controller.InternalFeeCatalogController;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SharedFeeCatalogServiceTest {
/** 已停用、方向变更或非海外项目不能沿用页面旧选项。 */
@Test void currentEligibilityIsCheckedBeforeUse() {
FeeItemRepository repository = mock(FeeItemRepository.class);
SharedFeeCatalogService service = new SharedFeeCatalogService(repository);
FeeItemEntity item = new FeeItemEntity();
item.setFeeCode("FEE-1"); item.setFeeTypeName("操作费");
item.setStatus("ACTIVE"); item.setTradeScope("INTERNATIONAL");
item.setWarehouseReceivable(true); item.setDefaultPrice(new BigDecimal("2.50"));
when(repository.findItemByCode("FEE-1")).thenReturn(item);
when(repository.listActiveInternationalWarehouseItems("RECEIVABLE")).thenReturn(List.of(item));
assertEquals(new BigDecimal("2.50"), service.list("RECEIVABLE").get(0).defaultPrice());
assertEquals("操作费", service.requireActive("FEE-1", "RECEIVABLE").name());
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "PAYABLE"));
item.setStatus("INACTIVE");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
item.setStatus("ACTIVE"); item.setTradeScope("DOMESTIC");
assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> service.list(null));
}
/** 内部入口未配置、缺少或错误凭证均不得读取目录。 */
@Test void internalCatalogRequiresExplicitServiceIdentity() {
SharedFeeCatalogService service = mock(SharedFeeCatalogService.class);
InternalFeeCatalogController controller = new InternalFeeCatalogController(service, "configured-token");
assertThrows(BizException.class, () -> controller.list(null, "RECEIVABLE"));
assertThrows(BizException.class, () -> controller.detail("wrong", "FEE-1", "RECEIVABLE"));
assertThrows(BizException.class, () -> new InternalFeeCatalogController(service, "").list("", "RECEIVABLE"));
verifyNoInteractions(service);
when(service.list("RECEIVABLE")).thenReturn(List.of());
assertTrue(controller.list("configured-token", "RECEIVABLE").successful());
}
}