# M3ApiService.java

    package cloud.wconnect.finance.service.pl;

    import cloud.wconnect.commonlib.cons.BaseErrorCodeEnum;
    import cloud.wconnect.commonlib.exception.InternalServerErrorException;
    import cloud.wconnect.commonlib.pojo.HttpResponseWrapper;
    import cloud.wconnect.commonlib.pojo.dto.m3Config.PropertyM3ConfigDTO;
    import cloud.wconnect.commonlib.pojo.dto.m3Config.TenantM3ConfigDTO;
    import cloud.wconnect.commonlib.util.JsonUtils;
    import cloud.wconnect.finance.pojo.dto.pl.m3api.*;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.google.common.collect.Maps;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.collections.CollectionUtils;
    import org.apache.commons.lang3.StringUtils;
    import org.apache.hc.client5.http.classic.methods.HttpGet;
    import org.apache.hc.client5.http.config.RequestConfig;
    import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
    import org.apache.hc.client5.http.impl.classic.HttpClients;
    import org.apache.hc.core5.http.HttpStatus;
    import org.apache.hc.core5.http.io.entity.EntityUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.retry.annotation.Backoff;
    import org.springframework.retry.annotation.Retryable;
    import org.springframework.stereotype.Service;

    import java.net.URLEncoder;
    import java.nio.charset.StandardCharsets;
    import java.text.MessageFormat;
    import java.util.Collections;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;

    /**
     * M3 接口业务层
     *
     * @author lichen
     * @date 2025/9/26
     */
    @Slf4j
    @Service
    public class M3ApiService {

        @Autowired
        private ObjectMapper objectMapper;
        @Autowired
        private M3TokenService m3TokenService;

        //默认 等待响应数据的超时时间
        private static final int RESPONSE_TIMEOUT = 10;
        //默认重试的延迟时间，单位毫秒
        private static final int DEFAULT_RETRY_DELAY = 600;
        @Value(value = "${m3-api.get-customer-close-account}")
        private String getCustomerCloseAccountUrl;
        @Value(value = "${m3-api.list-account}")
        private String listAccountUrl;
        @Value(value = "${m3-api.list-trialbalance}")
        private String listTrialbalanceUrl;
        @Value(value = "${m3-api.list-budget}")
        private String listBudgetUrl;
        @Value(value = "${m3-api.list-forecasts}")
        private String listForecastsUrl;
        @Value(value = "${m3-api.list-forecasts-name}")
        private String listForecastsNameUrl;
        @Value(value = "${m3-api.list-journal-entry}")
        private String listJournalEntryUrl;

        /**
         * 获取客户关账信息
         *
         * @param tenantM3ConfigDTO 租户的M3 Config
         * @param year              年
         * @return java.util.List<cloud.wconnect.finance.pojo.dto.pl.m3api.CustomerCloseAccountDTO>
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public CustomerCloseAccountDTO getCustomerCloseAccount(TenantM3ConfigDTO tenantM3ConfigDTO, String year) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String url = MessageFormat.format(getCustomerCloseAccountUrl,
                    customerShortName, year);

            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "getCustomerCloseAccount", RESPONSE_TIMEOUT);

            List<CustomerCloseAccountDTO> customerCloseAccountDTOS = JsonUtils.convertStringToList(objectMapper, body, CustomerCloseAccountDTO.class);
            if (CollectionUtils.isEmpty(customerCloseAccountDTOS)) {
                throw new RuntimeException("M3ApiService.getAccountHasBeenClosed error, customerCloseAccountDTOS is empty");
            }
            return customerCloseAccountDTOS.get(0);
        }

        /**
         * 获取资产M3 Account数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产的M3 Config
         * @return java.util.List<cloud.wconnect.finance.pojo.dto.pl.m3api.M3AccountDTO>
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public List<M3AccountDTO> listAccount(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listAccountUrl, customerShortName, companyCode, propertyCode);
            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "listAccount", RESPONSE_TIMEOUT);

            return JsonUtils.convertStringToList(objectMapper, body, M3AccountDTO.class);
        }

        /**
         * 获取 trialbalance 数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param asOfDate            查询日期，yyyy-MM-dd
         * @return java.util.List<cloud.wconnect.finance.pojo.dto.pl.m3api.TrialbalanceDTO>
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public List<TrialbalanceDTO> listTrialbalance(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                                      String asOfDate) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listTrialbalanceUrl, customerShortName, companyCode, propertyCode, asOfDate);
            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "listTrialbalance", RESPONSE_TIMEOUT);

            return JsonUtils.convertStringToList(objectMapper, body, TrialbalanceDTO.class);
        }

        /**
         * 获取预算数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param fiscalYear          Fiscal Year,yyyy
         * @return java.util.List<cloud.wconnect.finance.pojo.dto.pl.m3api.BudgetDTO>
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public List<BudgetDTO> listBudget(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                          String fiscalYear) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listBudgetUrl, customerShortName, companyCode, propertyCode, fiscalYear);
            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "listBudgets", 40);

            return JsonUtils.convertStringToList(objectMapper, body, BudgetDTO.class);
        }

        /**
         * 获取预测数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param fiscalYear          Fiscal Year,yyyy
         * @param forecastName        预测名称
         * @return cloud.wconnect.finance.pojo.dto.pl.m3api.ForecastDTO
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public ForecastDTO listForecasts(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                         String fiscalYear, String forecastName) {
            // 使用 US-ASCII 编码
            forecastName = URLEncoder.encode(forecastName, StandardCharsets.US_ASCII);

            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listForecastsUrl, customerShortName, companyCode, propertyCode, fiscalYear, forecastName);

            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "listForecasts", RESPONSE_TIMEOUT);

            if (StringUtils.isEmpty(body)) {
                return new ForecastDTO(Collections.emptyList());
            }
            return JsonUtils.convertStringToObject(objectMapper, body, ForecastDTO.class);
        }

        /**
         * 获取预测名称
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param fiscalYear          Fiscal Year,yyyy
         * @return java.util.List<cloud.wconnect.finance.pojo.dto.pl.m3api.ForecastDTO>
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = DEFAULT_RETRY_DELAY, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public List<ForecastNameDTO> listForecastsName(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                                       String fiscalYear) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listForecastsNameUrl, customerShortName, companyCode, propertyCode, fiscalYear);

            //查询数据
            String body = listData(tenantM3ConfigDTO, url, "listForecastsName", RESPONSE_TIMEOUT);

            return JsonUtils.convertStringToList(objectMapper, body, ForecastNameDTO.class);
        }

        /**
         * 获取日记账数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param fromDate            开始时间，格式：yyyy-MM-dd
         * @param toDate              结束时间，格式：yyyy-MM-dd
         * @return cloud.wconnect.finance.pojo.dto.pl.m3api.JournalEntryDataDTO
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 6, // 最大重试次数（包括首次调用）
                backoff = @Backoff(delay = 300000) // 固定延迟300000毫秒（5分钟）
        )
        public JournalEntryDataDTO listJournalEntry(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                                    String fromDate, String toDate) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();
            String url = MessageFormat.format(listJournalEntryUrl, customerShortName, companyCode, propertyCode, fromDate, toDate);

            String apiSortName = "listJournalEntry";

            // 获取token
            String token = m3TokenService.getToken(tenantM3ConfigDTO);

            // Headers
            HashMap<String, String> headers = Maps.newHashMapWithExpectedSize(1);
            headers.put("Authorization", token);

            // 发起请求
            HttpResponseWrapper responseWrapper = sendRequest(url, headers, RESPONSE_TIMEOUT);

            if (responseWrapper.getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { // 401 token失效
                log.warn("M3ApiService.{} token expired", apiSortName);
                //删除缓存中的token
                m3TokenService.deleteToken(tenantM3ConfigDTO.getTenantId());
                throw new RuntimeException("M3ApiService." + apiSortName + " token expired");
            } else if (responseWrapper.getStatusCode() == HttpStatus.SC_CREATED) { // 成功
                return JsonUtils.convertStringToObject(objectMapper, responseWrapper.getBody(), JournalEntryDataDTO.class);
            } else {
                log.error("M3ApiService.{} error, Status Code: {}, Response Body: {}", apiSortName, responseWrapper.getStatusCode(), responseWrapper.getBody());
                throw new RuntimeException("M3ApiService." + apiSortName + " error, statusCode:" + responseWrapper.getStatusCode());
            }
        }

        /**
         * 获取日记账数据
         *
         * @param tenantM3ConfigDTO   租户的M3 Config
         * @param propertyM3ConfigDTO 资产M3 Config
         * @param fromDate            开始时间，格式：yyyy-MM-dd
         * @param toDate              结束时间，格式：yyyy-MM-dd
         * @return cloud.wconnect.finance.pojo.dto.pl.m3api.JournalEntryDataDTO
         * @author lichen
         * @date 2025/9/26
         */
        @Retryable(
                retryFor = {Exception.class}, // 指定需要重试的异常类型
                maxAttempts = 3, // 最大重试次数（包括首次调用）, 默认3次
                backoff = @Backoff(delay = 300000, multiplier = 2) //初次重试延迟时间单位毫秒，后续每次重试延迟时间翻倍（乘以2）
        )
        public JournalEntryDataDTO listJournalEntry(TenantM3ConfigDTO tenantM3ConfigDTO, PropertyM3ConfigDTO propertyM3ConfigDTO,
                                                    String fromDate, String toDate, List<String> accountNumbers) {
            // 请求url
            String customerShortName = tenantM3ConfigDTO.getCustomerShortName();
            String companyCode = propertyM3ConfigDTO.getCompanyCode();
            String propertyCode = propertyM3ConfigDTO.getPropertyCode();

            String url = MessageFormat.format(listJournalEntryUrl + "&AccountNumbers={5}",
                    customerShortName, companyCode, propertyCode, fromDate, toDate, StringUtils.join(accountNumbers, ","));

            String apiSortName = "listJournalEntry";

            // 获取token
            String token = m3TokenService.getToken(tenantM3ConfigDTO);

            // Headers
            HashMap<String, String> headers = Maps.newHashMapWithExpectedSize(1);
            headers.put("Authorization", token);

            // 发起请求
            HttpResponseWrapper responseWrapper = sendRequest(url, headers, RESPONSE_TIMEOUT);

            if (responseWrapper.getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { // 401 token失效
                log.warn("M3ApiService.{} token expired", apiSortName);
                //删除缓存中的token
                m3TokenService.deleteToken(tenantM3ConfigDTO.getTenantId());
                throw new RuntimeException("M3ApiService." + apiSortName + " token expired");
            } else if (responseWrapper.getStatusCode() == HttpStatus.SC_CREATED) { // 成功
                return JsonUtils.convertStringToObject(objectMapper, responseWrapper.getBody(), JournalEntryDataDTO.class);
            } else {
                log.error("M3ApiService.{} error, Status Code: {}, Response Body: {}", apiSortName, responseWrapper.getStatusCode(), responseWrapper.getBody());
                throw new RuntimeException("M3ApiService." + apiSortName + " error, statusCode:" + responseWrapper.getStatusCode());
            }
        }

        /**
         * 调用m3接口获取数据
         *
         * @param tenantM3ConfigDTO 租户的M3 Config
         * @param url               接口url
         * @param apiSortName       接口简称
         * @param responseTimeout   等待响应数据的超时时间
         * @return java.util.List<org.apache.poi.ss.formula.functions.T>
         * @author lichen
         * @date 2025/9/26
         */
        private String listData(TenantM3ConfigDTO tenantM3ConfigDTO, String url, String apiSortName, long responseTimeout) {
            // 获取token
            String token = m3TokenService.getToken(tenantM3ConfigDTO);

            // Headers
            HashMap<String, String> headers = Maps.newHashMapWithExpectedSize(1);
            headers.put("Authorization", token);

            // 发起请求
            HttpResponseWrapper responseWrapper = sendRequest(url, headers, responseTimeout);

            if (responseWrapper.getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { // 401 token失效
                log.warn("M3ApiService.{} token expired", apiSortName);
                //删除缓存中的token
                m3TokenService.deleteToken(tenantM3ConfigDTO.getTenantId());
                throw new RuntimeException("M3ApiService." + apiSortName + " token expired");
            } else if (responseWrapper.getStatusCode() == HttpStatus.SC_OK) { // 成功
                return responseWrapper.getBody();
            } else {
                log.error("M3ApiService.{} error, Status Code: {}, Response Body: {}", apiSortName, responseWrapper.getStatusCode(), responseWrapper.getBody());
                throw new RuntimeException("M3ApiService." + apiSortName + " error, statusCode:" + responseWrapper.getStatusCode());
            }
        }

        /**
         * 发送请求
         *
         * @param url             请求的URL
         * @param headers         请求头
         * @param responseTimeout 等待响应数据的超时时间
         * @return cloud.wconnect.commonlib.pojo.HttpResponseWrapper
         * @author lichen
         * @date 2025/9/26
         */
        private HttpResponseWrapper sendRequest(String url, Map<String, String> headers, long responseTimeout) {
            try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
                HttpGet httpGet = new HttpGet(url);

                //配置超时时间
                RequestConfig config = RequestConfig.custom()
                        .setConnectionRequestTimeout(5, TimeUnit.SECONDS)//设置从连接池获取连接的超时时间
                        .setConnectTimeout(5, TimeUnit.SECONDS)//设置建立TCP连接的超时时间
                        .setResponseTimeout(responseTimeout, TimeUnit.SECONDS)//设置等待响应数据的超时时间
                        .build();
                httpGet.setConfig(config);

                if (headers != null) {
                    headers.forEach(httpGet::addHeader);
                }

                return httpClient.execute(httpGet, response -> {
                    int statusCode = response.getCode();
                    String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8) : null;
                    return new HttpResponseWrapper(statusCode, body);
                });
            } catch (Exception e) {
                log.error("GET request failed, url: {}", url, e);
                throw new InternalServerErrorException(
                        BaseErrorCodeEnum.THIRD_PARTY_SERVICE_ERROR.getI18nCode(),
                        BaseErrorCodeEnum.THIRD_PARTY_SERVICE_ERROR.getCode(),
                        "GET request exception, url = " + url, e);
            }
        }
    }