如何在Go中进行预签名的POST上传到AWS S3?

huangapple go评论85阅读模式
英文:

How to do a Pre-signed POST upload to AWS S3 in Go?

问题

我将为您翻译以下内容:

我想要使用Go语言进行预签名 POST 请求以将文件上传到 AWS S3 存储桶,请问如何实现?

请注意,这与使用 PUT 进行预签名上传不同。

英文:

I would like to do a pre-signed POST to upload files to an AWS S3 bucket - how would this be done in Go?

Please note that this is not the same as Pre-signed upload with PUT.

答案1

得分: 6

为了帮助他人,我将回答这个问题并提供一些代码,以帮助其他可能遇到同样问题的人。

在这里可以找到一个示例的 Google App Engine 网页应用程序,用于渲染预签名的 POST 表单:https://github.com/murrekatt/go-aws-s3-presigned-post-app-engine

还有一个我创建的用于在 Go 中执行预签名 POST 的小型库:https://github.com/murrekatt/go-s3presigned-post

简而言之,要将预签名的 POST 请求发送到一个公开读取的 Amazon S3 存储桶,你需要执行以下步骤:

1. 配置 S3 存储桶只允许公开下载。

以下是一个允许仅公开读取的示例存储桶策略:

{
    "Version": "2012-10-17",
    "Id": "akjsdhakshfjlashdf",
    "Statement": [
        {
            "Sid": "kjahsdkajhsdkjasda",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::BUCKETNAMEHERE/*"
        }
    ]
}

2. 创建一个允许上传的 HTTP POST 策略。

参考 AWS S3 文档:http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html

以下是一个带有过期时间、上传到特定存储桶并允许公开读取访问的 POST 策略模板示例:

{
    "expiration": "%s",
    "conditions": [
        {"bucket": "%s"},
        ["starts-with", "$key", "%s"],
        {"acl": "public-read"},
        {"x-amz-credential": "%s"},
        {"x-amz-algorithm": "AWS4-HMAC-SHA256"},
        {"x-amz-date": "%s"}
    ]
}

3. 使用 S3 存储桶所有者的凭证生成并签署策略。

参考 AWS 文档:http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html

  • 填写正确的过期时间、存储桶、键、凭证和日期值。
  • 对策略进行 base64 编码。
  • 使用 HMAC-SHA256 对策略进行签名。
  • 对签名进行十六进制编码。

4. 构建并发送多部分表单数据的 POST 请求。

参考 AWS S3 文档:http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

现在,你可以按照上述链接中描述的方式生成一个 HTML 表单,并自动获取正确的多部分表单数据请求。

我想在 Go 中手动完成这个过程,以下是如何实现的:

无论哪种方式,你都需要提供在步骤 2 和 3 中创建的 POST 策略中指定的所有部分。除了必填字段(不在策略中)外,请求中不能有其他字段。

字段的顺序也是指定的,所有字段都是 HTTP POST 请求中的多部分字段。

func Upload(url string, fields Fields) error {
    var b bytes.Buffer
    w := multipart.NewWriter(&b)
    for _, f := range fields {
        fw, err := w.CreateFormField(f.Key)
        if err != nil {
            return err
        }
        if _, err := fw.Write([]byte(f.Value)); err != nil {
            return err
        }
    }
    w.Close()

    req, err := http.NewRequest("POST", url, &b)
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", w.FormDataContentType())

    client := &http.Client{}
    res, err := client.Do(req)
    if err != nil {
        return err
    }
    if res.StatusCode != http.StatusOK {
        err = fmt.Errorf("bad status: %s", res.Status)
    }
    return nil
}

以上是你要翻译的内容。

英文:

So in order to help others I will answer the question myself and provide some code to help others who might have the same problem.

Example web app for Google App Engine rendering a pre-signed POST form can be found here.

And a small library I created doing the pre-signed POST in Go.

In short, doing a presigned POST to a public-read Amazon S3 bucket you need to:

1. Configure the S3 bucket to only allow public download.

Example bucket policy that allow only public read.

{
    "Version": "2012-10-17",
    "Id": "akjsdhakshfjlashdf",
    "Statement": [
	    {
		    "Sid": "kjahsdkajhsdkjasda",
		    "Effect": "Allow",
		    "Principal": {
			    "AWS": "*"
		    },
		    "Action": "s3:GetObject",
		    "Resource": "arn:aws:s3:::BUCKETNAMEHERE/*"
	    }
    ]
}

2. Create a policy for the HTTP POST that allows the upload.

AWS S3 docs

Example POST policy template with expiration to upload a specific key, into a specific bucket and allow public-read access.

{ "expiration": "%s",
	"conditions": [
		{"bucket": "%s"},
		["starts-with", "$key", "%s"],
		{"acl": "public-read"},

		{"x-amz-credential": "%s"},
		{"x-amz-algorithm": "AWS4-HMAC-SHA256"},
		{"x-amz-date": "%s" }
	]
}

3. Generate and sign the policy using the S3 bucket owner's credentials.

AWS docs

  • Fill in the correct values for expiration, bucket, key, credentials and date.
  • base64 encode the policy.
  • HMAC-SHA256 the policy to get a signature.
  • hex encode the signature.

4. Construct and POST the multipart form data

AWS S3 docs

Now either you would generate an HTML form and automatically get the correct multipart form data request like described in the above link.

I wanted to do this by hand in Go so here's how to do that.

Either way you need to provide all the parts that are specified in the POST policy you created in steps 2 and 3. You can also not have additional fields in the request except for the mandatory ones (not in the policy).

The order of the fields is also specified and all of them are multipart fields in the HTTP POST request.

func Upload(url string, fields Fields) error {
    var b bytes.Buffer
    w := multipart.NewWriter(&b)
    for _, f := range fields {
            fw, err := w.CreateFormField(f.Key)
            if err != nil {
                    return err
            }
            if _, err := fw.Write([]byte(f.Value)); err != nil {
                    return err
            }
    }
    w.Close()

    req, err := http.NewRequest("POST", url, &b)
    if err != nil {
            return err
    }
    req.Header.Set("Content-Type", w.FormDataContentType())

    client := &http.Client{}
    res, err := client.Do(req)
    if err != nil {
            return err
    }
    if res.StatusCode != http.StatusOK {
            err = fmt.Errorf("bad status: %s", res.Status)
    }
    return nil
}

答案2

得分: 4

这是一个来自https://github.com/minio/minio-go的替代方法,你可能会喜欢它,因为它提供了一种完全以编程方式生成预签名 POST 策略的方法。

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/minio/minio-go"
)

func main() {
	policy := minio.NewPostPolicy()
	policy.SetKey("myobject")
	policy.SetBucket("mybucket")
	policy.SetExpires(time.Now().UTC().AddDate(0, 0, 10)) // expires in 10 days
	config := minio.Config{
		AccessKeyID:     "YOUR-ACCESS-KEY-HERE",
		SecretAccessKey: "YOUR-PASSWORD-HERE",
		Endpoint:        "https://s3.amazonaws.com",
	}
	s3Client, err := minio.New(config)
	if err != nil {
		log.Fatalln(err)
	}
	m, err := s3Client.PresignedPostPolicy(policy)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("curl ")
	for k, v := range m {
		fmt.Printf("-F %s=%s ", k, v)
	}
	fmt.Printf("-F file=@/etc/bashrc ")
	fmt.Printf(config.Endpoint + "/mybucket\n")
}

步骤1:

policy := minio.NewPostPolicy()
policy.SetKey("myobject")
policy.SetBucket("mybucket")
policy.SetExpires(time.Now().UTC().AddDate(0, 0, 10)) // expires in 10 days

实例化一个新的策略结构,该策略结构实现了以下方法:

func NewPostPolicy() *PostPolicy
func (p *PostPolicy) SetBucket(bucket string) error
func (p *PostPolicy) SetContentLength(min, max int) error
func (p *PostPolicy) SetContentType(contentType string) error
func (p *PostPolicy) SetExpires(t time.Time) error
func (p *PostPolicy) SetKey(key string) error
func (p *PostPolicy) SetKeyStartsWith(keyStartsWith string) error
func (p PostPolicy) String() string

步骤2:

m, err := s3Client.PresignedPostPolicy(policy)
if err != nil {
	fmt.Println(err)
	return
}

现在,PresignedPostPolicy() 接受 PostPolicy 结构,并返回一个“键/值”映射,可以在 HTML 表单或 curl 命令中使用该映射来上传数据到 S3。

英文:

Here is an alternative approach from https://github.com/minio/minio-go
that you might like for a full programmatic way of generating presigned post policy.

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/minio/minio-go"
)

func main() {
    policy := minio.NewPostPolicy()
	policy.SetKey("myobject")
	policy.SetBucket("mybucket")
	policy.SetExpires(time.Now().UTC().AddDate(0, 0, 10)) // expires in 10 days
	config := minio.Config{
		AccessKeyID:     "YOUR-ACCESS-KEY-HERE",
		SecretAccessKey: "YOUR-PASSWORD-HERE",
		Endpoint:        "https://s3.amazonaws.com",
	}
	s3Client, err := minio.New(config)
	if err != nil {
		log.Fatalln(err)
	}
	m, err := s3Client.PresignedPostPolicy(policy)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("curl ")
	for k, v := range m {
		fmt.Printf("-F %s=%s ", k, v)
	}
	fmt.Printf("-F file=@/etc/bashrc ")
	fmt.Printf(config.Endpoint + "/mybucket\n")
}

Step 1:

    policy := minio.NewPostPolicy()
	policy.SetKey("myobject")
	policy.SetBucket("mybucket")
	policy.SetExpires(time.Now().UTC().AddDate(0, 0, 10)) // expires in 10 days

Instantiate a new policy structure, this policy structure implements following methods.

func NewPostPolicy() *PostPolicy
func (p *PostPolicy) SetBucket(bucket string) error
func (p *PostPolicy) SetContentLength(min, max int) error
func (p *PostPolicy) SetContentType(contentType string) error
func (p *PostPolicy) SetExpires(t time.Time) error
func (p *PostPolicy) SetKey(key string) error
func (p *PostPolicy) SetKeyStartsWith(keyStartsWith string) error
func (p PostPolicy) String() string

Step 2:

    m, err := s3Client.PresignedPostPolicy(policy)
	if err != nil {
		fmt.Println(err)
		return
	}

Now PresignedPostPolicy() takes the PostPolicy structure and returns back a map of "key/values" which can be used in your HTML form or curl command to upload data to s3.

答案3

得分: 2

一瞥之下,似乎POST方法需要使用附加的策略和签名,用于基于浏览器的上传。详细信息请参阅AWS文档

具体来说,您需要生成策略并对其进行签名,然后将它们包含在HTML表单中,以及POST请求中的其他必需信息。或者让浏览器为您完成这些步骤。

在HTML表单的POST上传中,您只需对策略字符串进行签名。要发布的最终URL可能会根据表单内容而变化:https://bucket.s3.amazonaws.com/<depends-on-form-content>。因此,您无法预先签名该URL,因为您不知道它是什么。

这与将文件PUT到已签名URL的情况不同。您可以签名该URL,因为您知道完整的URL:https://bucket.s3.amazonaws.com/known-key

您可以构建一个带有适当策略和参数的POST请求,并通过POST方式上传。但是,您需要知道表单的内容才能预先知道URL。在这种情况下,您可以直接使用预签名的PUT URL。

至少乍看之下是这样的...

英文:

At a glance it looks like POST works with an attached policy and signature -- designed for browser based uploads. See the AWS Docs for details.

Specifically, you need to generate a policy and sign that -- then include them in the HTML form, and thereby the POST request -- along with the rest of the required information. Or let the browser do it for you.

In the case of HTML form POST uploads you are only signing the policy string. The final URL to be posted to can vary based on the form contents: https://bucket.s3.amazonaws.com/<depends-on-form-content>. So you can't presign that URL because you don't know what it is.

This is different than a signed URL to which you PUT a file. You can sign that because you know the full URL: https://bucket.s3.amazonaws.com/known-key

You could build a POST request with the appropriate policy and parameters and upload via POST that way. However, you would need to know the contents of the form to know the URL beforehand. In which case you may as well use a presigned PUT URL.

At least that is how it appears at a glance...

答案4

得分: 1

在尝试使用@murrekatt提供的解决方案时,遇到了"InvalidAccessKeyId"错误。

后来我发现这个问题是因为我在lambda函数内部生成了预签名的POST请求,但没有在表单数据和策略中包含"x-amz-security-token"。

所以这是我根据@murrekatt和boto3库的帮助编写的代码:

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
)

type PresignedPOST struct {
	URL           string `json:"url"`
	Key           string `json:"key"`
	Policy        string `json:"policy"`
	Credential    string `json:"credential"`
	SecurityToken string `json:"securityToken,omitempty"`
	Signature     string `json:"signature"`
	Date          string `json:"date"`
}

func NewPresignedPost(input *NewPresignedPostInput) (*PresignedPOST, error) {

	// 过期时间
	expirationTime := time.Now().Add(time.Second * time.Duration(input.ExpiresIn)).UTC()
	dateString := expirationTime.Format("20060102")

	// 凭证字符串
	creds := fmt.Sprintf("%s/%s/%s/s3/aws4_request", input.Credentials.AccessKeyID, dateString, input.Region)

	// 策略
	policyDoc, err := createPolicyDocument(expirationTime, input.Bucket, input.Key, creds, &input.Credentials.SessionToken, input.Conditions)
	if err != nil {
		return nil, err
	}

	// 创建签名
	signature := createSignature(input.Credentials.SecretAccessKey, input.Region, dateString, policyDoc)

	// URL
	url := fmt.Sprintf("https://%s.s3.amazonaws.com/", input.Bucket)

	// 过期时间
	dateTimeString := expirationTime.Format("20060102T150405Z")

	// POST
	post := &PresignedPOST{
		Key:           input.Key,
		Policy:        policyDoc,
		Signature:     signature,
		URL:           url,
		Credential:    creds,
		SecurityToken: input.Credentials.SessionToken,
		Date:          dateTimeString,
	}

	return post, nil
}

type NewPresignedPostInput struct {
	// Key 名称
	Key string

	// 凭证
	Credentials aws.Credentials

	// 区域
	Region string

	// 预签名的 POST 请求的存储桶名称
	Bucket string

	// 过期时间 - 预签名的 POST 请求的有效时间(秒)
	ExpiresIn int64

	// 包含在策略中的条件列表。每个元素可以是列表或结构。
	// 例如:
	// [
	// 		{"acl": "public-read"}, ["content-length-range", 2, 5], ["starts-with", "$success_action_redirect", ""]
	// ]
	Conditions []interface{}
}

// 辅助函数
func createPolicyDocument(expirationTime time.Time, bucket string, key string, credentialString string, securityToken *string, extraConditions []interface{}) (string, error) {

	doc := map[string]interface{}{}
	doc["expiration"] = expirationTime.Format("2006-01-02T15:04:05.000Z")

	// 条件
	conditions := []interface{}{}
	conditions = append(conditions, map[string]string{
		"bucket": bucket,
	})

	conditions = append(conditions, []string{
		"starts-with", "$key", key,
	})

	conditions = append(conditions, map[string]string{
		"x-amz-credential": credentialString,
	})

	if securityToken != nil {
		conditions = append(conditions, map[string]string{
			"x-amz-security-token": *securityToken,
		})
	}

	conditions = append(conditions, map[string]string{
		"x-amz-algorithm": "AWS4-HMAC-SHA256",
	})

	conditions = append(conditions, map[string]string{
		"x-amz-date": expirationTime.Format("20060102T150405Z"),
	})

	// 其他条件
	conditions = append(conditions, extraConditions...)

	doc["conditions"] = conditions

	// base64 编码的 JSON 字符串
	jsonBytes, err := json.Marshal(doc)
	if err != nil {
		return "", err
	}

	return base64.StdEncoding.EncodeToString(jsonBytes), nil
}

func createSignature(secretKey string, region string, dateString string, stringToSign string) string {

	// 辅助函数,用于生成 HMAC-SHA256
	makeHmac := func(key []byte, data []byte) []byte {
		hash := hmac.New(sha256.New, key)
		hash.Write(data)
		return hash.Sum(nil)
	}

	h1 := makeHmac([]byte("AWS4"+secretKey), []byte(dateString))
	h2 := makeHmac(h1, []byte(region))
	h3 := makeHmac(h2, []byte("s3"))
	h4 := makeHmac(h3, []byte("aws4_request"))
	signature := makeHmac(h4, []byte(stringToSign))
	return hex.EncodeToString(signature)
}

用法

// 凭证
conf, _ := config.LoadDefaultConfig(c.Context)
awsCreds, _ := conf.Credentials.Retrieve(c.Context)

// 生成预签名的 POST 请求
post, err := s3util.NewPresignedPost(&s3util.NewPresignedPostInput{
	Key:         <file-name>,
	Credentials: awsCreds,
	Region:      <region>,
	Bucket:      <bucket-name>,
	ExpiresIn:   <expiration>,
	Conditions: []interface{}{
		[]interface{}{"content-length-range", 1, <size-limit>},
	},
})

然后在前端,使用返回的 JSON 数据作为 POST 表单数据

key:					<key>
X-Amz-Credential:		<credential>
X-Amz-Security-Token:	<securityToken> 	// 如果提供了
X-Amz-Algorithm:		AWS4-HMAC-SHA256
X-Amz-Date:				<date>
Policy:					<policy>
X-Amz-Signature:		<signature>
file: 					<file>
英文:

Came across this problem and faced the "InvalidAccessKeyId" error when attempting to use the solution provided by @murrekatt.

Later I found out this issue was because I was generating the presigned POST inside a lambda and not including x-amz-security-token in the form data & policy.

So here's what I wrote with the help from @murrekatt and the boto3 library:

import (
	&quot;crypto/hmac&quot;
	&quot;crypto/sha256&quot;
	&quot;encoding/base64&quot;
	&quot;encoding/hex&quot;
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;time&quot;

	&quot;github.com/aws/aws-sdk-go-v2/aws&quot;
)

type PresignedPOST struct {
	URL           string `json:&quot;url&quot;`
	Key           string `json:&quot;key&quot;`
	Policy        string `json:&quot;policy&quot;`
	Credential    string `json:&quot;credential&quot;`
	SecurityToken string `json:&quot;securityToken,omitempty&quot;`
	Signature     string `json:&quot;signature&quot;`
	Date          string `json:&quot;date&quot;`
}

func NewPresignedPost(input *NewPresignedPostInput) (*PresignedPOST, error) {

	// expiration time
	expirationTime := time.Now().Add(time.Second * time.Duration(input.ExpiresIn)).UTC()
	dateString := expirationTime.Format(&quot;20060102&quot;)

	// credentials string
	creds := fmt.Sprintf(&quot;%s/%s/%s/s3/aws4_request&quot;, input.Credentials.AccessKeyID, dateString, input.Region)

	// policy
	policyDoc, err := createPolicyDocument(expirationTime, input.Bucket, input.Key, creds, &amp;input.Credentials.SessionToken, input.Conditions)
	if err != nil {
		return nil, err
	}

	// create signature
	signature := createSignature(input.Credentials.SecretAccessKey, input.Region, dateString, policyDoc)

	// url
	url := fmt.Sprintf(&quot;https://%s.s3.amazonaws.com/&quot;, input.Bucket)

	// expiration time
	dateTimeString := expirationTime.Format(&quot;20060102T150405Z&quot;)

	// post
	post := &amp;PresignedPOST{
		Key:           input.Key,
		Policy:        policyDoc,
		Signature:     signature,
		URL:           url,
		Credential:    creds,
		SecurityToken: input.Credentials.SessionToken,
		Date:          dateTimeString,
	}

	return post, nil
}

type NewPresignedPostInput struct {
	// Key name
	Key string

	// Creds
	Credentials aws.Credentials

	// Region
	Region string

	// The name of the bucket to presign the post to
	Bucket string

	// Expiration -  The number of seconds the presigned post is valid for.
	ExpiresIn int64

	// A list of conditions to include in the policy. Each element can be either a list or a structure.
	// For example:
	// [
	// 		{&quot;acl&quot;: &quot;public-read&quot;}, [&quot;content-length-range&quot;, 2, 5], [&quot;starts-with&quot;, &quot;$success_action_redirect&quot;, &quot;&quot;]
	// ]
	Conditions []interface{}
}

// helpers
func createPolicyDocument(expirationTime time.Time, bucket string, key string, credentialString string, securityToken *string, extraConditions []interface{}) (string, error) {

	doc := map[string]interface{}{}
	doc[&quot;expiration&quot;] = expirationTime.Format(&quot;2006-01-02T15:04:05.000Z&quot;)

	// conditions
	conditions := []interface{}{}
	conditions = append(conditions, map[string]string{
		&quot;bucket&quot;: bucket,
	})

	conditions = append(conditions, []string{
		&quot;starts-with&quot;, &quot;$key&quot;, key,
	})

	conditions = append(conditions, map[string]string{
		&quot;x-amz-credential&quot;: credentialString,
	})

	if securityToken != nil {
		conditions = append(conditions, map[string]string{
			&quot;x-amz-security-token&quot;: *securityToken,
		})
	}

	conditions = append(conditions, map[string]string{
		&quot;x-amz-algorithm&quot;: &quot;AWS4-HMAC-SHA256&quot;,
	})

	conditions = append(conditions, map[string]string{
		&quot;x-amz-date&quot;: expirationTime.Format(&quot;20060102T150405Z&quot;),
	})

	// other conditions
	conditions = append(conditions, extraConditions...)

	doc[&quot;conditions&quot;] = conditions

	// base64 encoded json string
	jsonBytes, err := json.Marshal(doc)
	if err != nil {
		return &quot;&quot;, err
	}

	return base64.StdEncoding.EncodeToString(jsonBytes), nil
}

func createSignature(secretKey string, region string, dateString string, stringToSign string) string {

	// Helper to make the HMAC-SHA256.
	makeHmac := func(key []byte, data []byte) []byte {
		hash := hmac.New(sha256.New, key)
		hash.Write(data)
		return hash.Sum(nil)
	}

	h1 := makeHmac([]byte(&quot;AWS4&quot;+secretKey), []byte(dateString))
	h2 := makeHmac(h1, []byte(region))
	h3 := makeHmac(h2, []byte(&quot;s3&quot;))
	h4 := makeHmac(h3, []byte(&quot;aws4_request&quot;))
	signature := makeHmac(h4, []byte(stringToSign))
	return hex.EncodeToString(signature)
}

Usage

// credentials
conf, _ := config.LoadDefaultConfig(c.Context)
awsCreds, _ := conf.Credentials.Retrieve(c.Context)

// generate presigned post
post, err := s3util.NewPresignedPost(&amp;s3util.NewPresignedPostInput{
	Key:         &lt;file-name&gt;,
	Credentials: awsCreds,
	Region:      &lt;region&gt;,
	Bucket:      &lt;bucket-name&gt;,
	ExpiresIn:   &lt;expiration&gt;,
	Conditions: []interface{}{
		[]interface{}{&quot;content-length-range&quot;, 1, &lt;size-limit&gt;},
	},
})

Then on the frontend, use the returned json in a POST form data

key:					&lt;key&gt;
X-Amz-Credential:		&lt;credential&gt;
X-Amz-Security-Token:	&lt;securityToken&gt; 	// if provided
X-Amz-Algorithm:		AWS4-HMAC-SHA256
X-Amz-Date:				&lt;date&gt;
Policy:					&lt;policy&gt;
X-Amz-Signature:		&lt;signature&gt;
file: 					&lt;file&gt;

huangapple
  • 本文由 发表于 2015年9月3日 22:00:13
  • 转载请务必保留本文链接:https://go.coder-hub.com/32377782.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定