This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.masterdata.exchange.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/exchange-rates")
@Tag(name = "Monthly exchange rates / 月度汇率")
public class PlatformMonthlyExchangeRateController {
@Resource private MonthlyExchangeRateService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
@Operation(summary = "Page monthly exchange rates", description = "Required authority: platform:overseas-master-data:exchange-rate:list")
public ApiResponse<PageResult<MonthlyExchangeRateDtos.Response>> page(@Valid MonthlyExchangeRateDtos.Query query) {
return ApiResponse.success(service.page(query));
}
@GetMapping("/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
public ApiResponse<MonthlyExchangeRateDtos.Summary> summary() { return ApiResponse.success(service.summary()); }
@GetMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> detail(@PathVariable String month) { return ApiResponse.success(service.detail(month)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_CREATE + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody MonthlyExchangeRateDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EDIT + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.Update request) {
return ApiResponse.success(service.update(month, operator(user), request));
}
@PatchMapping("/{month}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_STATUS + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(month, operator(user), request));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
.body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.masterdata.exchange.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class MonthlyExchangeRateDtos {
private MonthlyExchangeRateDtos() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthFrom,
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthTo,
String status) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Create(@NotBlank @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String month,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
@Size(max = 1000) String remark) { }
public record Update(@NotNull @Min(0) Long recordVersion,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
@Size(max = 1000) String remark) { }
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion,
@Size(max = 500) String remark) { }
public record Response(String month, String baseCurrency, String quoteCurrency,
BigDecimal usdToCnyAverage, String status, long recordVersion,
String updatedBy, LocalDateTime updatedAt, String remark) { }
public record Summary(String latestMonth, BigDecimal latestUsdToCnyAverage,
String updatedBy, LocalDateTime updatedAt) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.masterdata.exchange.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_monthly_exchange_rate")
public class MonthlyExchangeRate {
@TableId(type = IdType.INPUT) private Long id;
private String rateMonth;
private String baseCurrency;
private String quoteCurrency;
private BigDecimal usdToCnyAverage;
private String status;
private String remark;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.masterdata.exchange.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_monthly_exchange_rate_history")
public class MonthlyExchangeRateHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long exchangeRateId;
private BigDecimal beforeRate;
private BigDecimal afterRate;
private String beforeStatus;
private String afterStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.masterdata.exchange.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MonthlyExchangeRateHistoryMapper extends BaseMapper<MonthlyExchangeRateHistory> { }
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.masterdata.exchange.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MonthlyExchangeRateMapper extends BaseMapper<MonthlyExchangeRate> {
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1")
MonthlyExchangeRate findMonth(@Param("month") String month);
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1 FOR UPDATE")
MonthlyExchangeRate lockMonth(@Param("month") String month);
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE status='ACTIVE' ORDER BY rate_month DESC LIMIT 1")
MonthlyExchangeRate latestActive();
}
@@ -0,0 +1,156 @@
package com.baiyue.lastleg.masterdata.exchange.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
@Service
public class MonthlyExchangeRateService {
private static final String ACTIVE = "ACTIVE";
private static final String INACTIVE = "INACTIVE";
@Resource private MonthlyExchangeRateMapper rates;
@Resource private MonthlyExchangeRateHistoryMapper histories;
public PageResult<MonthlyExchangeRateDtos.Response> page(MonthlyExchangeRateDtos.Query query) {
validateRange(query.monthFrom(), query.monthTo());
String status = optionalStatus(query.status());
Page<MonthlyExchangeRate> page = rates.selectPage(Page.of(query.page(), query.size()),
new LambdaQueryWrapper<MonthlyExchangeRate>()
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
.eq(status != null, MonthlyExchangeRate::getStatus, status)
.orderByDesc(MonthlyExchangeRate::getRateMonth));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public MonthlyExchangeRateDtos.Response detail(String month) { return response(required(month)); }
public MonthlyExchangeRateDtos.Summary summary() {
MonthlyExchangeRate value = rates.latestActive();
return value == null ? new MonthlyExchangeRateDtos.Summary(null, null, null, null)
: new MonthlyExchangeRateDtos.Summary(value.getRateMonth(), value.getUsdToCnyAverage(),
value.getUpdatedBy(), value.getUpdatedAt());
}
@Transactional
public MonthlyExchangeRateDtos.Response create(String operator, MonthlyExchangeRateDtos.Create request) {
String month = month(request.month()); LocalDateTime now = LocalDateTime.now();
MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setId(IdWorker.getId());
value.setRateMonth(month); value.setBaseCurrency("USD"); value.setQuoteCurrency("CNY");
value.setUsdToCnyAverage(request.usdToCnyAverage()); value.setStatus(ACTIVE);
value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { rates.insert(value); }
catch (DuplicateKeyException exception) { throw conflict("该月份汇率已存在"); }
history(value.getId(), null, value.getUsdToCnyAverage(), null, ACTIVE, operator, request.remark());
return detail(month);
}
@Transactional
public MonthlyExchangeRateDtos.Response update(String month, String operator,
MonthlyExchangeRateDtos.Update request) {
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
.eq(MonthlyExchangeRate::getId, current.getId())
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
.set(MonthlyExchangeRate::getUsdToCnyAverage, request.usdToCnyAverage())
.set(MonthlyExchangeRate::getRemark, trim(request.remark()))
.set(MonthlyExchangeRate::getUpdatedBy, operator).set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
history(current.getId(), current.getUsdToCnyAverage(), request.usdToCnyAverage(),
current.getStatus(), current.getStatus(), operator, request.remark());
return detail(current.getRateMonth());
}
@Transactional
public MonthlyExchangeRateDtos.Response changeStatus(String month, String operator,
MonthlyExchangeRateDtos.ChangeStatus request) {
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
.eq(MonthlyExchangeRate::getId, current.getId())
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
.set(MonthlyExchangeRate::getStatus, status).set(MonthlyExchangeRate::getUpdatedBy, operator)
.set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
history(current.getId(), current.getUsdToCnyAverage(), current.getUsdToCnyAverage(),
current.getStatus(), status, operator, request.remark());
return detail(current.getRateMonth());
}
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
validateRange(query.monthFrom(), query.monthTo());
String status = optionalStatus(query.status());
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
.eq(status != null, MonthlyExchangeRate::getStatus, status)
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
String beforeStatus, String afterStatus, String operator, String remark) {
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
value.setExchangeRateId(id); value.setBeforeRate(beforeRate); value.setAfterRate(afterRate);
value.setBeforeStatus(beforeStatus); value.setAfterStatus(afterStatus); value.setOperatorId(operator);
value.setRemark(trim(remark)); value.setCreatedAt(LocalDateTime.now()); histories.insert(value);
}
private MonthlyExchangeRate required(String month) {
MonthlyExchangeRate value = rates.findMonth(month(month));
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
}
private MonthlyExchangeRate locked(String month) {
MonthlyExchangeRate value = rates.lockMonth(month(month));
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
}
private MonthlyExchangeRateDtos.Response response(MonthlyExchangeRate value) {
return new MonthlyExchangeRateDtos.Response(value.getRateMonth(), value.getBaseCurrency(),
value.getQuoteCurrency(), value.getUsdToCnyAverage(), value.getStatus(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
}
private void requireVersion(MonthlyExchangeRate value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("汇率已被其他操作修改,请刷新后重试");
}
private void validateRange(String from, String to) {
if (StringUtils.hasText(from) && StringUtils.hasText(to) && from.compareTo(to) > 0) throw badRequest("monthFrom不能晚于monthTo");
}
private String month(String value) {
if (value == null || !value.matches("\\d{4}-(0[1-9]|1[0-2])")) throw badRequest("月份格式必须为yyyy-MM"); return value;
}
private String optionalStatus(String value) { return StringUtils.hasText(value) ? status(value) : null; }
private String status(String value) {
String result = value.trim().toUpperCase(Locale.ROOT);
if (!ACTIVE.equals(result) && !INACTIVE.equals(result)) throw badRequest("汇率状态只支持ACTIVE/INACTIVE"); return result;
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}