Python · 字符串与编码

str 是"人话" (Unicode 码点), bytes 是"电线上的字节" — encode/decode 双向管道两端 codec 必须一致, 90% 的乱码事故都发生在这条管子上

encode('utf-8') decode('utf-8') decode('utf-8') encode('utf-8') 事故点① 字符无法映射 '中'.encode('ascii') → UnicodeEncodeError 事故点② 非法字节序列 b'\xd6\xd0...'.decode('utf-8') → UnicodeDecodeError str — 人话 Unicode 码点序列 '中' = U+4E2D = 20013 len('中文') == 2 (按码点) 进程内部统一用它 bytes — 电线上的字节 b'\xe4\xb8\xad\xe6\x96\x87' '中文' 的 UTF-8 形态, 共 6 字节 len(b) == 6 ≠ len(s) == 2 出门 (网络/磁盘) 前必须变成它 str (还原) '中文' 完整回来 codec 一致才有 encode → decode 往返一致 进门后第一时间 decode 更危险的事故: codec 不匹配但字节恰好"合法" → 不报错, 静默产出乱码 (mojibake), 比抛错难查十倍 码点视角 — 编号 vs 存储方案 码点是编号, 编码是编号怎么装进字节 — 两个概念 ord('中') == 20013 == 0x4E2D chr(20013) == '中' UTF-8 变长: 'a' 1 字节 · '中' 3 字节 · '😀' 4 字节 '中'.encode('utf-8') → E4 B8 AD len('中文') == 2 是码点数; 同样的字 UTF-8 是 6 字节 按 len(str) 算存储/带宽, 中文场景直接少算 3 倍 组合字符/ZWJ: len('👨‍👩‍👧‍👦') == 7, 字节级截断会拆出半个人 事故现场 — 静默 mojibake 与二次编码修复 b = '中文'.encode('utf-8') b.decode('latin-1') → '中æ\x96\x87' ← 不报错! UTF-8 字节被 latin-1 全盘接收, 每个字节变一个字符 修复: '中æ\x96\x87'.encode('latin-1') .decode('utf-8') → '中文' 二次编码往返: 从哪扇错误的门进来, 就从哪扇门退回去 b'\xd6\xd0\xce\xc4'(GBK 编码).decode('utf-8') → UnicodeDecodeError: invalid continuation byte 工程纪律 — 三条铁律 1. 进程边界上传 bytes + 显式 codec; 系统内部一律 str, 别让 bytes 流进业务层 2. open() 读写文件永远显式 encoding='utf-8', 别信 locale 默认值 (Windows 是 GBK) 3. decode 显式 errors 策略: 脏数据 replace + 计数告警, 让每一次妥协都可观测 Legend str (人话) bytes (字节) 事故 / 乱码 码点机制 字节单元 / 修复

str/bytes 分工明确

  • • 进程内部统一 str, 出门 (网络/磁盘/加密) 变 bytes
  • • 混着用直接 TypeError, Python 3 不帮你隐式转
  • • bytearray 是可变版, 做缓冲区拼装不复制
  • • b'' 里没有"字符", 只有 0-255 的字节值

codec 一致才有往返

  • • encode 用什么 codec, decode 就得用同一个
  • • 不一致: 要么抛错, 要么更糟 — 静默 mojibake
  • • 所有读写点显式 encoding='utf-8'
  • • 容器里 PYTHONUTF8=1 兜底 locale 差异

"长度"有三种

  • • len(str) 数码点 — 不等于字节数
  • • len(bytes) 数字节 — 存储带宽按它算
  • • 终端显示宽度又是另一回事 (中文占 2 列)
  • • emoji 是码点序列, 截断要按码点/字素簇切

💡 一句话理解

把 str 想成"写在纸上的字", 把 bytes 想成"电报码": 纸上的字人人可读, 但上电线前必须按某本密码本 (codec) 编成数字, 对端再按同一本密码本解回来。90% 的乱码事故就出在两头密码本不一致: 运气差一点, 解码器直接抛 UnicodeDecodeError, 你立刻知道错在哪; 运气"好"一点, 字节恰好合法, 解出一个 '中æ\x96\x87' 不报错 — 这种静默 mojibake 才是真正难查的。记住边界纪律: 数据进门第一件事 decode 成 str, 出门最后一刻才 encode 成 bytes, 中间全流程只见"人话"。

🧠 必知必会 必考 & 必会

str / bytes / bytearray
str 是 Unicode 码点序列 (不可变); bytes 是字节序列 (不可变); bytearray 可变, 做接收缓冲区反复拼装不产生新对象。
s = "中文"                  # str: 码点序列, 不可变
b = s.encode("utf-8")       # → b'\xe4\xb8\xad\xe6\x96\x87'
buf = bytearray()           # 关键: 可变缓冲区
buf += b                    # 反复拼装不产生新对象
码点与编码
码点是字符的编号 (ord('中') == 20013), 编码是编号的存储方案。同一字符在 UTF-8/GBK/UTF-16 里字节完全不同, 但码点只有一个。
print(ord("中"))            # → 20013  码点唯一
"中".encode("utf-8")         # → b'\xe4\xb8\xad'  3 字节
"中".encode("gbk")           # → b'\xd6\xd0'  2 字节
UTF-8 变长
ASCII 1 字节、常用中文 3 字节、emoji 4 字节; 首字节标记长度, 续字节都是 10xxxxxx。兼容 ASCII 是它通吃 Web 的原因。
for ch in ["a", "中", "😀"]:
    print(ch, len(ch.encode()))  # → a 1 / 中 3 / 😀 4
# 关键: 首字节标记长度, 续字节都是 10xxxxxx
len 语义差异
len(s) 数码点, len(s.encode()) 数字节, 终端显示宽度是第三件事。算存储/带宽/协议长度字段, 永远用字节那个。
s = "中文😀"
print(len(s), len(s.encode()))  # → 3 10  (3+3+4)
# 关键: 协议长度字段/带宽配额永远用字节数
b'' 前缀
字节字面量: b'\xe4\xb8\xad'。bytes 里"没有中文", 只有字节值; b'中' 直接 SyntaxError。
b = b"\xe4\xb8\xad"              # 字节字面量
b.decode("utf-8")              # → '中'
# 错: b'中' → SyntaxError: bytes 里只有字节值
f-string 与 spec
f"{x=}" 调试打印名字和值; {n:,} 千分位、{r:.1%} 百分比、{s:>10} 对齐 — 报表格式化一行搞定, 别手写循环插逗号。
x, n, r = 3.14159, 1234567, 0.256
f"{x=:.2f}"   # → 'x=3.14'  调试直接带名字
f"{n:,}"      # → '1,234,567'  千分位
f"{r:.1%}"    # → '25.6%'  百分比
不可变与驻留
str 不可变, 任何"修改"都造新对象; 解释器对短字符串/标识符做 intern 复用 — 所以 is 比较字符串是坏习惯, 永远用 ==。
a = "hello"; b = "hel" + "lo"   # 编译期合并并驻留
c = "".join(["hel", "lo"])       # 运行期生成
a is b, a is c    # → (True, False)  is 看运气
a == c            # → True  关键: 判等永远用 ==
split / partition
split 按分隔符切列表; partition 切成 (前, 分隔符, 后) 三段, 解析 "key=value" 这种只需切一刀的场景更稳。
"key=value=1".partition("=")  # → ('key','=','value=1')
"a,b,c".split(",")            # → ['a','b','c']
# 只切一刀用 partition, 不怕分隔符再出现
strip 家族
strip/lstrip/rstrip 的参数是字符集合不是子串: strip('ab') 把开头的 a、b 连续剥掉。去固定前后缀用 3.9+ 的 removeprefix/removesuffix。
"abtestab".strip("ab")       # → 'test'  剥字符集合
"abtest".removeprefix("ab")   # → 'test'  去固定前缀
re 的衔接
正则直接作用于 str; 对 bytes 匹配时模式串也得是 rb'...', 两边类型必须一致, 否则 TypeError。
re.search(r"\d+", "a12")      # str 模式配 str
re.search(rb"\d+", b"a12")    # bytes 模式配 bytes
re.search(rb"\d+", "a12")     # 错: → TypeError 混用
组合字符与 ZWJ
'é' 可以是 1 个码点也可以是 e+组合符 2 个码点; '👨‍👩‍👧‍👦' 是 4 个 emoji 加 ZWJ 共 7 个码点 — len≠1, 字节级截断必出"半个人"。
print(len("👨‍👩‍👧‍👦"))   # → 7  4 个 emoji + 3 个 ZWJ
# 关键: len≠1, 码点级截断 s[:3] 必出"半个人"
# 按"字素簇"数要 grapheme 库
errors 策略
strict 抛错 (默认)、replace 换 U+FFFD、ignore 丢弃、surrogateescape 无损往返未知字节 — 兜底要选看得见代价的那种并计数。
b"\xff".decode("utf-8", errors="replace")  # → '\ufffd'
b"\xff".decode("utf-8", errors="ignore")   # → ''
b"\xff".decode("utf-8", errors="surrogateescape")
# → '\udcff'  再 encode 同参数可无损往返

🏭 生产实战 real world

场景 1 · ETL 兜底脏数据: replace + 计数告警, 妥协可观测

上游日志 0.01% 是坏字节, strict 让整条管道天天中断, ignore 又无声丢字。折中方案是 replace + 计数:

REPLACED = 0
def safe_decode(raw: bytes, source: str) -> str:
    global REPLACED
    text = raw.decode("utf-8", errors="replace")   # U+FFFD 占位, 不中断管道
    n = text.count("\ufffd")
    if n:
        REPLACED += n
        metrics.incr(f"etl.bad_utf8.{source}", n)    # 告警带来源标签
    return text

# 批结束检查: 替换率超 0.1% 就挡批隔离, 别让脏数据静默入库
if total and REPLACED / total > 0.001:
    raise BatchQuarantined(REPLACED, total)

场景 2 · 文件读写显式 encoding, 消灭 Windows GBK 事故

同一段代码 macOS 正常、Windows 全是乱码: open() 不传 encoding 时用的是 locale.getpreferredencoding(), 各平台不一样:

# 事故根因: Windows 默认 cp936(GBK); 容器 LANG=C 时默认 ANSI_X3.4(ascii)
# 不传 encoding = 把编码决定权交给运行环境的 locale, 必出事
with open("data.txt", "w", encoding="utf-8", newline="\n") as f:
    f.write(text)          # 换行符也钉死, 防 Windows 写出 \r\n

import locale
# 自检: 打印当前默认, CI 里断言它是 utf-8, 环境漂移当场暴露
assert locale.getpreferredencoding(False).lower() in ("utf-8", "utf8")
# 容器一次性兜底: Dockerfile 里加  ENV PYTHONUTF8=1

场景 3 · 网络协议按字节解析: struct 打包 + 长度前缀分包

TCP 是字节流不自带消息边界, 协议层是 bytes 的主场, 一个字节都不能含糊:

import struct

def encode_frame(payload: bytes) -> bytes:
    # 4 字节大端长度前缀 + 内容: 对端才知道一条消息到哪结束
    return struct.pack("!I", len(payload)) + payload

def recv_exact(sock, n: int) -> bytes:   # recv 可能短读, 必须循环凑满
    buf = b""
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("peer closed")
        buf += chunk
    return buf

def read_frame(sock) -> bytes:
    (length,) = struct.unpack("!I", recv_exact(sock, 4))
    if length > MAX_FRAME:             # 防恶意超大帧撑爆内存
        raise ProtocolError(length)
    return recv_exact(sock, length)

场景 4 · f-string 打日志, SQL 永远参数绑定

模板复用用 .format, 拼日志用 f-string; 但 SQL 拼字符串等于给攻击者开门:

# 日志: f-string 直接拼, 一次性的用完即弃
log.info(f"order={oid} paid={amount:.2f} at={ts:%H:%M:%S}")
# 会被复用/翻译的模板: .format 留坑位
TEMPLATE = "user={uid} region={region} latency={ms:.1f}ms"
log.debug(TEMPLATE.format(uid=u.id, region=r, ms=lat))

# SQL: 永远参数绑定 — 拼字符串 = SQL 注入 + 放弃预编译缓存
cur.execute(
    "SELECT id FROM orders WHERE user_id = %s AND status = %s",
    (uid, "paid"),
)
# 表名/排序字段没法参数化: 白名单校验后再拼
assert sort_col in {"created_at", "amount"}
sql = f"ORDER BY {sort_col}"

场景 5 · 千分位/百分比/对齐: format spec 出生产报表

给运营导周报, 金额要千分位两位小数、占比要百分比、列要对齐 — 全在 spec 里声明:

rows = [
    ("华东", 12_345_678.9, 0.1872),
    ("华南", 9_876_543.2, 0.2419),
]
for region, amount, ratio in rows:
    # 一行 spec: 左对齐 / 千分位 / 百分比, 别手写循环插逗号
    print(f"{region:<4} {amount:>14,.2f} {ratio:>7.1%}")
# 华东   12,345,678.90    18.7%
# 华南    9,876,543.20    24.2%

debug = f"{dt:%Y-%m-%d %H:%M} value={value=}"  # = 号连名带值一起打

场景 6 · CSV 脏行跳过 + 样本采集, 不整批中断

千万行 CSV 里有几十行坏编码, 整批失败不可接受。跳过 + 留证 + 告警:

import csv

bad_rows = []
with open("users.csv", encoding="utf-8", errors="replace",
          newline="") as f:          # csv 模块 newline='' 必传
    for lineno, row in enumerate(csv.reader(f), 1):
        if "\ufffd" in "".join(row):
            bad_rows.append((lineno, row[:3]))     # 采样留证
            continue
        ingest(row)

if bad_rows:
    alert.fire(f"{len(bad_rows)} dirty rows", sample=bad_rows[:5])
# 妥协可见: 跳了多少行、长什么样, 都在告警里

场景 7 · 大文本按行处理, 永不截断多字节字符

要按"区间"抽 20GB 文本的样本, 直觉用字节切片 — 3 字节的'中'会被拦腰斩断成非法字节:

def sample_lines(path: str, lo: int, hi: int) -> list[str]:
    out = []
    with open(path, encoding="utf-8") as f:  # 按行读: 行边界永不切断码点
        for line in f:
            if lo < len(line) <= hi:
                out.append(line)
    return out

# 确实要按字节定位 (如断点续读 offset): 先记住字节偏移,
# 读取后从该偏移往后找第一个行首再开始 decode — 别在字节中间下刀
# UTF-8 续字节都是 10xxxxxx: 切在它前面, decode 必炸

场景 8 · emoji 用户名截断修复: 按码点切 + 尾部组合符回收

列表页要截 12 个字符的昵称, 旧代码按字节切, emoji 用户全变乱码:

import unicodedata

def truncate(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    cut = text[:limit]              # 按码点切, 不按字节 — 不会出半个人
    while cut and unicodedata.combining(cut[-1]):
        cut = cut[:-1]          # 尾部不能是悬空的组合符号
    return cut + "…"

name = truncate("👨‍👩‍👧‍👦 一家四口", 4)
# 坏版本 name.encode('utf-8')[:12].decode('utf-8') → UnicodeDecodeError

场景 9 · charset-normalizer 排查上游"每周换个编码"

供应商导出文件有时 GBK 有时 UTF-8, 报错才改永远晚一步。入口先检测 + 核对:

from charset_normalizer import from_bytes

def sniff(raw: bytes) -> str:
    # 检测 + 置信度: 只看头部 512KB, 大文件别全量喂
    best = from_bytes(raw[:512_000]).best()
    if best is None:
        raise ValueError("cannot detect encoding")
    return str(best.encoding)

raw = fetch_supplier_dump()
text = raw.decode(sniff(raw))
# 检测不是真理: 双字节编码常互相兼容, 关键表抽 3 行人肉核对再入库

场景 10 · 哈希/HMAC 输入必须 encode, 先归一化再算指纹

hashlib 只吃 bytes, 传 str 直接 TypeError; 更隐蔽的是组合字符让"看起来一样"的字符串指纹不同:

import hashlib, hmac, unicodedata

# 坑: hashlib 要 bytes, 传 str 直接 TypeError: Unicode-objects must be encoded
# 更深的坑: 'café' 的 é 可能是 1 个码点, 也可能是 e + 组合符 2 个码点
normalized = unicodedata.normalize("NFC", payload["name"])
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()

# 签名同理: key 和 body 都要 bytes
sig = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
# 对账接口先 normalize 再算指纹, 否则"同一个名字"对出两个哈希

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 隐式编码依赖 locale — 同代码本机正常、进容器就 UnicodeEncodeError: LANG=C 时默认 codec 是 ascii。正解: 所有 open() 显式 encoding='utf-8', 容器加 ENV PYTHONUTF8=1 一劳永逸。
# 错: open("a.txt", "w") → LANG=C 容器里默认 ascii
open("a.txt", "w", encoding="utf-8")  # 对: 显式
# Dockerfile: ENV PYTHONUTF8=1  一劳永逸
坑 2 · 'gbk' codec can't encode 报错难定位 — 写文件/发网络时中文没事, 一遇到 emoji 就炸 'gbk' codec can't encode character '\U0001F600'。正解: 输出端显式 utf-8; 必须兼容 GBK 通道就 errors='replace' 并告警。
# 错: "看板😀".encode("gbk")
# → 'gbk' codec can't encode character '\U0001F600'
"看板😀".encode("gbk", errors="replace")  # 对: 换 U+FFFD 并告警
坑 3 · decode 用错 codec 不报错, 出 mojibake — UTF-8 字节用 latin-1 解, 每个字节都合法, 静默产出 '中æ\x96\x87', 比抛错难查十倍。正解: 入口统一 utf-8 + 关键字段断言 (如订单号正则不过就挡), 存疑用 charset-normalizer 复核。
"中文".encode().decode("latin-1")  # 错: → '中æ\x96\x87' 静默乱码
raw.decode("utf-8")              # 对: 入口统一, 错 codec 当场抛
# 关键字段加断言: 订单号正则不过就挡
坑 4 · len(str) 当字节数算存储/带宽 — len('中文')==2 但 UTF-8 占 6 字节, 按码点数算配额直接少算 3 倍。正解: 涉及容量一律 len(s.encode('utf-8')); 数据库字段长度先确认是字节还是字符口径。
print(len("中文"))                  # 错: → 2 当存储算, 少算 3 倍
print(len("中文".encode("utf-8")))  # 对: → 6
# DB 字段长度先确认是字节还是字符口径
坑 5 · 字符串乘法拼大对象内存翻倍 — '-' * 10**8 一步造出 100MB 不可变对象, 再拼接又复制一份。正解: 分块流式输出, 或用 bytearray/file.write 循环写, 别物化整个大字符串。
# 错: sep = "-" * 10**8  一步物化 100MB 不可变对象
for chunk in gen():    # 对: 分块流式输出
    f.write(chunk)       # 不物化整个大字符串
坑 6 · 循环里 += 拼接 O(n²) — str 不可变, 每次 += 都新建对象, 百万次循环从 0.1s 拖到 30s。正解: 收进 list 最后 ''.join(parts); 已有分段迭代器直接 io.StringIO 或生成器。
# 错: for x in items: s += x  百万次 0.1s → 30s
parts = []
for x in items: parts.append(x)
s = "".join(parts)       # 对: 一次拼完
坑 7 · strip 传参是字符集合不是子串 — 'abxcab'.strip('ab') 把开头连续的 a、b 全剥掉得 'xc', 不是去掉 "ab" 前缀。正解: 去固定前后缀用 removeprefix('ab'); 去中间子串用 replace 或切片。
"abxcab".strip("ab")          # 错: → 'xc'  剥字符集合
"abxcab".removeprefix("ab")    # 对: → 'xcab' 固定前缀
坑 8 · split() 与 split(' ') 语义不同 — 'a b' 前者按任意空白切并合并得 ['a','b'], 后者按单个空格切出空串 ['a','','b']。正解: 解析日志/CSV 用 split() 或 split(None, maxsplit); 需要保留空字段才显式 ' '。
"a  b".split()      # → ['a', 'b']  合并任意空白
"a  b".split(" ")   # → ['a', '', 'b']  出空串
# 解析日志用 split() 或 split(None, 1) 限次数
坑 9 · in 判断 str 与 bytes 类型不匹配 — b'ERROR' in line 而 line 是 str, 直接 TypeError: a bytes-like object is required。正解: 两边类型对齐 — 网络层留 bytes 就用 b'ERROR', 已 decode 就用 'ERROR', 别混。
# 错: b"ERROR" in line  而 line 是 str
# → TypeError: a bytes-like object is required
line = raw.decode("utf-8")   # 对: 已 decode 就用 str
"ERROR" in line            # 两边类型对齐
坑 10 · f-string 引号嵌套与反斜杠限制 — 3.12 前表达式里不能复用外层同款引号, 也不能出现反斜杠, f"say {d["k"]}" 老版本直接 SyntaxError。正解: 先取变量 v = d["k"] 再进 f-string; 或升级 3.12+ 享受新解析器。
# 错(3.12 前): f"say {d["k"]}"  → SyntaxError
v = d["k"]           # 对: 先取变量再进 f-string
f"say {v}"           # 或升级 3.12+ 新解析器
坑 11 · format string injection — 模板来自用户输入时, user_tpl.format(account=account) 能通过 {account.__class__} 系列读到对象内部结构, 泄露敏感信息。正解: 模板必须是自己写的常量, 用户只提供值; 或改用 string.Template.safe_substitute。
# 错: user_tpl.format(account=account)
#   {account.__class__} 可摸到对象内部结构
Template("$key").safe_substitute(vals)  # 对
# 模板必须是自己写的常量, 用户只提供值
坑 12 · 逆序切片破坏组合字符 — 'é' 若是 e+U+0301 两个码点, s[::-1] 变成 ́e (重音跑到前面)。正解: 需要按"字素簇"处理时用 unicodedata.normalize('NFC', s) 先归一, 或引入 grapheme 库切分。
s = "cafe\u0301"           # e + 组合重音, 2 个码点
s[::-1]                     # 错: 重音跑到前面
unicodedata.normalize("NFC", s)[::-1]  # 对
坑 13 · title/upper 的本地化怪异 — 土耳其语 'i'.upper() 是 'İ' (带点大写), 用 upper 后比较相等会误判; 'ß'.upper() 变 'SS' 长度还变了。正解: 无关本地化比较统一 casefold(); 展示用途才用 title/upper。
"ß".upper()      # → 'SS'  长度都变了
"straße".casefold() == "strasse"  # 对: → True
# 无关本地化比较统一 casefold; 展示才用 upper
坑 14 · 字节索引切片截断多字节字符 — b'中文...'[:4] 把 3 字节的字砍剩 1 字节, 后续 decode 必抛 UnicodeDecodeError。正解: 先 decode 成 str 再按码点切; 必须按字节定位就从行首/安全边界开始 (见场景 7)。
raw = "中文".encode()          # b'\xe4\xb8\xad\xe6\x96\x87'
raw[:4].decode("utf-8")      # 错: → UnicodeDecodeError
raw.decode()[:1]            # 对: 先 decode 再切 → '中'
坑 15 · csv 模块 newline='' 必传 — Windows 下不传 newline='', csv.reader 收到 \r\n 被双层处理, 每行末尾多一个空行/字段错位。正解: open(p, newline='', encoding='utf-8') 两个参数都是标配。
# 错: open(p, "w") → Windows 下 \r\n 双层处理, 字段错位
f = open(p, newline="", encoding="utf-8")  # 对
csv.writer(f).writerow(["a", "b"])  # 两参数都是标配
坑 16 · subprocess 参数 bytes 与 str 混用 — args 列表传了 bytes 而 cwd/env 传 str (或反之), 直接 TypeError: can't mix str and bytes arguments。正解: 整个调用统一一种类型; 文件路径含非 ASCII 时优先全 str + encoding='utf-8'。
# 错: subprocess.run([b"ls"], cwd="/tmp") → TypeError
#     can't mix str and bytes arguments
subprocess.run(["ls"], cwd="/tmp",
               encoding="utf-8")          # 对: 全 str
坑 17 · 'a' == b'a' 静默返回 False — str 与 bytes 比较不报错只给 False, 拿 bytes 去查 str 做 key 的 dict 静默 KeyError, 排查极耗时间。正解: 边界处立刻 decode; 调试时打印 type(x) 确认, 别信 repr 的相似外观。
"a" == b"a"     # 错: → False  不报错只给 False
d = {"key": 1}
d[b"key"]       # → KeyError  静默查不到
# 对: 边界处立刻 decode, 调试打印 type(x)
坑 18 · base64 后的 +/ 在 URL 里未转义 — 标准 base64 含 + 和 /, 拼进 query 会被解析成空格/路径, 签名对不上。正解: base64.urlsafe_b64encode (换成 -_), 且注意 padding 的 = 在部分网关也要 quote。
# 错: base64.b64encode(...) 含 +/ 拼进 query 被解析坏
b64 = base64.urlsafe_b64encode(sig)   # 对: 换成 -_
urllib.parse.quote(b64.decode())      # 对: padding 的 = 也要转义
坑 19 · print 中文到 GBK 终端 UnicodeEncodeError — 本地跑得好好的, 远程管道/CI 终端是 GBK, 一打中文就崩 'gbk' codec can't encode。正解: 程序入口 sys.stdout.reconfigure(encoding='utf-8', errors='replace'), 日志框架单独配 utf-8 handler。
# 错: 远程/CI 终端是 GBK, print("中文") 当场崩
sys.stdout.reconfigure(encoding="utf-8",
                       errors="replace")  # 对: 入口重配
坑 20 · json.dumps 默认 ensure_ascii 膨胀 — 默认 True 时 '中' 变 \u4e2d 六个字符, 中文日志没法看、消息体白白膨胀。正解: json.dumps(obj, ensure_ascii=False) 再 encode('utf-8'); 注意此时 write 端也必须真是 utf-8。
# 错: json.dumps({"city": "北京"}) → '{"city": "\u5317\u4eac"}'
json.dumps(d, ensure_ascii=False)  # 对: '{"city": "北京"}'
# 再 encode("utf-8") 发送; write 端也必须真是 utf-8