<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
  <channel>
    <title>Xiaohei Terminal</title>
    <link>https://xiaohei.moe/feed.xml</link>
    <description>Xiaohei's blog</description>
    <atom:link href="https://xiaohei.moe/feed.xml" rel="self"/>
    <docs>http://www.rssboard.org/rss-specification</docs>
    <generator>python-feedgen</generator>
    <lastBuildDate>Mon, 10 Mar 2025 07:37:09 +0000</lastBuildDate>
    <item>
      <title>使用开发平台 API 下载华为云空间文件历史版本</title>
      <link>https://xiaohei.moe/post/2023/03/13/huawei-drive-file-history/</link>
      <description><![CDATA[<p>近日有一个回退华为云空间文件版本的需求。虽然客户端上没有恢复版本文件的入口，但在华为开发者联盟上提供了查询文件历史版本的 <a href="https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/server-quaring-history-version-0000001064501116">API</a> ，故尝试使用该 API 获取文件的历史版本。</p>
<h2>准备工作</h2>
<p>注册华为开发者联盟账号后，需要完成实名认证，并在管理后台创建一个 AppGallery Connect 项目：</p>
<p><img alt="项目创建" src="https://s2.loli.net/2023/03/13/qQvK7R4ftu9jCXU.png" /></p>
<p>在该项目下创建一个应用，只需填写第一步的信息：</p>
<p><img alt="应用创建" src="https://s2.loli.net/2023/03/13/zb6KhOv2T3PcIBp.png" /></p>
<p>创建应用后回到项目页面，在 <code>API管理</code> 标签下打开 <code>云空间</code> 选项：</p>
<p><img alt="云空间权限使能" src="https://s2.loli.net/2023/03/13/IwJjXcuExHF4p7a.png" /></p>
<p>在应用页面 <code>常规</code> 标签下找到 <code>应用</code> 栏，添加回调地址，可以使用一个不存在的网站：</p>
<p><img alt="获取应用信息" src="https://s2.loli.net/2023/03/13/aTjbAuXpvMwDQfN.png" /></p>
<p>记录下 <code>Client ID</code> 和 <code>Client Secret</code>。</p>
<h2>获取 Access token</h2>
<p>此部分参考 <a href="https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/open-platform-oauth-0000001053629189">基于 OAuth 2.0 开放鉴权</a> 中的 <code>授权码扩展模式（PKCE）</code> 部分。</p>
<p>访问：</p>
<p><a href="https://oauth-login.cloud.huawei.com/oauth2/v3/authorize?response_type=code&amp;code_challenge=ovoy4lehgHbv8uNmif_hak3bH2_Ylk6_fWP0UL232QQ&amp;code_challenge_method=plain&amp;client_id={{client_id}}&amp;redirect_uri={{redirect_uri}}&amp;scope=openid+https://www.huawei.com/auth/drive">https://oauth-login.cloud.huawei.com/oauth2/v3/authorize?response_type=code&amp;code_challenge=ovoy4lehgHbv8uNmif_hak3bH2_Ylk6_fWP0UL232QQ&amp;code_challenge_method=plain&amp;client_id={{client_id}}&amp;redirect_uri={{redirect_uri}}&amp;scope=openid+https://www.huawei.com/auth/drive</a></p>
<p>将 <code>client_id</code> 替换为准备工作中获取的 <code>Client ID</code>，<code>redirect_uri</code> 替换为准备工作中填写的回调地址。</p>
<p>访问后回跳转到回调地址，参数为 <code>?code=...</code>，记录 <code>code</code> 的内容，作为授权码 Code。</p>
<p>然后需要通过此授权码 Code 换取鉴权令牌：</p>
<pre class="highlight"><code class="language-http">### Get access token by authorization code
POST https://oauth-login.cloud.huawei.com/oauth2/v3/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&amp;
code={{code}}&amp;
client_id={{client_id}}&amp;
client_secret={{client_secret}}&amp;
code_verifier=ovoy4lehgHbv8uNmif_hak3bH2_Ylk6_fWP0UL232QQ&amp;
redirect_uri={{redirect_uri}}
</code></pre>

<p>填写 <code>code</code> <code>client_id</code> <code>client_secret</code> <code>redirect_uri</code>，注意 <code>code</code> 需要进行 URL 编码。发送请求，获得的结果如下：</p>
<pre class="highlight"><code class="language-json">{
  &quot;scope&quot;: &quot;https://www.huawei.com/auth/drive openid&quot;,
  &quot;access_token&quot;: &quot;DA************&quot;,
  &quot;token_type&quot;: &quot;Bearer&quot;,
  &quot;expires_in&quot;: 3600,
  &quot;id_token&quot;: &quot;...&quot;
}
</code></pre>

<p>记录 <code>access_token</code> 内容，在后续步骤中验证使用。</p>
<h2>恢复文件</h2>
<p>此部分参考 <a href="https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/server-quaring-history-version-0000001064501116">查询文件历史版本</a> 。</p>
<p>首先需要获取文件 <code>id</code>，如果文件较少可直接获取全部文件列表：</p>
<pre class="highlight"><code class="language-http">### Get file list
GET https://driveapis.cloud.huawei.com.cn/drive/v1/files?fields=* HTTP/1.1
Accept: application/json
Cache-Control: no-cache
Authorization: Bearer {{access_token}}
</code></pre>

<p>如果文件较多可以参考 <a href="https://developer.huawei.com/consumer/cn/doc/development/HMSCore-References/server-public-info-0000001050159641">文档</a> 中 <code>mimeType</code> 的介绍进行搜索与排序，下面是一个按编辑日期倒序排序 <code>.docx</code> 文件的示例：</p>
<pre class="highlight"><code class="language-http">### Get `.docx` file list ordered by editedTime desc
GET https://driveapis.cloud.huawei.com.cn/drive/v1/files?fields=*&amp;queryParam=mimeType%3D%27application%2Fvnd.openxmlformats-officedocument.wordprocessingml.document%27&amp;orderBy=editedTime%20desc HTTP/1.1
Content-Type: application/json
Authorization: Bearer {{access_token}}
Cache-Control: no-cache
Accept: application/json
</code></pre>

<p>测试时该接口经常提示无权限，但多次访问后又会正常返回，如果配置信息正确仍提示 <code>INSUFFICIENT_SCOPE</code> 就多试几次。</p>
<p>返回结果格式如下：</p>
<pre class="highlight"><code class="language-json">{
  &quot;files&quot;: [
    {
      &quot;fileName&quot;: &quot;测试.doc&quot;,
      &quot;sha256&quot;: &quot;30e0ee3fc2ac07ca2e2fedfa4aad5a293c13a28268f4843f354f2675f78f991d&quot;,
      &quot;fileSuffix&quot;: &quot;doc&quot;,
      &quot;mimeType&quot;: &quot;application/octet-stream&quot;,
      &quot;lastHistoryVersionId&quot;: &quot;1110774401038742656.1110774800504128256&quot;,
      &quot;editedByMeTime&quot;: &quot;2023-03-13T05:51:14.000Z&quot;,
      &quot;createdTime&quot;: &quot;2023-03-13T05:50:27.166Z&quot;,
      &quot;id&quot;: &quot;BoAY1s_TPZKYqq3HJGUtObq9sd5VZTUUm&quot;,
      &quot;version&quot;: 5,
      &quot;iconDownloadLink&quot;: &quot;https://event.dbankcdn.com/filemanagerpic/20191114101425c162.png&quot;,
      &quot;editedTime&quot;: &quot;2023-03-13T05:51:14.000Z&quot;,
      &quot;size&quot;: 38912,
      &quot;fullFileSuffix&quot;: &quot;doc&quot;,
      &quot;category&quot;: &quot;drive#file&quot;
    },
    {
      // ...
    }
  ],
  &quot;category&quot;: &quot;drive#fileList&quot;
}
</code></pre>

<p>找到对应的文件并记录其 <code>id</code>，然后获取其历史记录：</p>
<pre class="highlight"><code class="language-http">### Get file history
GET https://driveapis.cloud.huawei.com.cn/drive/v1/files/{{id}}/historyVersions?fields=* HTTP/1.1
Authorization: Bearer {{access_token}}
Cache-Control: no-cache
Accept: application/json
</code></pre>

<p>返回结果如下：</p>
<pre class="highlight"><code class="language-json">{
  &quot;historyVersions&quot;: [
    {
      &quot;editedTime&quot;: &quot;2023-03-13T05:51:15.063Z&quot;,
      &quot;size&quot;: 38912,
      &quot;sha256&quot;: &quot;ce6376d16144b5c36da0414a4666a33bb15624f8b5c0553dad1ae456c64510ac&quot;,
      &quot;id&quot;: &quot;1110774401038742656.1110774800504128256&quot;,
      &quot;mimeType&quot;: &quot;application/octet-stream&quot;,
      &quot;category&quot;: &quot;drive#historyVersion&quot;,
      &quot;originalFilename&quot;: &quot;nonamea696f86cb6e045d19c696396636595b5&quot;
    },
    {
      // ...
    },
    {
      // ...
    }
  ],
  &quot;category&quot;: &quot;drive#historyVersionList&quot;
}
</code></pre>

<p>根据编辑时间找到需要的版本（UTC 时间），记录下历史文件的 <code>id</code>，由于和文件 <code>id</code> 重名，在下面表示为 <code>history_id</code>。直接下载对应历史版本文件：</p>
<pre class="highlight"><code class="language-http">### Get file
GET https://driveapis.cloud.huawei.com.cn/drive/v1/files/{{id}}/historyVersions/{{history_id}}?form=content HTTP/1.1
Authorization: Bearer {{access_token}}
Cache-Control: no-cache
Accept: application/json
</code></pre>]]></description>
      <guid isPermaLink="false">https://xiaohei.moe/post/2023/03/13/huawei-drive-file-history/</guid>
      <pubDate>Mon, 13 Mar 2023 13:44:00 +0806</pubDate>
    </item>
    <item>
      <title>不同高级语言的 URL 编码差异</title>
      <link>https://xiaohei.moe/post/2023/08/07/url-encode-differentiation/</link>
      <description><![CDATA[<p>水群时看到有群友遇到了因 URL 对字符 <code>*</code> 的编码不符合预期问题导致的程序错误，便做此篇测试部分高级语言的 URL 编码实现有何不同。</p>
<h2>相关标准</h2>
<p>由于 <a href="https://datatracker.ietf.org/doc/html/rfc1738">RFC 1738: Uniform Resource Locators (URL)</a> 并非互联网标准 (Internet Standard)，故本文参考互联网标准 <a href="https://datatracker.ietf.org/doc/html/rfc3986">RFC 3986: Uniform Resource Identifier (URI): Generic Syntax</a> 编写。该标准推荐使用通用术语 "URI"，而不是限制性更强的术语 "URL" 和 "URN" <a href="https://datatracker.ietf.org/doc/html/rfc3305">(RFC3305)</a>。</p>
<p>RFC 3986 对 URI 中非保留字符的定义如下：</p>
<pre class="highlight"><code class="language-text">unreserved  = ALPHA / DIGIT / &quot;-&quot; / &quot;.&quot; / &quot;_&quot; / &quot;~&quot;
</code></pre>

<p>在 URI 编码时，对于非保留字符 <code>unreserved</code> 应保持不进行转义，但是该标准同样说明了如果遇到了转义了这些字符的 URI 编码，在解码时仍需要将其恢复为原字符。</p>
<pre class="highlight"><code class="language-text">URIs that differ in the replacement of an unreserved character with
its corresponding percent-encoded US-ASCII octet are equivalent: they
identify the same resource.  However, URI comparison implementations
do not always perform normalization prior to comparison (see Section
6).  For consistency, percent-encoded octets in the ranges of ALPHA
(%41-%5A and %61-%7A), DIGIT (%30-%39), hyphen (%2D), period (%2E),
underscore (%5F), or tilde (%7E) should not be created by URI
producers and, when found in a URI, should be decoded to their
corresponding unreserved characters by URI normalizers.
</code></pre>

<p>该标准中同样指出了 <code>~</code> 字符在旧的 URI 编码实现中经常转义为 <code>%7E</code>。</p>
<pre class="highlight"><code class="language-text">For example, the octet
corresponding to the tilde (&quot;~&quot;) character is often encoded as &quot;%7E&quot;
by older URI processing implementations; the &quot;%7E&quot; can be replaced by
&quot;~&quot; without changing its interpretation.
</code></pre>

<p>对于可能需要转义的保留字符，该标准将其分为两类：</p>
<pre class="highlight"><code class="language-text">reserved    = gen-delims / sub-delims
gen-delims  = &quot;:&quot; / &quot;/&quot; / &quot;?&quot; / &quot;#&quot; / &quot;[&quot; / &quot;]&quot; / &quot;@&quot;
sub-delims  = &quot;!&quot; / &quot;$&quot; / &quot;&amp;&quot; / &quot;'&quot; / &quot;(&quot; / &quot;)&quot;
            / &quot;*&quot; / &quot;+&quot; / &quot;,&quot; / &quot;;&quot; / &quot;=&quot;
</code></pre>

<p>其中 <code>gen-delims</code> 和 URI 的结构相关，必须要进行转义，而 <code>sub-delims</code> 是否需要需要根据所在位置判断。特别地，由于转义使用 <code>%</code> 符号，所以 <code>%</code> 符号自身也需要进行转义。</p>
<p>典型的 URI 组成部分如下：</p>
<pre class="highlight"><code class="language-text">      foo://example.com:8042/over/there?name=ferret#nose
      \_/   \______________/\_________/ \_________/ \__/
       |           |            |            |        |
    scheme     authority       path        query   fragment
       |   _____________________|__
      / \ /                        \
      urn:example:animal:ferret:nose
</code></pre>

<p>与 <code>sub-delims</code> 相关的文法片段如下：</p>
<pre class="highlight"><code class="language-text">authority     = [ userinfo &quot;@&quot; ] host [ &quot;:&quot; port ]
userinfo      = *( unreserved / pct-encoded / sub-delims / &quot;:&quot; )

host          = IP-literal / IPv4address / reg-name
IP-literal    = &quot;[&quot; ( IPv6address / IPvFuture  ) &quot;]&quot;
IPvFuture     = &quot;v&quot; 1*HEXDIG &quot;.&quot; 1*( unreserved / sub-delims / &quot;:&quot; )
reg-name      = *( unreserved / pct-encoded / sub-delims )

path          = path-abempty    ; begins with &quot;/&quot; or is empty
              / path-absolute   ; begins with &quot;/&quot; but not &quot;//&quot;
              / path-noscheme   ; begins with a non-colon segment
              / path-rootless   ; begins with a segment
              / path-empty      ; zero characters
path-abempty  = *( &quot;/&quot; segment )
path-absolute = &quot;/&quot; [ segment-nz *( &quot;/&quot; segment ) ]
path-noscheme = segment-nz-nc *( &quot;/&quot; segment )
path-rootless = segment-nz *( &quot;/&quot; segment )
path-empty    = 0&lt;pchar&gt;
segment       = *pchar
segment-nz    = 1*pchar
segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / &quot;@&quot; )
              ; non-zero-length segment without any colon &quot;:&quot;
pchar         = unreserved / pct-encoded / sub-delims / &quot;:&quot; / &quot;@&quot;

query         = *( pchar / &quot;/&quot; / &quot;?&quot; )

fragment      = *( pchar / &quot;/&quot; / &quot;?&quot; )
</code></pre>

<p>如果按照以上文法推导，<code>sub-delims</code> 中的字符在 <code>authority</code> <code>path</code> <code>query</code> <code>fragment</code> 中均可能保持原样。</p>
<p>另外，空格字符在 <code>application/x-www-form-urlencoded</code> 类型中编码为 <code>+</code>，而在 RFC 3986 中的编码为 <code>%20</code>。</p>
<p>为找出不同高级语言对这些字符转义处理的差别，下面进行了一个简单的测试，先给出了测试结果，具体的测试代码及输出在最后给出。</p>
<h2>测试结果</h2>
<p>仅测试了在 <code>query</code> 段中的编码和解码情况，在所有编码测试中，以 <code>sub-delims</code> 中的字符均已编码，<code>unreserved</code> 中的特殊字符均未编码为参考结果，标注与参考结果有差别的字符表，另外单列了对空格的转义情况。解码测试使用全部特殊字符转义的字符串，由于解码结果均相同，不额外展示在表格中。</p>
<table>
<thead>
<tr>
<th>语言</th>
<th>Module / Function</th>
<th style="text-align: center;"><code>sub-delims</code><br/> 未被转义</th>
<th style="text-align: center;"><code>unreserved</code> <br/>被转义</th>
<th style="text-align: center;">SP 编码</th>
<th style="text-align: center;"><code>+</code> 解码</th>
</tr>
</thead>
<tbody>
<tr>
<td>Python 3</td>
<td><code>urllib.parse</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;">需使用 <code>unquote_plus</code></td>
</tr>
<tr>
<td>Go</td>
<td><code>net/url</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>Java</td>
<td><code>java.net.URLEncoder</code> <br/> <code>java.net.URLDecoder</code></td>
<td style="text-align: center;"><code>*</code></td>
<td style="text-align: center;"><code>~</code></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>JavaScript</td>
<td><code>URLSearchParams</code></td>
<td style="text-align: center;"><code>*</code></td>
<td style="text-align: center;"><code>~</code></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>JavaScript</td>
<td><code>encodeURIComponent</code><br/> <code>decodeURIComponent</code></td>
<td style="text-align: center;"><code>*</code></td>
<td style="text-align: center;"><code>~</code></td>
<td style="text-align: center;"><code>%20</code></td>
<td style="text-align: center;">无法解码 <code>+</code></td>
</tr>
<tr>
<td>Node.js</td>
<td><code>querystring</code></td>
<td style="text-align: center;"><code>!'()*</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>%20</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>C#</td>
<td><code>System.Net.WebUtility</code></td>
<td style="text-align: center;"><code>!()*</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>PHP</td>
<td><code>urlencode</code> <br/> <code>urldecode</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>~</code></td>
<td style="text-align: center;"><code>+</code></td>
<td style="text-align: center;"></td>
</tr>
<tr>
<td>PHP</td>
<td><code>rawurlencode</code><br/> <code>rawurldecode</code></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"></td>
<td style="text-align: center;"><code>%20</code></td>
<td style="text-align: center;">无法解码 <code>+</code></td>
</tr>
</tbody>
</table>
<p>虽然编码时对符号的转义处理不同，但是使用全部转义的 <code>sub-delims</code> 以及 <code>unreserved</code> 中的特殊字符进行测试时被测程序都能正确进行解码。</p>
<h2>测试代码</h2>
<p>Python 3:</p>
<pre class="highlight"><code class="language-python">from urllib.parse import urlencode, unquote, unquote_plus

print(urlencode({&quot;param&quot;:&quot; !$&amp;'()*+,;=-._~&quot;}))
print(unquote(&quot;param=a+b&quot;))
print(unquote_plus(&quot;param=a+b&quot;))
</code></pre>

<pre class="highlight"><code class="language-text">param=+%21%24%26%27%28%29%2A%2B%2C%3B%3D-._~
param=a+b
param=a b
</code></pre>

<p>Go:</p>
<pre class="highlight"><code class="language-go">package main

import (
    &quot;fmt&quot;
    &quot;net/url&quot;
)

func main() {
    fmt.Println(url.QueryEscape(&quot; !$&amp;'()*+,;=-._~&quot;))
    fmt.Println(url.QueryUnescape(&quot;a+b&quot;))
}
</code></pre>

<pre class="highlight"><code class="language-text">+%21%24%26%27%28%29%2A%2B%2C%3B%3D-._~
a b &lt;nil&gt;
</code></pre>

<p>Java:</p>
<pre class="highlight"><code class="language-java">import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

public class Main {
    public static void main(String[] args) throws UnsupportedEncodingException {
        System.out.println(URLEncoder.encode(&quot; !$&amp;'()*+,;=-._~&quot;, StandardCharsets.UTF_8.toString()));
        System.out.println(URLDecoder.decode(&quot;a+b&quot;, StandardCharsets.UTF_8.toString()));
    }
}
</code></pre>

<pre class="highlight"><code class="language-text">+%21%24%26%27%28%29*%2B%2C%3B%3D-._%7E
a b
</code></pre>

<p>JavaScript:</p>
<pre class="highlight"><code class="language-js">const encode = new URLSearchParams();
encode.set(&quot;param&quot;, &quot; !$&amp;'()*+,;=-._~&quot;);
console.log(encode.toString());
const decode = new URLSearchParams(&quot;param=a+b&quot;);
console.log(decode.get(&quot;param&quot;));
console.log(encodeURIComponent(&quot; !$&amp;'()*+,;=-._~&quot;));
console.log(decodeURIComponent(&quot;a+b&quot;));
</code></pre>

<pre class="highlight"><code class="language-text">param=+%21%24%26%27%28%29*%2B%2C%3B%3D-._%7E
a b
%20!%24%26'()*%2B%2C%3B%3D-._~
a+b
</code></pre>

<p>Node.js:</p>
<pre class="highlight"><code class="language-js">const querystring = require(&quot;querystring&quot;);
console.log(querystring.stringify({ param: &quot; !$&amp;'()*+,;=-._~&quot; }));
console.log(querystring.parse(&quot;param=a+b&quot;).param);
</code></pre>

<pre class="highlight"><code class="language-text">param=%20!%24%26'()*%2B%2C%3B%3D-._~
a b
</code></pre>

<p>C#:</p>
<pre class="highlight"><code class="language-cs">using System;

class Program
{
    static void Main()
    {
        Console.WriteLine(System.Net.WebUtility.UrlEncode(&quot; !$&amp;'()*+,;=-._~&quot;));
        Console.WriteLine(System.Net.WebUtility.UrlDecode(&quot;a+b&quot;));
    }
}
</code></pre>

<pre class="highlight"><code class="language-text">+!%24%26%27()*%2B%2C%3B%3D-._%7E
a b
</code></pre>

<p>PHP:</p>
<pre class="highlight"><code class="language-php">&lt;?php
echo urlencode(&quot; !$&amp;'()*+,;=-._~&quot;) . &quot;\n&quot;;
echo urldecode(&quot;a+b&quot;) . &quot;\n&quot;;
echo rawurlencode(&quot; !$&amp;'()*+,;=-._~&quot;) . &quot;\n&quot;;
echo rawurldecode(&quot;a+b&quot;) . &quot;\n&quot;;
?&gt;
</code></pre>

<pre class="highlight"><code class="language-text">+%21%24%26%27%28%29%2A%2B%2C%3B%3D-._%7E
a b
%20%21%24%26%27%28%29%2A%2B%2C%3B%3D-._~
a+b
</code></pre>]]></description>
      <guid isPermaLink="false">https://xiaohei.moe/post/2023/08/07/url-encode-differentiation/</guid>
      <pubDate>Mon, 07 Aug 2023 20:00:00 +0806</pubDate>
    </item>
    <item>
      <title>NoneBot QQ 机器人开发指南</title>
      <link>https://xiaohei.moe/post/2023/10/21/nonebot-qq-bot-development/</link>
      <description><![CDATA[<p><a href="https://q.qq.com/#/">QQ 开放平台</a> 近期将把之前已经用于频道的 <a href="https://bot.q.qq.com/wiki">QQ 机器人</a> 扩展到群聊和私聊场景。虽然功能限制较多，但由于其是官方 API，稳定性会比较好。本篇文章为使用 NoneBot 开发 QQ 机器人的教程，阅读本教程需要有一定 Python 开发基础。需要注意，本教程仅为相关文档的补充，请仔细阅读文中引用的文档。</p>
<h2>前期准备</h2>
<h3>了解机器人能力</h3>
<p>QQ 机器人应用于频道和群聊两个主要场景，可以在单聊、群聊、文字子频道、频道私信使用，提供的频道机器人能力可以参考 <a href="https://bot.q.qq.com/wiki/develop/api/">API 文档</a>；群聊机器人能力暂未公开发布，可先参考 <a href="https://docs.qq.com/doc/DRkVHT1N2a1JYSnVr">QQ Bot 开发者文档【内测版】</a>。</p>
<p>需要注意的限制有：</p>
<ul>
<li>发送消息文本中的链接需要经过 ICP 备案，并在 QQ 机器人管理端进行根目录文件验证绑定。其他含有格式为 <code>英文.英文</code> 的文本也无法发送，如 <code>bot.self_id</code>，可以考虑将 <code>.</code> 替换为其他符号。</li>
<li>无法在群聊和文字子频道接收图片和视频。</li>
<li>无法在文字子频道和频道私信收发语音和文件，无法在群聊接收语音和文件。</li>
<li>所有群管理能力暂不对外开放。</li>
</ul>
<h3>创建沙箱频道</h3>
<p>当前 QQ 机器人需要在沙箱频道中进行测试，请确保所使用的开发者 QQ 账号拥有 QQ 频道内测权限，并创建一个频道供机器人测试使用，并确保该频道人数小于 20 人。</p>
<h3>私域机器人</h3>
<p>公域 QQ 机器人只能响应 @机器人 后发送的消息，而频道私域机器人可以响应全量的文字子频道消息，并有额外的频道管理能力（群机器人目前只有公域）。如果想要创建不需要 @机器人 的频道私域机器人，请确保使用频道主的账号进行 QQ 开放平台的注册，并在创建机器人时选择类型为“私域”。</p>
<p>频道私域机器人额外能力：获取频道成员列表、删除指定频道成员、创建子频道、修改子频道信息、删除指定子频道、可以接收频道内发送的所有消息事件。</p>
<h2>QQ 机器人介绍与开通</h2>
<p>想要接入 QQ 机器人，首先需要在 QQ 开放平台进行注册，具体流程请查看 QQ 机器人文档的 <a href="https://bot.q.qq.com/wiki/#%E6%8E%A5%E5%85%A5%E6%B5%81%E7%A8%8B">接入流程</a> 部分，可以使用企业或个人主体入驻（文档中第 2 步或第 3 步）。</p>
<p>完成开发者账号创建后，登录 <a href="https://q.qq.com/#/app/bot">QQ 开放平台</a> 参考 接入流程 文档的第 4 步创建应用，在 “应用管理” → “机器人” 标签下点击 “创建机器人” 按钮。</p>
<p>成功创建机器人后在 <a href="https://q.qq.com/bot/#/developer/developer-setting">开发设置</a> 界面获取机器人的 BotAppID、机器人令牌、机器人密钥。</p>
<h2>NoneBot 项目创建与配置</h2>
<p>本文中的 NoneBot 均指 NoneBot2。NoneBot2 是一个可扩展的 Python 异步机器人框架，它会对机器人收到的事件进行解析和处理，并以插件化的形式，按优先级分发给事件所对应的事件响应器，来完成具体的功能。</p>
<h3>测试项目创建</h3>
<p>如果未使用过 NoneBot，建议首先创建一个测试用的 Bot 工程以熟悉项目创建流程。</p>
<p>参考 NoneBot 文档的 <a href="https://nonebot.dev/docs/quick-start">快速上手</a> 章节创建一个基于终端的交互式机器人实例，并测试是否正常工作。</p>
<h3>项目创建与配置</h3>
<p>上述测试成功后便可以创建开发 QQ 机器人使用的工程，命令行跳转到放置工程的目录，按照上述 快速上手 章节中 创建项目 部分的操作步骤，项目模板 选择 <code>simple（插件开发者）</code>，并按下面的内容选择驱动器和适配器：</p>
<pre class="highlight"><code>[?] 要使用哪些驱动器? HTTPX (HTTPX 驱动器), websockets (websockets 驱动器)
[?] 要使用哪些适配器? QQ (QQ 官方机器人)
</code></pre>

<p>完成项目创建后，参考 <a href="https://github.com/nonebot/adapter-qq">QQ 适配器文档</a> 进行 QQ 适配器配置，打开项目目录下的 <code>.env</code> 文件（如果没有这个文件，请在创建时选择 <code>simple</code> 模板），添加以下内容：</p>
<pre class="highlight"><code>QQ_IS_SANDBOX=true
QQ_BOTS='
[
  {
    &quot;id&quot;: &quot;xxx&quot;,
    &quot;token&quot;: &quot;xxx&quot;,
    &quot;secret&quot;: &quot;xxx&quot;,
    &quot;intent&quot;: {
      &quot;guild_messages&quot;: true,
      &quot;at_messages&quot;: false
    }
  }
]
'
</code></pre>

<p>将 <code>id</code> <code>token</code> <code>secret</code> 分别替换为在 QQ 开放平台获取到的 BotAppID、机器人令牌、机器人密钥。</p>
<p>以上配置为频道私域机器人，接收全量消息无需 @机器人，如果使用频道公域机器人，将 <code>guild_messages</code> 值改为 <code>false</code>，<code>at_messages</code> 值改为 <code>false</code>，或删去这两个配置项使用默认值。</p>
<p>完成以上配置后，运行机器人项目，在沙箱频道中 @机器人 并输入 <code>/echo hello world</code> 测试，如果收到回复则配置成功（此处需要 @机器人 是因为 <code>echo</code> 插件只接受与我相关的消息，文字子频道中需要 @机器人 触发）。</p>
<h2>插件开发</h2>
<h3>插件编写基础</h3>
<p>参考 NoneBot 文档中的 <a href="https://nonebot.dev/docs/tutorial/create-plugin">插件编写准备</a>、<a href="https://nonebot.dev/docs/tutorial/matcher">事件响应器</a>、<a href="https://nonebot.dev/docs/tutorial/handler">事件处理</a>、<a href="https://nonebot.dev/docs/tutorial/event-data">获取事件信息</a> 四个部分进行示例插件的创建。</p>
<p>按照文档创建的插件是可以提供给任何适配器使用的，所以也适用于 QQ 适配器。这种方式的局限性在于无法适用适配器提供的特殊消息类型，而只能发送纯文本，要实现发送图片之类的功能，则需要根据所使用的适配器对发送的消息进行处理。</p>
<h3>QQ 适配器消息处理</h3>
<p>参考 NoneBot 文档中的 <a href="https://nonebot.dev/docs/tutorial/message">处理消息</a> 部分，文档中是以 <code>Console</code> 适配器作为示例，与 QQ 适配器有部分不同，QQ 适配器中提供了以下消息段：</p>
<ul>
<li><code>MessageSegment.text("abc")</code>: 文本消息段。</li>
<li><code>MessageSegment.emoji("4")</code>: QQ 表情，ID 参考 QQ 机器人文档的 <a href="https://bot.q.qq.com/wiki/develop/api/openapi/emoji/model.html">表情对象</a> 部分。</li>
<li><code>MessageSegment.mention_user("12345")</code>: 提及 @用户。</li>
<li><code>MessageSegment.mention_channel("123")</code>: 提及 #子频道。</li>
<li><code>MessageSegment.mention_everyone()</code>: 提及 @所有人。</li>
<li><code>MessageSegment.image("http://example.com/image.png")</code>: 网络图片，需要后台绑定域名。</li>
<li><code>MessageSegment.MessageSegment.file_image(image)</code>: 本地图片，可以传入 <code>bytes</code> / <code>io.BytesIO</code> / <code>pathlib.Path</code>。</li>
<li><code>MessageSegment.ark(ark)</code>: Ark 消息，私域被动消息有 Ark 权限，参考 QQ 机器人文档的 <a href="https://bot.q.qq.com/wiki/develop/api/openapi/message/post_ark_messages.html">发送 ARK 模板消息</a> 部分。</li>
<li><code>MessageSegment.embed(embed)</code>: Embed 消息，<a href="https://bot.q.qq.com/wiki/develop/api/openapi/message/template/embed_message.html">文档</a>。</li>
<li><code>MessageSegment.markdown(markdown)</code>: Markdown 模板消息和 Markdown 消息，需要内邀开通，<a href="https://bot.q.qq.com/wiki/develop/api/openapi/message/post_markdown_messages.html">文档</a>。</li>
<li><code>MessageSegment.keyboard(keyboard)</code>: Markdown 消息的按钮列表，需要内邀开通，<a href="https://bot.q.qq.com/wiki/develop/api/openapi/message/message_keyboard.html">文档</a>。</li>
</ul>
<p>使用以上消息段进行消息拼接，便可以使用 QQ 适配器的特有消息类型进行消息发送和回复。以 NoneBot 文档中的插件示例为例，可以修改为：</p>
<pre class="highlight"><code class="language-python">from nonebot import on_command
from nonebot.rule import to_me
from nonebot.adapters import Message
from nonebot.params import CommandArg

# 文件系统路径 Python 标准库
# 用于创建本地文件路径
from pathlib import Path

# 从 QQ 适配器导入消息段
from nonebot.adapters.qq import MessageSegment

weather = on_command(
    &quot;天气&quot;, rule=to_me(), aliases={&quot;weather&quot;, &quot;查天气&quot;}, priority=10, block=True
)

@weather.handle()
async def handle_function(args: Message = CommandArg()):
    # 提取参数纯文本作为地名，并判断是否有效
    if location := args.extract_plain_text():
        image = Path(&quot;data/image.png&quot;)
        messaege = f&quot;今天{location}的天气是...&quot; + MessageSegment.file_image(image)
        await weather.finish(messaege)
    else:
        messaege = MessageSegment.emoji(&quot;123&quot;) + &quot;请输入地名&quot;
        await weather.finish(messaege)
</code></pre>

<h3>QQ 适配器 API 调用</h3>
<p>QQ 适配器提供了 API 的封装，可以直接使用 Bot 实例进行调用，QQ 适配器中提供的 API 可在 <a href="https://bot.q.qq.com/wiki/develop/api/">API 文档</a> 中查看，推荐使用输入关键词加上自动补全（如使用 Visual Studio Code 的 Pylance 扩展）来快速找到对应的 API 封装名称和参数列表，示例如下：</p>
<pre class="highlight"><code class="language-python">from nonebot import on_command
from nonebot.adapters.qq import Bot, MessageCreateEvent

test = on_command(&quot;test&quot;)

@test.handle()
async def handle_function(bot: Bot, event: MessageCreateEvent):
    guild = await bot.get_guild(guild_id=event.guild_id)
    member = await bot.get_member(guild_id=guild.id, user_id=event.get_user_id())
    await test.finish(member.json())
</code></pre>

<p>更加深入的开发请参考 <a href="https://nonebot.dev/docs">NoneBot 文档</a>。</p>]]></description>
      <guid isPermaLink="false">https://xiaohei.moe/post/2023/10/21/nonebot-qq-bot-development/</guid>
      <pubDate>Sat, 21 Oct 2023 17:15:00 +0806</pubDate>
    </item>
    <item>
      <title>香港银行账户开立</title>
      <link>https://xiaohei.moe/post/2024/11/18/hongkong-bank-card-application/</link>
      <description><![CDATA[<!-- markdownlint-disable MD024 MD033 -->

<p>即使不需要在海外居住，海外的银行账户也经常会被需要。国际会议注册、海外旅行、海外网购等都可能需要使用海外银行账户。虽然中国内地的银行也有发行 Visa/Mastercard 等借记卡或信用卡，但是可能会被网站拒付，而使用海外银行账户的扣账卡则可以避免这个问题。</p>
<p>随着近日新加坡华侨银行 OCBC 对持中国大陆护照的开户申请索要额外的证明材料，线上开立海外银行账户的难度进一步提高。但是如果能够前往香港，开立香港银行账户仍然是一个门槛相对较低的选择。本文将介绍如何在香港开立银行账户、申请扣账卡，以及从内地汇款至香港银行账户。</p>
<h2>概述</h2>
<p>不同于内地银行的一张银行卡对应一个或多个币种账户，香港银行的账户与卡是分开的。开立账户后，扣账卡可能需要额外申请。存款时使用的是银行账户，消费时可以使用提款卡。</p>
<p>本文推荐的开户银行是中银香港 BOCHK 以及众安银行 ZA Bank。这两家银行都可以在香港境内线上开户，BOCHK 可以与内地中银同名无手续费互转，ZA Bank 可以提供 Visa 扣账卡，且可以绑定 BOCHK 账户进行充值，或使用 FPS 从 BOCHK 转账到 ZA Bank。</p>
<p>这样在支持 Visa 但不支持银联或支付宝/微信的网站上，就可以通过以下方式将内地中银卡中的资金转入到 Visa 卡中，再进行消费：</p>
<p>内地中银卡 -&gt; <em>购汇港币或美元</em> -&gt; <em>汇款</em> -&gt; 中银香港账户 -&gt; <em>FPS</em> -&gt; ZA Bank 账户</p>
<p>其中汇款一般在工作日的会在当天处理完毕，提交较晚的会在第二天处理，其余步骤均为实时操作。</p>
<p>ZA Bank 是数字银行，所有操作都可以在手机 App 上完成。而 BOCHK 既能在线上开户，也可以预约线下开户，主要区别如下：</p>
<table>
<thead>
<tr>
<th></th>
<th>账户类型</th>
<th>扣账卡</th>
<th>扣账卡类型</th>
<th>额外材料</th>
</tr>
</thead>
<tbody>
<tr>
<td>线上开户</td>
<td>自在理财</td>
<td>邮寄</td>
<td>银联</td>
<td></td>
</tr>
<tr>
<td>线下开户</td>
<td>智盈理财</td>
<td>部分网点现场发卡</td>
<td>银联<br>Mastercard*</td>
<td>地址证明<br>理财经验</td>
</tr>
</tbody>
</table>
<p>* Mastercard 扣账卡当前为特选开放，完成开户后在 App 内申请，部分智盈理财客户无法申请，但是有部分自在理财客户申请成功。</p>
<p>整个开户过程中可能用到的条件和材料如下：</p>
<ul>
<li>已开通国际漫游的内地手机号（接收短信验证码）</li>
<li>内地身份证</li>
<li>港澳通行证及签注（若线上开户签注需要打印清晰）</li>
<li>内地银行卡（ZA Bank 验证需要）</li>
<li>出入境记录文件（移民局微信小程序申请）</li>
<li>内地地址证明（仅线下开户）</li>
<li>香港过关小票（仅线下开户）</li>
<li>1000 港币现金（仅线下开户）</li>
<li>理财经验证明（股票、基金账户等，仅线下开户）</li>
</ul>
<h2>开户流程</h2>
<h3>BOCHK 线上开户</h3>
<h4>开户条件</h4>
<p>中华人民共和国居民身份证持有人需要满足以下条件才能开户：</p>
<ul>
<li>身处香港</li>
<li>全新客户或未持有任何中银香港个人/联名的储蓄或往来账户</li>
<li>年满 18 岁或以上</li>
<li>持有有效中华人民共和国居民身份证、往来港澳通行证</li>
<li>两种证件由申请日起计必须有 30 天或以上之有效期</li>
<li>提供国家移民局发出的「出入境纪录」文件 (包括申请日在内的最近 7 日内发出)</li>
<li>国籍(国家/地区)为中国内地</li>
<li>持有有效内地电话号码和地址</li>
</ul>
<p>申请使用的手机系统需要为 iOS 13 或以上或 Android 8 或以上的版本，并配有前后摄像镜头及重力传感器以便作身份验证。</p>
<h4>开户流程</h4>
<p>以下为中国内地访港旅客在线申请中银香港账户的流程：</p>
<ul>
<li>微信小程序“移民局”下载出入境记录，解压出 PDF 文件</li>
<li>安装 BOCHK 中银香港 App</li>
<li>连接香港本地网络，大部分区域有提供公共 Wi-Fi</li>
<li>打开手机定位权限，并确保定位处于香港范围内，可使用 Google Maps 刷新定位信息</li>
<li>打开 BOCHK App，选择开立账户，依次选择内地居民身份证开户、身处香港</li>
<li>点击即时开立，依次选择我不在分行、自在理财</li>
<li>上传出入境记录 PDF 文件，完成身份证认证和人脸认证</li>
<li>拍摄港澳通行证照片</li>
<li>选择开户原因（按照实际填写即可），填写准确地址信息（扣账卡收件用）</li>
<li>提示开户成功或等待审核，最晚将会在提交开户申请的 4 个工作日内通知审核结果</li>
</ul>
<h4>补充说明</h4>
<ul>
<li>在线开户成功时每天转账上限为港币 10,000 元；4 个工作日内完成开户资料内部审阅后提升至港币 210,000 元</li>
<li>在线开户账户不可以给未登记第三者账户转账</li>
<li>常见问题：<a href="https://www.bochk.com/dam/more/accountopening/images/faq_sc.pdf">https://www.bochk.com/dam/more/accountopening/images/faq_sc.pdf</a></li>
</ul>
<h3>ZA Bank 线上开户</h3>
<h4>开户条件</h4>
<p>访港旅客开户必须年满  18 岁，开户时需身处香港境内，且需要提供以下文件/资料：</p>
<ul>
<li>有效中国居民身份证正本</li>
<li>有效往来港澳通行证正本（开户时至少还有 30 日或以上之有效期）</li>
<li>内地手机号码（可于香港境内接收本地短信及电话）</li>
<li>内地银行储蓄卡（需与同一内地手机号码绑定）</li>
</ul>
<p>注意：定位需要在香港中心区域，不能在靠近边界的地方。</p>
<h4>开户流程</h4>
<ul>
<li>微信小程序“移民局”下载出入境记录，解压出 PDF 文件</li>
<li>安装 ZA Bank App</li>
<li>打开 ZA Bank App，点击立即开户</li>
<li>选择内地居民身份证</li>
<li>验证手机号码，需要是内地储蓄卡的预留手机号</li>
<li>填写邀请码 B3L359，或者点击无邀请码</li>
<li>拍摄身份证正反面，填写准确地址信息（扣账卡收件用）</li>
<li>填写纳税信息、职业信息、开户目的，按实际情况填写即可</li>
<li>确认开户资料，确认无误后提交</li>
<li>设置账户登录用户名和密码</li>
<li>进行内地储蓄卡验证，需要相同的预留手机号码</li>
<li>上传出入境记录 PDF 文件</li>
<li>5 个工作日内通知审核结果</li>
</ul>
<h4>补充说明</h4>
<ul>
<li>注册过程中会要求设置用户名及密码，审核通过后使用该用户名及密码登录</li>
<li>成功开户后在 App 中申请 Visa 扣账卡，可自定义后六位卡号，邮寄到内地需要 35 港币</li>
<li>不建议直接从内地银行汇款到 ZA Bank，会要求提供证明材料，且需要收取手续费</li>
</ul>
<h3>BOCHK 线下开户</h3>
<p>BOCHK 线下开户大概率可以额外申请一张 Mastercard 扣账卡，且根据分行情况可能可以当场发卡。</p>
<h4>开户条件</h4>
<p>线下开户默认开立投资账户（仅储蓄账户可能会被拒绝开立），相对于线上开户额外需要地址证明、香港过关小票、1000 港币现金、理财经验证明。理财经验证明可以展示股票账户，或理财产品交易流水，或支付宝基金交易记录等。</p>
<p>线下开户建议在微信公众号“中银香港微服务”，提前 7 天预约，无预约需要现场拿号排队，且部分分行不接受非预约开户。</p>
<h4>开户流程</h4>
<ul>
<li>安装 BOCHK 中银香港 App、BoC Pay</li>
<li>按照预约时间到达分行取号以及开户二维码</li>
<li>BOCHK App 扫二维码填写信息</li>
<li>填写完成后通知前台等待叫号办理</li>
<li>按要求提供身份证明资料</li>
<li>按要求提供地址证明，若使用银行电子账单，需要现场展示原 PDF 文件</li>
<li>按要求提供投资证明，可能被要求截图发送到柜员邮箱</li>
<li>被问及资金转入计划时，回答可现场存入 1000 港币，后续使用内地中行汇款</li>
<li>若分行可以当场发卡，需要往账户中存入 1000 港币激活</li>
</ul>
<h4>补充说明</h4>
<ul>
<li>线下开户成功后，可以在 App 中申请 Mastercard 扣账卡，支持虚拟卡和实体卡</li>
</ul>
<h2>汇款与转账流程</h2>
<h3>从内地中银汇款至 BOCHK</h3>
<p>中银内地和中银香港同名账户之间港币和美元汇款免收跨境汇款手续费。中银内地通过手机银行向境外中行汇款时，跨境汇款手续费和电讯费全免。</p>
<p>条件：中国银行账户为可以结售汇的一类账户</p>
<p>汇款流程：</p>
<ul>
<li>打开中国银行 App，搜索跨境汇款</li>
<li>点击页面左上境外中行</li>
<li>填写收款人信息，具体信息见下</li>
<li>填写汇款人信息，主要是补充地址和邮编</li>
<li>填写汇款信息，具体信息见下</li>
<li>核对汇款信息，完成汇款</li>
</ul>
<p>收款人信息：</p>
<ul>
<li>收款人名称：英文姓名，姓在前名在后</li>
<li>收款人账户：012 开头 14 位账户号，港币为港元储蓄账户号，美元为外汇宝储蓄账户号</li>
<li>省/市/州：HONG KONG</li>
<li>详细地址：1 GARDEN ROAD, CENTRAL, HONG KONG</li>
<li>SWIFT 地址：BKCHHKHHXXX</li>
<li>银行名称：BANK OF CHINA (HONG KONG) LIMITED, HONG KONG</li>
<li>主要地址：BANK OF CHINA TOWER, 1 GARDEN ROAD, CENTRAL, HONG KONG</li>
</ul>
<p>汇款信息：</p>
<ul>
<li>选择扣款账户，应为具有跨境汇款权限的一类账户</li>
<li>汇款币种：无法直接汇出人民币，需要购汇为港币或美元</li>
<li>汇款金额：根据需求填写</li>
<li>是否本人承担中转费：否</li>
<li>付款币种：根据需求选择</li>
<li>手机号：如实填写，如存在问题会直接打电话告知</li>
<li>收款人常驻国家/地区：中国香港</li>
<li>汇款用途：根据实际情况选择</li>
<li>汇款用途详细说明：根据实际情况选择</li>
</ul>
<h3>从 BOCHK 转账至 ZA Bank</h3>
<p>ZA Bank 中可以绑定 BOCHK 账户充值，若不想绑定账户，也可以通过 FPS 从 BOCHK 转账至 ZA Bank。</p>
<ul>
<li>打开 BOCHK App，进入 FPS 与转账设置</li>
<li>查看或新增登记 FPS，可以使用手机号、邮箱或 FPS ID</li>
<li>在 BOCHK App 中选择转账/FPS，使用 FPS 转账</li>
</ul>]]></description>
      <guid isPermaLink="false">https://xiaohei.moe/post/2024/11/18/hongkong-bank-card-application/</guid>
      <pubDate>Mon, 18 Nov 2024 17:00:00 +0806</pubDate>
    </item>
    <item>
      <title>支付宝“碰一下”原理分析与实现</title>
      <link>https://xiaohei.moe/post/2024/11/25/alipay-nfc-tag/</link>
      <description><![CDATA[<!-- markdownlint-disable MD033 -->

<p>支付宝推出“碰一下”收款机器已有一段时间，近期又开始推广融合了“碰一下”功能的收款码与红包码，作为一种比较新奇的支付方式，本文将对其原理进行分析，并尝试自行制作“碰一下”标签。</p>
<h2>原理分析</h2>
<p>在常规的手机 NFC 支付模式（如电子公交卡、电子八达通、Apple Pay 等）中，钱包信息被加密存储在手机本地，支付时通过 NFC 传递支付信息到 POS 机。POS 机再向结算机构发出请求完成支付，整个过程中手机无需联网，部分手机也可实现关机刷卡。而支付宝“碰一下”不同，虽然也是利用 NFC 功能进行支付，但手机并不存储钱包信息，而是先利用 NFC 完成应用跳转，然后与在线支付的操作相同。也就是说，支付宝“碰一下”实际上达到的效果与扫描二维码完全相同，只是在部分场景下减少了用户的操作。</p>
<p>目前已有的支付宝“碰一下”收款方式主要有两种：可以设置收款金额的“碰一下”收款机，以及带有“碰一下”感应标签的需要自行输入金额的收款码。前者需要商家输入收款金额后，用户碰一下收款机即可跳转至定额支付页面；后者则是无需进行外部控制的无源标签，碰一下之后跳转至输入金额支付的页面。除了收款之外，还衍生出了一系列的“碰一下”功能，如“碰一下”红包码、“碰一下”点餐码等。</p>
<p>以上两种“碰一下”方式的原理是相同的，均为手机扫描 NFC 标签后，根据标签内容启动支付宝应用，并根据标签中的链接跳转至指定页面。本文章将直接对支付宝中申请的“碰一下”收款码及红包码进行分析。</p>
<p><img alt="支付宝“碰一下”收款码及红包码" src="https://s2.loli.net/2024/11/25/N2AcHj84Sh1WDME.jpg" /></p>
<p>使用 <a href="https://play.google.com/store/apps/details?id=com.nxp.taginfolite">NFC TagInfo by NXP</a> 应用，分别对支付宝“碰一下”收款码及红包码进行扫描并查看，可以发现使用的均为复旦微的 NFC 芯片，NDEF 数据中均包含了两条记录，分别为 URI 和 Android Application Record。其中 URI 记录中包含了支付宝相关链接，而 Android Application Record 记录则为支付宝应用的包名。</p>
<p><img alt="支付宝“碰一下”收款码及红包码的 NDEF 数据" src="https://s2.loli.net/2024/11/25/HMOlogRSkWEvPYe.jpg" /></p>
<p>具体分析其 URI 数据，可以发现构成方式如下：</p>
<ul>
<li>收款码：<code>render.alipay.com/p/s/ulink/sn?s=dc&amp;scheme=alipay%3A%2F%2Fnfc%2Fapp%3Fid%3D10000007%26actionType%3Droute%26codeContent%3D{URL}</code></li>
<li>红包码：<code>render.alipay.com/p/s/ulink/nrps?s=dc&amp;scheme=alipay%3A%2F%2Fnfc%2Fapp%3Fid%3D10000007%26actionType%3Droute%26codeContent%3D{URL}</code></li>
</ul>
<p>二者区别在于收款码的 Endpoint 为 <code>/p/s/ulink/sn</code>，而红包码的 Endpoint 为 <code>/p/s/ulink/nrps</code>。其中 <code>{URL}</code> 为 <code>qr.alipay.com</code> 域名下的链接经过两次 URL Encode 的结果，扫描对应感应区左侧的二维码可发现与二维码链接相同，只是多了一个 <code>noT</code> 参数，包含此参数的交易可享受“碰一下”支付优惠。</p>
<h2>自制标签</h2>
<p>根据以上分析，可以发现只需要有对应的二维码即可自行制作一个“碰一下”标签。首先需要选择合适的标签类型，根据以上读取中的 230+ bytes 数据量，NTAG213 最大 144 bytes 的 User memory 不足够，可以选择 NTAG215 (504 bytes) 或 NTAG216 (888 bytes) 标签。然后使用 <a href="https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter">NFC TagWriter by NXP</a> 应用将数据写入标签。下图为测试时所使用的 NTAG216 贴纸标签。</p>
<p><img alt="NTAG216 标签" src="https://s2.loli.net/2024/11/25/FUQmejhl8BzyLSk.jpg" /></p>
<p>首先获取到收款二维码的链接</p>
<pre class="highlight"><code class="language-text">https://qr.alipay.com/xxxxxxxxxxxxxxxxxxxxxxx
</code></pre>

<p>将其进行两次 URL Encode，得到</p>
<pre class="highlight"><code class="language-text">https%253A%252F%252Fqr.alipay.com%252Fxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>

<p>使用收款码的 Endpoint 构造完整的 URI 记录</p>
<pre class="highlight"><code class="language-text">render.alipay.com/p/s/ulink/sn?s=dc&amp;scheme=alipay%3A%2F%2Fnfc%2Fapp%3Fid%3D10000007%26actionType%3Droute%26codeContent%3Dhttps%253A%252F%252Fqr.alipay.com%252Fxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>

<p>在 NFC TagWriter 中点击 Write Tags，选择 New dataset，然后选择 Launch Application，在应用列表中选择支付宝，或手动输入包名 <code>com.eg.android.AlipayGphone</code>，然后依次点击 SAVE&amp;WRITE 和 ADD MORE RECORD，选择 Link，Description 留空，URI type 选择 <code>https://</code>，URI data 输入上述构造的 URI 记录，然后依次点击 SAVE&amp;WRITE 和 WRITE，即可贴标签并写入。具体操作可参考下图。</p>
<p><img alt="标签写入操作" src="https://s2.loli.net/2024/11/25/nbujGxV1Mrl57kU.jpg" /></p>
<h2>更多玩法</h2>
<h3>定额“碰一下”收款</h3>
<p>默认的“碰一下”收款需要手动输入金额，如果想要实现定额收款，可以在支付宝收款码页面设定金额后保存二维码，然后按照 <a href="#自制标签">自制标签</a> 中的方法写入标签。</p>
<h3>使用支付宝打开任意第三方网页</h3>
<p>如果将以上链接中的 <code>{URL}</code> 替换为其他自定义链接，可以实现“碰一下”使用支付宝打开对应网页的功能。</p>
<h3>复制标签</h3>
<p>如果已经申请了具有“碰一下”功能的收款码或红包码，也可以直接使用 NFC TagWriter 将其复制到其他的标签，并贴在需要的地方。在推广期间支付宝商家服务中有机会免费领取“碰一下”收款码和红包码。</p>]]></description>
      <guid isPermaLink="false">https://xiaohei.moe/post/2024/11/25/alipay-nfc-tag/</guid>
      <pubDate>Mon, 25 Nov 2024 20:56:00 +0806</pubDate>
    </item>
    <follow_challenge>
      <feedId>54860944275961856</feedId>
      <userId>52323271612923904</userId>
    </follow_challenge>
  </channel>
</rss>
