分类归档 技术笔记

rocketMQ-封装库 使用

一,引入本地依赖, 注意自己的电脑是否配置有本地库,没配置问一下别人

     <!--<dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.3.0</version>
     </dependency>-->  这个就不要了,关于 rocketmq的一切依赖都可以删掉,只需要保留以下本地库

        <dependency>
            <groupId>org.dxstudio</groupId>
            <artifactId>dx-rocketmq-starter</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

二,配置文件增加以下内容

environment: local  #这里各个环境要使用不同的环境标记,叫什么不重要,目前统一 local | dev | pron | prod
rocketmq:
  consumer:
    # 一次拉取消息最大值,注意是拉取消息的最大值而非消费最大值
    pull-batch-size: 10
  producer:
    group: dx-dump-group
    # 发送消息超时时间,默认3000
    sendMessageTimeout: 3000
    # 发送消息失败重试次数,默认2
    retryTimesWhenSendFailed: 2
    # 异步消息重试此处,默认2
    retryTimesWhenSendAsyncFailed: 2
    # 消息最大长度,默认1024 * 1024 * 4(默认4M)
    maxMessageSize: 4096
    # 压缩消息阈值,默认4k(1024 * 4)
    compressMessageBodyThreshold: 4096
    # 是否在内部发送失败时重试另一个broker,默认false
    retryNextServer: false
  name-server: 192.168.222.10:9876

三,main上添加注解

为了自动注入三方库中的类。因为在配置中做了 注解拦截和修改,用于自动隔离环境,自动更改你的侦听器里的group和topic

@MapperScan("org.dxstudio.dump.core.mapper")
@EnableAsync
@SpringBootApplication
@ComponentScan("org.dxstudio.*")  //加上这个
public class DumpApplication {

    public static void main(String[] args) {
        SpringApplication.run(DumpApplication.class, args);
    }

}

四,发布消息

已经使用项目组的规则做了封装,为保持统一,虽然没有二次封装 rocketMQTemplate,也请使用封装好的 topic 和message 来

@Autowired
RocketMQTemplate rocketMQTemplate;

private SendResult sendMsg(int taskId ){
        //第一个参数是标签,我们约定为事件类型,
        //第二个参数是消息的key(用于在mq后台查询),这里如果是订单 直接订单号,其他使用ID即可
        //第三个参数是你要发送的消息体,范型,爱传什么传什么
        DxMQTpl<Integer> t = new DxMQTpl<>("task", String.valueOf( taskId ), taskId);

        //没有再二次封装 rocketMQTemplate, 所以可以网上搜索这个的发送方法即可,没有特别情况一般就用 syncSend
        SendResult result = rocketMQTemplate.syncSend( t.topic(), t.message() );
        log.info("发送消息 {}", result) ;
        return result;

}

五,消费消息

消费消息已经封装好了处理方法,继承DxMQMessageHandler<T>、并实现 RocketMQListener< DxMQMessage<T> > 即可。

@RocketMQMessageListener 这个是 rocketMQTemplate 的注释,可网上查资料,一般就用我下面的即可,改成自己要订阅的频道

@Slf4j
@RocketMQMessageListener(consumerGroup = "dx-dump-group",
        topic = "dx-dump",
        selectorExpression = "task",
        selectorType = SelectorType.TAG,
        consumeThreadMax = 1,
        consumeThreadNumber = 1)
public class Consumer extends DxMQMessageHandler<Long> implements RocketMQListener< DxMQMessage<Long> > {
    @Autowired
    TaskScanService taskScanService;
    
    //这里固定写 执行超类方法即可
    @Override
    public void onMessage( DxMQMessage<Long> msg) {
        super.dispatchMessage( msg );
    }
    
    //这里是自己真正要处理的逻辑,有问题毫无犹豫抛出异常即可
    @Override
    protected void handleMessage(DxMQMessage<Long> message) throws Exception {
        log.info("处理消息 {}", message);
//        throw new RuntimeException("测试一下异常");
    }
    
    //这里是异常时要做的事情,比如标记异常。 
    //这里必须是重试都失败了才会最后触发,比如关闭 isRetry 和 throwException。 throwException开启会触发rocketmq的自动重发
    @Override
    protected void handleMaxRetriesExceeded(DxMQMessage<Long> message) {
        log.error("消费失败,后续处理,比如标记异常");
    }
    

    //过滤方法,返回true不会执行handleMessage。 用于限制重复的消息消费(rocketmq不会保证不重复),比如订单已经处理过了,在这里个方法里检查好,就不应该再继续处理
    @Override
    protected boolean filter(DxMQMessage<Long> message) {
        return super.filter(message);
    }
    
    //如果要自己修改重试规则(mq的重试是一直会有),开启true,则每次发生异常,是由代码控制 重新发消息。这样自己可以控制重试多少次(5秒间隔),达到最大次数后触发handleMaxRetriesExceeded
    //这个要看自己的业务,允许异常的业务 用这个,不允许异常的,使用mq的重试
    @Override
    protected boolean isRetry() {
        return true;
    }

    @Override
    protected boolean throwException() {
        return true;
    }
}

六,附上rocketmq的重发机制

系统工程教程 —— 仿谷歌超大图片局部加载实现

为了跟公司的人演示一个软件从思考到开发的过程,我花了一天的时间,实现了这部分功能

先看效果: https://php.joson.cc/imagemap/

第一步,搞清原理

什么是局部加载大图,如果有一张体积超大的图,一次性传送给浏览器那几乎是一次糟糕的体验。
1,就算一次传给用户,用户浏览器迫于分辨率和窗口所见局限,也有可能看不清楚。特别是需要看清图上的文字。
2,用户要等很久才能看到图片长啥样。
3,服务器带宽浪费。

如果能只传送用户可见区域的部分图片给用户,等用户拖动时再加载其他部分,这将给用户一个非常有意思的体验。就像我们经常使用的百度地图,地图是非常大的图片,如果百度服务器一次性将地图传给用户,那就不可想象。

那么,我们只要知道用户正在请求图片的哪部分,我们将图片的那部分传过去就好了,其实我们可以用一些简单的数学计算,加上服务器上的GD处理,很容易办到。但如果这样,那么用户的每次请求,服务器都将进行大量的实时演算,消耗巨大的资源来处理图片,这将是可怕的。那么我们很容易想到,预先让服务器把图片的每个缩放等级处理好,再缓存下来。下次直接读取磁盘的缓存则可以解决。

我们可以通过简单的计算,把图片拆分成一个网格。再利用前端JS动态读取每个格子的图片即可。

第二步,理清系统工作流程

第三步,逐个模块实现

其他模块就不用说了,都很简单,重点在图形处理与客户端显示部分。首先我们确立缩放等级的公式,定为 块尺寸 *  2^level,这个公式的缩放可以得到比较好的结果。

在服务器上,我们首先通过图片的尺寸,计算出它的最大 缩放等级 ceil(  sqrt( width / 块尺寸 ) ) , 再把每个缩放等级按照 块尺寸 分割成图则可。如下图(4个缩放等级):

(程序将每个缩放等级的图,自动分发在相对应的文件夹)

(再在每个文件夹里,自动把图拆分成块储存)

现在,只需要前端能准确知道,当前屏幕正在显示哪几块碎片,就可以将他们读取出来。 

(放大后加载缩放等级更高的局部)

(半秒后,所见区域的其他部分被加载了)

springboot+modelMapper+mybatis的枚举和JSON处理

一,依赖

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.48</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.2.0</version>

二,枚举使用

(1)定义枚举

public enum StoreTypeEnum {

    OSS(0,"阿里OSS服务"),
    S3(1,"亚马逊S3服务");

    StoreTypeEnum( int code, String des ){
        this.code = code;
        this.des = des;
    }

    @EnumValue
    private final int code;

    private final String des; //有jsonvalue注释,转换值为des,否则为枚举名

}

(2)接收参数中使用和验证枚举

@Data
public class StoreConfigParam {

    @NotEmpty(message = "键名不能为空")
    private String storeKey;

    @NotEmpty(message = "描述不能为空")
    private String des;

    @NotNull(message = "配置的类型不能为空")
    private StoreTypeEnum storeType;

    private JSONObject storeMeta;

}

(3)数据库实体类中定义枚举

@TableName(autoResultMap = true)
@Data
public class StoreConfig {

    @TableId
    private String storeKey;
    private String des;
    private StoreTypeEnum storeType;

    @TableField( typeHandler = Fastjson2TypeHandler.class)
    private JSONObject storeMeta;

}

(4)使用枚举

public class StoreConfigHttp {

    @Autowired
    private StoreConfigMapper storeConfigMapper;

    @Autowired
    private ModelMapper modelMapper;

    @PostMapping("/store-config/add")
    public ResultRes add(@Validated @RequestBody StoreConfigParam param){
        StoreConfig storeConfig = modelMapper.map( param, StoreConfig.class );
        storeConfigMapper.insert( storeConfig );
        return ResultRes.success();

    }

    @PostMapping("/store-config/update")
    public ResultRes update(@Validated @RequestBody StoreConfigParam param){
        StoreConfig storeConfig = modelMapper.map( param, StoreConfig.class );
        storeConfig.setStoreType(StoreTypeEnum.S3); //这里故意多写一行演示设置枚举值,实际上 modelMapper可以自动帮我买转换
        storeConfigMapper.updateById( storeConfig);
        return ResultRes.success();
    }
}

使用modelMapper讲param转换成endity时,会自动转换枚举值,非常方便。查询时,也会将数据库中存的code(整型)自动转为枚举名(字符串类型)。避免接收或返回给前端无意义的状态数字

三,JSON使用

(1)关于Mysql的JSON字段说明

https://www.cnblogs.com/ivictor/p/16221712.html

(2)使用modelMapper自动转换模型

modelMapper不能默认自动转换json高级类型,但它提供自定义converter方法。我们只需要在配置中加入以下代码。这里有2种方法,JSONObject转string,存数据库也行,JSONObject转JSONObject也行(听起来很奇怪,但即使完全相同的类型,modelMapper确实不能识别),Mybatis可以完成JSONObject的存储

@Configuration
public class ModelMapperConfig {

    @Bean
    public ModelMapper modelMapper(){

        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration().setFullTypeMatchingRequired(true);
        modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
         
        //从JSONObject类型转为 entidy 的JSONObject类型
        Converter< JSONObject, JSONObject > converter = new Converter<JSONObject, JSONObject>() {
            @Override
            public JSONObject convert(MappingContext<JSONObject, JSONObject> mappingContext) {
                return mappingContext.getSource();
            }
        };

        modelMapper.addConverter(converter);
        return modelMapper;

    }

}

(3)mybatis的自适应

entidy模型,必须开启注释@TableName(autoResultMap = true),并在json字段注释@tableField,指定使用typeHandler为json。mybatis默认提供了fastjson\fastjson2\jackson等的typehandler,也可以利用typehandler机制自己实现

@TableName(autoResultMap = true) //必须加这个选项
@Data
public class StoreConfig {

    @TableId
    private String storeKey;
    private String des;
    private StoreTypeEnum storeType;

    @TableField( typeHandler = Fastjson2TypeHandler.class) //mybatis 自带的TypeHandler可以处理json
    private JSONObject storeMeta;

}

完毕后,即可以使用JSONObject插入、更新字段,查询出内容后也会自动转换成JSON格式输出

{
    "code": 0,
    "message": null,
    "data": {
        "records": [
            {
                "storeKey": "tes2",
                "des": "这是一个测试配置da",
                "storeType": "S3",
                "storeMeta": {
                    "key": "wawa444"
                }
            },
   }
}

electron-builder打包时下载electron失败

在打包或者运行的时候,又时候会一直卡在再downloading url=https://github.com/electron/electron/releases/download/v11.2.1/electron-v11.2.1-win32-ia32.zip size=74 MB parts=8


在打包时会检测cache中是否有electron 包,如果没有的话会从github上拉去,在国内网络环境中拉取的过程大概率会失败,所以你可以自己去下载一个包放到cache目录里

各个平台的目录地址

Linux: $XDG_CACHE_HOME or ~/.cache/electron/
MacOS: ~/Library/Caches/electron/
Windows: %LOCALAPPDATA%/electron/Cache or ~/AppData/Local/electron/Cache/

nodejs笔记

#查看当前镜像
npm config get registry
#淘宝镜像
npm config set registry https://registry.npm.taobao.org
#原镜像
npm config set registry https://registry.npmjs.org

#清理
npm cache clean --force

#使用cnpm
npm install cnpm -g --registry=https://registry.npmmirror.com

#windows禁止运行cnpm脚本解决
set-ExecutionPolicy RemoteSigned

vue+electron

npm create @quick-start/electron
npm install
npm run dev

#安装vite
npm install electron-vite --save-dev

C++实现的RMM分词

#include <map>
#include <vector>
#include <list>
#include <string>
#include <algorithm>
#include "tools.h"
using namespace std;


#ifndef PARTH
#define PARTH

#define _ARRAY_COUNT_(x) sizeof(x)/sizeof(*x)
#define _WPRINTF_(c) setlocale(LC_ALL,"chs"); wprintf( c ); cout << endl;
#define _WVECTOR_ vector<wstring>

void CreateVectorForArray(const wstring *arr, const int arr_len, vector<wstring> &vec);
void StrReplace(wstring &str, const wstring find, const wstring replace);

#define _WCreateVectorForArray_(src,target) CreateVectorForArray(src,_ARRAY_COUNT_(src),target);
#define _WInVectory(str, vec) WInVectory(str,vec)

void CreateVectorForArray(const wstring *arr, const int arr_len, vector<wstring> &vec){
	vector<wstring> _tmp( arr, arr+arr_len );
	vec.resize(arr_len);
	copy(_tmp.begin(), _tmp.end(), vec.begin());
}


void StrReplace(wstring &str, const wstring find, const wstring replace){
	string::size_type pos = 0;
	while ( (pos = str.find(find, pos)) != wstring::npos ) {
		str.replace( pos, find.size(), replace );
		pos++;
	}
}

 
typedef struct WordPartResultItem{
	int count;
	int startOffset;
	int endOffset;
	int sort;
	float scale;
	WordPartResultItem():count(0),startOffset(0),endOffset(0),scale(0),sort(0){};
} _WordPartResultItem;



typedef WordPartResultItem _WordPartResult;



class WordPart{
	
public:
	
	WordPart( wstring word[], int word_len);
	
	void SetContent( wstring content);

	//分词时是否允许将每个单字分割词
	bool is_single;

	//执行分词
	map<wstring,_WordPartResult> Part(); 

private:

	//用于分词的词典
	map<int,list<wstring>> words;
	
	//词典总数
	int words_length;

	//将要分词的内容
	wstring content;

	//最大匹配词的长度
	int max_length;

	//忽略的字符
	_WVECTOR_ trim_str;
	
	//断句符号
	_WVECTOR_ end_dot;

	//获取最大匹配词的长度
	int GetMaxLength(wstring *words);

	void setResult( map<wstring,_WordPartResult> &result, int &sort, const wstring k, const int offset );

};

#endif;


WordPart::WordPart( wstring word[], int word_len){
	//this->words = word;
	this->words_length = word_len;
	this->is_single = false;
	max_length = GetMaxLength(word);
	
	wstring trim_tmp[] = {L"\"",L"'",L"‘",L"’",L"“",L"”",L"\\",L"(",L")",L"(",L")"};
	wstring end_tmp[] = {L".",L",",L"。",L",",L":",L":",L" "};
	_WCreateVectorForArray_(trim_tmp,trim_str)
	_WCreateVectorForArray_(end_tmp,end_dot)
	
	//整理词典
	for( int i=0; i<word_len; i++){
		int k = word[i].size();
		if( words.find(k) == words.end() ){
			list<wstring> v;
			words.insert( map<int,list<wstring>>::value_type(k,v) );
		}
		words[k].push_back(word[i]);
	}
	
}

void WordPart::SetContent( wstring content){
	_WVECTOR_::iterator iter;
	for( iter=trim_str.begin(); iter!=trim_str.end(); iter++){
		StrReplace(content,*iter,L" ");
	}
	this->content = content;
}

int WordPart::GetMaxLength(wstring *words){
	
	int size = 0;
	for( int i=0; i<words_length; i++ ){
		if( words[i].length() > size )
			size = words[i].length();
	}

	return size;
	
}


map<wstring,_WordPartResult> WordPart::Part(){

	map<wstring,_WordPartResult> result;
	
	if( content.empty() || words_length == 0 )
		return result;
	
	//如果要求切分单字
	if( is_single ){
		wstring cstr;
		wstring eng;
		int sort = 0;
		for( int i=0; i<content.size(); i++ ){
			cstr = content.substr(i,1);
			if( (cstr < L"a" || cstr > L"z") && (cstr < L"A" || cstr > L"Z") && (cstr < L"0" || cstr > L"9") ){
				if(!eng.empty()){
					if( words.find(eng.size()) == words.end() 
						|| find( words[eng.size()].begin(), words[eng.size()].end(), eng ) == words[eng.size()].end() )
					setResult(result,sort,eng,i-eng.size());
					eng.clear();
				}
				if( words.find(1) == words.end() 
					|| find( words[1].begin(), words[1].end(), cstr ) == words[1].end() )
					setResult(result,sort,cstr,i);

			}else{
				eng.append(cstr);
			}
		}
		if(!eng.empty()){
			if( words.find(eng.size()) == words.end() 
				|| find( words[eng.size()].begin(), words[eng.size()].end(), eng ) == words[eng.size()].end() )
				setResult(result,sort,eng,content.size()-eng.size());
				eng.clear();
		}

	}

	_WVECTOR_ line;
	wstring one_line = L"";

	//段句
	for( int i=0; i<content.size(); i++ ){
		wstring str = content.substr( i, 1 );
		if( find(end_dot.begin(),end_dot.end(),str) == end_dot.end() ){
			one_line.append(str);
		}else{
			line.insert(line.begin(),one_line);
			one_line.clear();
		}
		if( i==content.size() - 1 && !one_line.empty() ){
			line.insert(line.begin(),one_line);
		}
	}
	
	int content_size = content.size(), offset = content_size, sort = 0;

	for(_WVECTOR_::iterator i=line.begin(); i != line.end(); i++){
		offset -= (*(i)).size();
		if( i != line.begin() ){
			offset -= 1;	//断句符号位置
		}
		//分词
		wstring str = *i;			//段内容
		wstring cstr;				//分段内容
		int begin = 0,pointer;		//游标,一个全局游标,和一个相对游标
		int k;						//词典键
		int sublength = max_length;	//每个查询字符的长度
		
		if( str.size() > max_length ){
			begin = str.size() - max_length;
		}else{
			sublength = str.size();
		}
		
		while (begin != -sublength)
		{
			pointer = 0;
			while( pointer < sublength ){
				if( begin >= 0 ){
					cstr = str.substr( begin, sublength-pointer );
					k = cstr.size();
					
					if( words.find(k) != words.end() && find(words[k].begin(),words[k].end(),cstr)!=words[k].end() ){
						setResult(result,sort,cstr,offset+begin);
						break;
					}
				}
				pointer++;
				if( pointer < sublength )
					begin++;
			}
			begin-=sublength;
		}
	}
	return result;
}


void WordPart::setResult( map<wstring,_WordPartResult> &result, int &sort, const wstring k, const int offset ){
	if( result.find(k) == result.end() ){
		_WordPartResultItem row;
		result.insert( map<wstring,_WordPartResult>::value_type(k,row) );
		result[k].startOffset = offset;
		result[k].sort = sort;
		sort++;
	}
	result[k].count += 1;
	result[k].endOffset = offset;
} 

golang最小化镜像

# 基础镜像,基于golang的alpine镜像构建--编译阶段
FROM golang:alpine AS builder

# 作者
MAINTAINER joson
# 变量
# ARG <name>[=<default value>]

# 全局工作目录
WORKDIR /go/ddns

# 把运行Dockerfile文件的当前目录所有文件复制到目标目录
COPY . /go/ddns

# 环境变量
# 用于代理下载go项目依赖的包
ENV GOPROXY https://goproxy.cn,direct

# 编译,关闭CGO,防止编译后的文件有动态链接,而alpine镜像里有些c库没有,直接没有文件的错误
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build



# 使用alpine这个轻量级镜像为基础镜像--运行阶段
FROM alpine AS runner

# 全局工作目录
WORKDIR /go/ddns

# 复制编译阶段编译出来的运行文件到目标目录
COPY --from=builder /go/ddns/aliyun_ddns .
# 复制编译阶段里的config文件夹到目标目录
COPY --from=builder /go/ddns/conf/aliyun-ddns.conf /go/ddns/conf/.

# 将时区设置为东八区
RUN echo "https://mirrors.aliyun.com/alpine/v3.8/main/" > /etc/apk/repositories \
    && echo "https://mirrors.aliyun.com/alpine/v3.8/community/" >> /etc/apk/repositories \
    && apk add --no-cache tzdata \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime  \
    && echo Asia/Shanghai > /etc/timezone \
    && apk del tzdata

# 需暴露的端口
#EXPOSE 8888

# 可外挂的目录
VOLUME ["/go/ddns/conf"]
# docker run命令触发的真实命令(相当于直接运行编译后的可运行文件)
ENTRYPOINT ["./aliyun_ddns"]

docker网络

#显示所有docker局域网络
docker network ls

#创建自定义网络
docker network create mynet

#显示某个局域网络信息
docker network inspect mynet

#把容器加入网络
docker network connect 网络名 容器名

#把容器踢出网络
docker network disconnect 网络名 容器名

#查看所有容器IP
docker inspect --format='{{.Name}} - {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -aq)

#删除所有未引用网络
docker network prune

#删除docker网络
docker network rm 网络名


#docker windows 无法pull
DockerCli.exe -SwitchDaemon

golang常用命令

#初始化
go mod init
#获取模块
go mod get https://xxxx
#自动构建模块
go mod tidy
#编译
go build
#设置代理
#go
go env -w GOPROXY=https://goproxy.cn
#linux:
export GOPROXY=https://proxy.golang.com.cn,direct
#windows:
$env:GOPROXY = "https://proxy.golang.com.cn,direct"

podman笔记

#容器镜像加速服务
https://cr.console.aliyun.com/cn-zhangjiakou/instances/mirrors?accounttraceid=df5460ffc1564ef18daafa6f0fab9384lwno

podman build -f Dockerfile -t ubi-with-nano
podman run ubi8/ubi
podman ps -a

#检查正在运行的容器
podman inspect -l |grep -i ipaddress
podman attach xxxx

#导出导入
podman save > php8.1.tar php:8.1
docker load < php8.1.tar

#停止指定pod或者所有pod(其中的容器也随之停止)

podman pod stop HelloWorld
podman pod stop $(podman pod ps -q)

#删除指定Pod或者删除所有已经停止的pod

podman pod rm -f HugoBlog
podman pod rm $(podman pod ps -q)
podman rm $(podman ps -a -q)

#这里需要修改podman的配置文件,/etc/containers/registries.conf

unqualified-search-registries = ["docker.io"]
[[registry]]
prefix="docker.io"
location="fi54miqv.mirror.aliyuncs.com"
csdn.net/liqz2009/article/details/126567221

#给go项目打最小docker镜像
https://www.bilibili.com/read/cv17024112

#查看所有容器IP
docker inspect --format='{{.Name}} - {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -aq)

dsG5CrIhXayAC4I9
grant all privileges on . to root@'%';

#ubuntu更换源
sed -i s@/archive.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
sed -i s@/security.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
apt-get clean
apt-get update

#mysql密码问题
https://blog.csdn.net/qq_54202620/article/details/122729704