This commit is contained in:
2026-09-09 15:27:10 +08:00
parent 5f671e164f
commit 2980308f4d
17 changed files with 292 additions and 17 deletions
@@ -0,0 +1,65 @@
package com.baiyue.finance.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.finance.client.FinanceFileClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/finance/v1/vouchers")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public class PlatformFinanceVoucherController {
private final FinanceFileClient files;
private final String token;
public PlatformFinanceVoucherController(FinanceFileClient files,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.files = files;
this.token = token;
}
public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {}
@GetMapping("/{fileId}")
public ApiResponse<Voucher> metadata(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes()));
}
@GetMapping("/{fileId}/content")
public ResponseEntity<byte[]> content(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
var response = files.download(token, customerId, customerId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试");
}
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody());
}
private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) {
if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效");
var response = files.metadata(token, customerId, customerId, fileId);
var file = response == null ? null : response.data();
if (file == null || !Long.valueOf(fileId).equals(file.fileId())
|| !Long.valueOf(customerId).equals(file.tenantId())
|| !"FINANCE_VOUCHER".equals(file.businessType())
|| !String.valueOf(customerId).equals(file.businessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户");
}
return file;
}
}
@@ -115,6 +115,13 @@ public class BusinessFeeSourceEventService {
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0
|| !Set.of("CNY", "USD").contains(value.originalCurrency())
|| value.originalAmount() == null || value.originalAmount().signum() <= 0
|| value.exchangeRate() == null || value.exchangeRate().signum() <= 0
|| blank(value.exchangeRateMonth())
|| (value.periodStart() == null) != (value.periodEnd() == null)
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())
|| (value.chargeableQuantity() == null) != blank(value.quantityUnit())
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
}
@@ -148,7 +155,9 @@ public class BusinessFeeSourceEventService {
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
@@ -69,7 +69,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
@@ -77,7 +78,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
@@ -0,0 +1,51 @@
package com.baiyue.finance;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.controller.PlatformFinanceVoucherController;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlatformFinanceVoucherControllerTest {
private final FinanceFileClient files = mock(FinanceFileClient.class);
private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token");
private FinanceFileClient.Metadata file(long customerId, String type, String businessId) {
return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L);
}
@Test void returnsFilenameAndDownloadsWithinCustomerScope() {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png");
var content = controller.content(91, 11);
assertThat(content.getBody()).containsExactly(1, 2, 3);
assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store");
assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;");
}
@Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() {
for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test"));
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong());
}
@Test void rejectsMissingMetadataAndFailedContent() {
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build());
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
@Test void requiresPlatformListPermission() {
assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:finance:customer-arap:list')");
}
}