Spring Boot返回前端Long型丟失精度

最近為Prong開發(fā)了一個基于snowflake算法的Java分布式ID組件,將實體主鍵從原來的String類型的UUID修改成了Long型的分布式ID。修改后發(fā)現(xiàn)前端顯示的ID和數(shù)據(jù)庫中的ID不一致。例如數(shù)據(jù)庫中存儲的是:812782555915911412,顯示出來卻成了812782555915911400,后面2位變成了0,精度丟失了:

console.log(812782555915911412);
812782555915911400

這是什么原因呢?

原來,JavaScript中數(shù)字的精度是有限的,Java的Long類型的數(shù)字超出了JavaScript的處理范圍。JavaScript內(nèi)部只有一種數(shù)字類型Number,所有數(shù)字都是采用IEEE 754 標(biāo)準(zhǔn)定義的雙精度64位格式存儲,即使整數(shù)也是如此。這就是說,JavaScript 語言的底層根本沒有整數(shù),所有數(shù)字都是小數(shù)(64位浮點數(shù))。其結(jié)構(gòu)如圖:

image.png

各位的含義如下:

  • 1位(s) 用來表示符號位,0表示正數(shù),1表示負(fù)數(shù)
  • 11位(e) 用來表示指數(shù)部分
  • 52位(f) 表示小數(shù)部分(即有效數(shù)字)

雙精度浮點數(shù)(double)并不是能夠精確表示范圍內(nèi)的所有數(shù), 雖然雙精度浮點型的范圍看上去很大: 2.23 \times 10^{-308} \sim 1.79 \times 10^{308}。 可以表示的最大整數(shù)可以很大,但能夠精確表示、使用算數(shù)運算的并沒有這么大。因為小數(shù)部分最大是 52 位,因此 JavaScript 中能精準(zhǔn)表示的最大整數(shù)是 2^{53} - 1,十進制為 9007199254740991。

console.log(Math.pow(2, 53) - 1);
console.log(1L<<53);
9007199254740991

JavaScript 有所謂的最大和最小安全值:

console.log(Number.MAX_SAFE_INTEGER);
console.log(Number.MIN_SAFE_INTEGER);
9007199254740991
-9007199254740991

安全意思是說能夠one-by-one表示的整數(shù),也就是說在(-2^{53}, 2^{53})范圍內(nèi),雙精度數(shù)表示和整數(shù)是一對一的,在這個范圍以內(nèi),所有的整數(shù)都有唯一的浮點數(shù)表示,這叫做安全整數(shù)。

而超過這個范圍,會有兩個或更多整數(shù)的雙精度表示是相同的;即超過這個范圍,有的整數(shù)是無法精確表示的,只能大約(round)到與它相近的浮點數(shù)(說到底就是科學(xué)計數(shù)法)表示,這種情況下叫做不安全整數(shù),例如:

console.log(Number.MAX_SAFE_INTEGER + 1);   // 結(jié)果:9007199254740992,精度未丟失
console.log(Number.MAX_SAFE_INTEGER + 2);   // 結(jié)果:9007199254740992,精度丟失
console.log(Number.MAX_SAFE_INTEGER + 3);   // 結(jié)果:9007199254740994,精度未丟失
console.log(Number.MAX_SAFE_INTEGER + 4);   // 結(jié)果:9007199254740996,精度丟失
console.log(Number.MAX_SAFE_INTEGER + 5);   // 結(jié)果:9007199254740996,精度未丟失

而Java的Long類型的有效位數(shù)是63位(扣除一位符號位),其最大值為2^{63}-1,十進制為9223372036854775807。

public static void main(String[] args) {
    System.out.println(Long.MAX_VALUE);
    System.out.println((1L<<63) -1);
}
9223372036854775807
9223372036854775807

所以只要java傳給JavaScript的Long類型的值超過9007199254740991,就有可能產(chǎn)生精度丟失,從而導(dǎo)致數(shù)據(jù)和邏輯出錯。

和其他編程語言(如 C 和 Java)不同,JavaScript 不區(qū)分整數(shù)值和浮點數(shù)值,所有數(shù)字在 JavaScript 中均用浮點數(shù)值表示,所以在進行數(shù)字運算的時候要特別注意精度缺失問題。容易造成混淆的是,某些運算只有整數(shù)才能完成,此時 JavaScript 會自動把64位浮點數(shù),轉(zhuǎn)成32位整數(shù),然后再進行運算,由于浮點數(shù)不是精確的值,所以涉及小數(shù)的比較和運算要特別小心。

進一步閱讀:JavaScript 教程 - 數(shù)據(jù)類型 - 數(shù)值

那有什么解決方法呢?

解決辦法之一就是讓Javascript把數(shù)字當(dāng)成字符串進行處理,對Javascript來說如果不進行運算,數(shù)字和字符串處理起來沒有什么區(qū)別。但如果需要進行運算,只能采用其他方法,例如JavaScript的一些開源庫 bignum、bigint等支持長整型的處理。在我們這個場景里不需要進行運算,且Java進行JSON處理的時候是能夠正確處理long型的,所以只需要將數(shù)字轉(zhuǎn)化成字符串就可以了。

大家都知道,用Spring cloud構(gòu)建微服務(wù)架構(gòu)時,API(controller)通常用@RestController進行注解,而 @Restcontroller@Controller@ResponseBody的結(jié)合體,而@ResponseBody用于將后臺返回的Java對象轉(zhuǎn)換為Json字符串傳遞給前臺。

@Controller用于注解配合視圖解析器InternalResourceViewResolver來完成頁面跳轉(zhuǎn)。如果要返回JSON數(shù)據(jù)到頁面上,則需要使用@RestController注解。
當(dāng)數(shù)據(jù)庫字段為date類型時,@ResponseBody注解在轉(zhuǎn)換日期類型時會默認(rèn)把日期轉(zhuǎn)換為時間戳(例如: date:2017-10-25 轉(zhuǎn)換為 時間戳:15003323990)。

在Spring boot中處理方法基本上有以下幾種:

一、配置參數(shù)

Jackson有個配置參數(shù)WRITE_NUMBERS_AS_STRINGS,可以強制將所有數(shù)字全部轉(zhuǎn)成字符串輸出。其功能介紹為:Feature that forces all Java numbers to be written as JSON strings.。使用方法很簡單,只需要配置參數(shù)即可:

spring:
  jackson:
    generator:
      write_numbers_as_strings: true

這種方式的優(yōu)點是使用方便,不需要調(diào)整代碼;缺點是顆粒度太大,所有的數(shù)字都被轉(zhuǎn)成字符串輸出了,包括按照timestamp格式輸出的時間也是如此。

二、注解

另一個方式是使用注解JsonSerialize。

使用官方提供的Serializer

@JsonSerialize(using=ToStringSerializer.class)
private Long bankcardHash;

指定了ToStringSerializer進行序列化,將數(shù)字編碼成字符串格式。這種方式的優(yōu)點是顆粒度可以很精細;缺點同樣是太精細,如果需要調(diào)整的字段比較多會比較麻煩。

三、自定義ObjectMapper

可以單獨根據(jù)類型進行設(shè)置,只對Long型數(shù)據(jù)進行處理,轉(zhuǎn)換成字符串,而對其他類型的數(shù)字不做處理。Jackson提供了這種支持,即對ObjectMapper進行定制。根據(jù)SpringBoot的官方幫助,找到一種相對簡單的方法,只對ObjectMapper進行定制,而不是完全從頭定制,方法如下:

@Bean("jackson2ObjectMapperBuilderCustomizer")
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
    Jackson2ObjectMapperBuilderCustomizer customizer = new Jackson2ObjectMapperBuilderCustomizer() {
        @Override
        public void customize(Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder) {
            jacksonObjectMapperBuilder.serializerByType(Long.class, ToStringSerializer.instance)
                    .serializerByType(Long.TYPE, ToStringSerializer.instance);
        }
    };
    return customizer;
}

通過定義Jackson2ObjectMapperBuilderCustomizer,對Jackson2ObjectMapperBuilder對象進行定制,對Long型數(shù)據(jù)進行了定制,使用ToStringSerializer來進行序列化。問題終于完美解決。

四、使用HttpMessageConverter(建議方案)

關(guān)于HttpMessageConverter

HttpMessageConverter接口提供了 5 個方法:

  • canRead:判斷該轉(zhuǎn)換器是否能將請求內(nèi)容轉(zhuǎn)換成 Java 對象

  • canWrite:判斷該轉(zhuǎn)換器是否可以將 Java 對象轉(zhuǎn)換成返回內(nèi)容

  • getSupportedMediaTypes:獲得該轉(zhuǎn)換器支持的 MediaType 類型

  • read:讀取請求內(nèi)容并轉(zhuǎn)換成 Java 對象

  • write:將 Java 對象轉(zhuǎn)換后寫入返回內(nèi)容

    其中readwrite方法的參數(shù)分別有有HttpInputMessageHttpOutputMessage對象,這兩個對象分別代表著一次 Http 通訊中的請求和響應(yīng)部分,可以通過getBody方法獲得對應(yīng)的輸入流和輸出流。

當(dāng)前 Spring 中已經(jīng)默認(rèn)提供了相當(dāng)多的轉(zhuǎn)換器,分別有:

名稱 作用 讀支持 MediaType 寫支持 MediaType
ByteArrayHttpMessageConverter 數(shù)據(jù)與字節(jié)數(shù)組的相互轉(zhuǎn)換 */* application/octet-stream
StringHttpMessageConverter 數(shù)據(jù)與 String 類型的相互轉(zhuǎn)換 text/* text/plain
FormHttpMessageConverter 表單與 MultiValueMap的相互轉(zhuǎn)換 application/x-www-form-urlencoded application/x-www-form-urlencoded
SourceHttpMessageConverter 數(shù)據(jù)與 javax.xml.transform.Source 的相互轉(zhuǎn)換 text/xml 和 application/xml text/xml 和 application/xml
MarshallingHttpMessageConverter 使用 Spring 的 Marshaller/Unmarshaller 轉(zhuǎn)換 XML 數(shù)據(jù) text/xml 和 application/xml text/xml 和 application/xml
MappingJackson2HttpMessageConverter 使用 Jackson 的 ObjectMapper 轉(zhuǎn)換 Json 數(shù)據(jù) application/json application/json
MappingJackson2XmlHttpMessageConverter 使用 Jackson 的 XmlMapper 轉(zhuǎn)換 XML 數(shù)據(jù) application/xml application/xml
BufferedImageHttpMessageConverter 數(shù)據(jù)與 java.awt.image.BufferedImage 的相互轉(zhuǎn)換 Java I/O API 支持的所有類型 Java I/O API 支持的所有類型
image.png

注意到AbstractMessageConverterMethodProcessor類的getProducibleMediaTypes、writeWithMessageConverters等方法在每次消息解析轉(zhuǎn)換都要作GenericHttpMessageConverter分支判斷,為什么呢?

package org.springframework.web.servlet.mvc.method.annotation;

public abstract class AbstractMessageConverterMethodProcessor extends AbstractMessageConverterMethodArgumentResolver
        implements HandlerMethodReturnValueHandler {
        
        protected List<MediaType> getProducibleMediaTypes(HttpServletRequest request, Class<?> valueClass, Type declaredType) {
        Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
        if (!CollectionUtils.isEmpty(mediaTypes)) {
            return new ArrayList<MediaType>(mediaTypes);
        }
        else if (!this.allSupportedMediaTypes.isEmpty()) {
            List<MediaType> result = new ArrayList<MediaType>();
            for (HttpMessageConverter<?> converter : this.messageConverters) {
                // 分支判斷
                if (converter instanceof GenericHttpMessageConverter && declaredType != null) {
                    if (((GenericHttpMessageConverter<?>) converter).canWrite(declaredType, valueClass, null)) {
                        result.addAll(converter.getSupportedMediaTypes());
                    }
                }
                else if (converter.canWrite(valueClass, null)) {
                    result.addAll(converter.getSupportedMediaTypes());
                }
            }
            return result;
        }
        else {
            return Collections.singletonList(MediaType.ALL);
        }
    }
}

GenericHttpMessageConverter接口繼承自HttpMessageConverter接口,用于提供支持泛型信息(java.lang.reflect.Type)參數(shù)的canRead/read/canWrite/write方法。它的實現(xiàn)類為 AbstractGenericHttpMessageConverter。

定制HttpMessageConverter

package io.prong.boot.framework;

import java.math.BigInteger;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;

import io.prong.boot.framework.json.CustomMappingJackson2HttpMessageConverter;

/**
 * prong boot 自動配置
 * 
 * @author tangyz
 *
 */
@Configuration
public class ProngBootAutoConfig {

    /**
     * 解決前端js處理大數(shù)字丟失精度問題,將Long和BigInteger轉(zhuǎn)換成string
     * 
     * @return
     */
    @Bean
    @ConditionalOnMissingBean
    public MappingJackson2HttpMessageConverter getMappingJackson2HttpMessageConverter() {
        CustomMappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new CustomMappingJackson2HttpMessageConverter();
        ObjectMapper objectMapper = new ObjectMapper();
        SimpleModule simpleModule = new SimpleModule();
        // 序列換成json時,將所有的long變成string 因為js中得數(shù)字類型不能包含所有的java long值
        simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance);
        simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
        simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(simpleModule);
        jackson2HttpMessageConverter.setObjectMapper(objectMapper);
        return jackson2HttpMessageConverter;
    }

}

因為全局地對所有的long轉(zhuǎn)string的粒度太粗了,我們需要對不同的接口進行區(qū)分,比如限定只對web前端的接口需要轉(zhuǎn)換,但對于內(nèi)部微服務(wù)之間的調(diào)用或者第三方接口等則不需要進行轉(zhuǎn)換。CustomMappingJackson2HttpMessageConverter的主要作用就是為了限定long轉(zhuǎn)string的范圍為web接口,即符合/web/xxxxx風(fēng)格的url(當(dāng)然這個你需要根據(jù)自己產(chǎn)品的規(guī)范進行自定義)。

package io.prong.boot.framework.json;

import java.lang.reflect.Type;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

/**
 * 自定義的json轉(zhuǎn)換器,匹配web api(以/web/開頭的controller)中的接口方法的返回參數(shù)
 * 
 * @author tangyz
 *
 */
public class CustomMappingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter {

    private final static Logger logger = LoggerFactory.getLogger(CustomMappingJackson2HttpMessageConverter.class);

    /**
     * 判斷該轉(zhuǎn)換器是否能將請求內(nèi)容轉(zhuǎn)換成 Java 對象
     */
    @Override
    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        // 不需要反序列化
        return false;
    }

    /**
     * 判斷該轉(zhuǎn)換器是否能將請求內(nèi)容轉(zhuǎn)換成 Java 對象
     */
    @Override
    public boolean canRead(Type type, Class<?> contextClass, MediaType mediaType) {
        // 不需要反序列化
        return false;
    }

    /**
     * 判斷該轉(zhuǎn)換器是否可以將 Java 對象轉(zhuǎn)換成返回內(nèi)容.
     * 匹配web api(形如/web/xxxx)中的接口方法的返回參數(shù)
     */
    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        if (super.canWrite(clazz, mediaType)) {
            ServletRequestAttributes ra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (ra != null) { // web請求
                HttpServletRequest request = ra.getRequest();
                String uri = request.getRequestURI(); // 例如: "/web/frontApplicationPage"
                logger.debug("Current uri is: {}", uri);
                if (uri.startsWith("/web/")) {
                    return true;
                }
            }
        }
        return false;
    }
    
}

我們的疑問來了,spring boot默認(rèn)到底有多少個轉(zhuǎn)換器?我們自定義的CustomMappingJackson2HttpMessageConverter是覆蓋了默認(rèn)的MappingJackson2HttpMessageConverter,還是兩者并存?多個轉(zhuǎn)換器之間的順序是如何的?相互之間是否有影響?

下面我們來一一分析并回答。

查看spring的源碼,首先我們找到了DelegatingWebMvcConfiguration類,它的setConfigurers方法將Spring容器中所有的WebMvcConfigurer接口bean注入了方法的參數(shù)configurers中。

package org.springframework.web.servlet.config.annotation;

@Configuration
public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {

    private final WebMvcConfigurerComposite configurers = new WebMvcConfigurerComposite();

    /**
     * 將Spring容器中所有的WebMvcConfigurer接口bean注入了參數(shù)configurers
     */
    @Autowired(required = false)
    public void setConfigurers(List<WebMvcConfigurer> configurers) {
        if (!CollectionUtils.isEmpty(configurers)) {
            this.configurers.addWebMvcConfigurers(configurers);
        }
    }

跟蹤org.springframework.web.servlet.config.annotation.WebMvcConfigurerComposite類的configureMessageConverters方法,有以下WebMvcConfigurer接口的9個代理(this.delegates):

[0]io.prong.cloud.platform.config.SwaggerConfig
[1]org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration
[2]org.springframework.security.config.annotation.web.configuration.WebMvcSecurityConfiguration
[3]org.springframework.cloud.netflix.metrics.MetricsInterceptorConfiguration$MetricsWebResourceConfiguration
[4]org.springframework.boot.actuate.endpoint.mvc.HeapdumpMvcEndpoint
[5]org.springframework.boot.actuate.endpoint.mvc.LogFileMvcEndpoint
[6]org.springframework.boot.actuate.endpoint.mvc.AuditEventsMvcEndpoint
[7]org.springframework.data.web.config.SpringDataWebConfiguration
[8]org.springframework.cloud.netflix.rx.RxJavaAutoConfiguration$RxJavaReturnValueHandlerConfig

當(dāng)然,這個代理的數(shù)量是不確定的,跟你的工程以及所依賴組件里面包含的WebMvcConfigurer接口實現(xiàn)類的數(shù)量有關(guān)系。

目前這里面只有WebMvcAutoConfiguration代理類覆蓋了configureMessageConverters方法并定義了spring boot默認(rèn)的轉(zhuǎn)換器,所以其他代理類的我們可以無視了。跟蹤代碼可以找到spring boot在WebMvcConfigurationSupport類的addDefaultHttpMessageConverters方法中對默認(rèn)的轉(zhuǎn)換器進行了定義。

跟蹤org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration的內(nèi)部類WebMvcAutoConfigurationAdapter類的configureMessageConverters(List<HttpMessageConverter<?>> converters)方法,發(fā)現(xiàn)最終初始化的轉(zhuǎn)換器順序如下:

[0]org.springframework.http.converter.ByteArrayHttpMessageConverter
[1]org.springframework.http.converter.StringHttpMessageConverter  // spring boot自定義的轉(zhuǎn)換器
[2]org.springframework.http.converter.StringHttpMessageConverter
[3]org.springframework.http.converter.ResourceHttpMessageConverter
[4]org.springframework.http.converter.xml.SourceHttpMessageConverter
[5]org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
[6]io.prong.boot.framework.json.CustomMappingJackson2HttpMessageConverter  // prong boot自定義的轉(zhuǎn)換器
[7]org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
[8]org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter

那么我們定義的轉(zhuǎn)換器是怎么加入進來的呢?

HttpMessageConvertersAutoConfiguration類的構(gòu)造函數(shù),掃描spring容器并找到所有通過@bean方式定義的HttpMessageConverter轉(zhuǎn)換器:

package org.springframework.boot.autoconfigure.web;

public class HttpMessageConvertersAutoConfiguration {

    static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper";

    private final List<HttpMessageConverter<?>> converters;

    public HttpMessageConvertersAutoConfiguration(
            ObjectProvider<List<HttpMessageConverter<?>>> convertersProvider) {
        // 找到容器里自定義的HttpMessageConverter實例
        this.converters = convertersProvider.getIfAvailable();
    }

這里面找到了2個:

[0]io.prong.boot.framework.json.CustomMappingJackson2HttpMessageConverter
[1]org.springframework.http.converter.StringHttpMessageConverter

接下來spring boot將自定義的轉(zhuǎn)換器和默認(rèn)的轉(zhuǎn)換器進行合并:

package org.springframework.boot.autoconfigure.web;

public class HttpMessageConverters implements Iterable<HttpMessageConverter<?>> {

    public HttpMessageConverters(boolean addDefaultConverters,
            Collection<HttpMessageConverter<?>> converters) {
        // 將自定義的轉(zhuǎn)換器和默認(rèn)的轉(zhuǎn)換器進行合并
        List<HttpMessageConverter<?>> combined = getCombinedConverters(converters,
                addDefaultConverters ? getDefaultConverters()
                        : Collections.<HttpMessageConverter<?>>emptyList());
        combined = postProcessConverters(combined);
        this.converters = Collections.unmodifiableList(combined);
    }

合并在方法getCombinedConverters中進行,具體的算法大家可以看看源代碼,我總結(jié)算法的主要核心如下:

1、比較自定義轉(zhuǎn)換器類型是否為可以替換默認(rèn)轉(zhuǎn)換器的類型?
   例如 CustomMappingJackson2HttpMessageConverter 是可以替換默認(rèn)的 MappingJackson2HttpMessageConverter。
2、如果是,將自定義轉(zhuǎn)換器放在默認(rèn)轉(zhuǎn)換器的前面。

因此,我們可以最終看到如上所述的,CustomMappingJackson2HttpMessageConverter轉(zhuǎn)換器的順序排在了默認(rèn)轉(zhuǎn)換器MappingJackson2HttpMessageConverter的前面。

注意,轉(zhuǎn)換器是采用read、write分離的2條職責(zé)鏈的設(shè)計模式,一旦某個轉(zhuǎn)換器的read/write可以處理請求,則退出職責(zé)鏈。

排除例外

定義自己的Serializer

上面的MappingJackson2HttpMessageConverter將所有的long都轉(zhuǎn)成了string,對于有些例外的情況,例如前端antd列表組件的總記錄數(shù)為number,java后端使用了pagehelper分頁組件,pagehelperPage類返回的記錄總數(shù)totallong型,如果轉(zhuǎn)為string給前端就會有問題,因此,我們通過自定義的Serializer來排除這種例外。

import java.io.IOException;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

public class LongJsonSerializer extends JsonSerializer<Long> {

    @Override
    public void serialize(Long value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
            throws IOException {
        if (value != null) {
            jsonGenerator.writeNumber(value);
        }
    }

}

如何使用?

使用自定義的PageBean類替換官方的PageInfo,并在PageBean類中使用:

@JsonSerialize(using = LongJsonSerializer.class)
private long total;     // 總記錄數(shù)
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容