缘起:一个"看起来只是写几个字段"的需求
《人工智能生成合成内容标识办法》(国信办通字〔2025〕2 号)与强制性国标 GB 45438-2025 于 2025-09-01 施行后,"给 AI 生成的内容加标识"从可选项变成了上架硬门槛------分发平台在上架时会核验标识材料,不符合强制性国标属行政处罚面,不是"整改提醒"。
我做的悦舍堂装修智能平台是一个多端产品(一套后端 + iOS / 安卓 / 鸿蒙 / 三个 Web 端)需要落地这套标识。初看这件事,需求很朴素:
- 显式标识:人眼可见的标注(界面角标 + 导出文件上的文字水印);
- 隐式标识:文件元数据里的生成合成属性信息(我们选 EXIF)。
但真正动手之后,我发现这件事的难点不在"写",而在"写对"与"跨端一致"。下面把踩过的坑、验证手段和工程取舍写清楚,给同样要做合规标识的同学省点时间。
1. 先把要求拆成三类字段
法规对隐式标识的三要素要求是:生成合成属性信息 、服务提供者名称或编码 、内容编号。我们把它映射成三处载体:
| 要素 | 载体 | 说明 |
|---|---|---|
| 生成合成属性信息 | EXIF UserComment 的 aigc |
恒 "1" |
| 服务提供者 | EXIF UserComment 的 provider |
写平台自身的 ASCII 编码名 |
| 内容编号 | EXIF UserComment 的 contentId + 服务端台账 |
YSTAI-{YYYYMMDD}-{8位小写hex},后端唯一生成 |
外加两个辅助字段:Software(标识版本)、ImageDescription(ASCII 短语)。UserComment 是这三要素的真正载体 ,Software / ImageDescription 属"可被常规工具直接看到"的辅助信息。
内容编号必须由后端生成------端上不许自造。这条不是洁癖:编号是回溯到服务端台账(留存 ≥6 个月)的唯一钥匙,若各端各行其是,台账就成了孤岛。
2. EXIF 写入:第一个坑在"落哪个 IFD"
用 Python + Pillow 给一张 JPEG 写这三个键,直觉写法长这样:
python
from PIL import Image
img = Image.open("in.jpg")
exif = img.getexif()
exif[0x010E] = "AI Generated Content" # ImageDescription
exif[0x0131] = "MyApp AI/v1" # Software
exif[0x9286] = b"ASCII\x00\x00\x00" + b'{"aigc":"1",...}' # UserComment
img.save("out.jpg", exif=exif.tobytes())
这段代码的 UserComment 不会被写进 ExifIFD。 实测(Pillow 10.1.0)三种写法:
| 写法 | 结果 |
|---|---|
exif[0x9286] = ...(顶层赋值) |
✗ 落 IFD0,get_ifd(0x8769) 读不到 |
exif.get_ifd(0x8769)[0x9286] = ... |
✗ 原地改完全不落盘 |
exif[0x8769] = {0x9286: ...} |
✓ 正确落在 ExifIFD |
也就是说,UserComment(0x9286) 必须通过 ExifIFD 指针(0x8769) 整体重新赋值才落盘。规范读端从 ExifIFD 取 UserComment,写错 IFD 的话,文件"看着有 EXIF",但标识读不出来。正确写法:
python
TAG_EXIF_IFD_POINTER, TAG_USER_COMMENT = 0x8769, 0x9286
sub = dict(exif.get_ifd(TAG_EXIF_IFD_POINTER))
sub[TAG_USER_COMMENT] = b"ASCII\x00\x00\x00" + payload
exif[TAG_EXIF_IFD_POINTER] = sub # 整体重新赋值,才落 ExifIFD
顺带一个重编码陷阱 :如果你的通道会重编码图片,必须把源图的 EXIF 带上 (base_exif=img.getexif()),否则会把来源图的 Orientation 抹掉------方向标签一丢,看图方向和上传时不一致,用户会以为你处理坏了图片。做法是:带上源 EXIF,只覆盖我们自己的三个键。
3. UserComment 的六个键,为什么要"恒定齐全"
我们最终的载荷是单行 JSON、六键恒定:
json
{"aigc":"1","contentId":"YSTAI-20260928-a1b2c3d4","provider":"Yueshetang","model":"","labelVersion":"v1","generatedAt":"2026-09-28T10:00:00+08:00"}
三条设计决定,都是被坑出来的:
① 未知值写空串,禁止省键。 同一张图可能经两条通道导出:后端落盘、端上导出。端上导出往往拿不到模型名(model 只能是 "")。如果"未知就省略键",那么同一内容经两条通道会得到不同的键集合 ------跨端逐字节比对时无法用"键完全相等"判定,只能降级成子序列匹配,此后每次比对都要先做键集合归一。恒定六键 让判定器可以直接断言 keys == EXPECTED_KEYS。
② provider 写平台名,不写上游通道名。 这是最容易写错的一处。最初的实现里,provider 回落成了"通道名"(如 modelscope / paid)。问题在于:法规要的是服务提供者 ,对使用者而言就是平台本身;上游模型通道是供应商 。写通道名会让"后端落盘"与"端上导出"得到不同的 provider,四端取证互相矛盾。定稿是:provider 恒为平台 ASCII 名,通道与模型的明细归服务端台账,靠 contentId 回溯。
③ 非 ASCII 一律转 \uXXXX。 UserComment 的 8 字节前缀声明的是 ASCII\0\0\0,正文若写裸 UTF-8 就与声明的字符集自相矛盾。所以 JSON 用 ensure_ascii=True 序列化,中文经 \uXXXX 无损往返,任何 JSON 消费者都能解回。
4. 三个实测坑(都是"只有按规范/按像素看产物"才暴露)
这三个坑的共性是:本地"写下来再读回"的镜像式断言永远发现不了它们 。因为端上写侧与读侧用的是同一个库、同一套假设,任何"库自己不加/不改"的偏差都会对称隐藏。
坑一:UserComment 缺 8 字节字符集前缀
EXIF 的 UserComment(0x9286) 是 type=UNDEFINED 型,规范要求载荷以 8 字节字符集标识开头:ASCII\0\0\0(5 个字母 + 3 个 NUL)。
现象:安卓端(Dart image 包)导出的图,用规范读端解析时,JSON 的前 8 个字符被当成字符集吃掉,整条隐式标识读不出来。
根因:iOS 的 ImageIO 会自动补这个前缀、后端 Pillow 按同布局写、鸿蒙手写字节------只有 Dart image 包把字符串原样写成 code units、不补前缀。
修正:
dart
// 修复前:直接把 JSON 字符串写进去(读端会把前 8 字符当字符集吃掉)
// 修复后:显式补 8 字节前缀
final bytes = utf8.encode('ASCII\u0000\u0000\u0000' + jsonStr);
回归守卫要断言两件事:前缀 8 字节正确 ,且前缀后紧跟 {(证明 JSON 起点没错位)。
⚠️ 同一处还有个"手滑"版本的坑:鸿蒙端手写十六进制时写成了
ASCI\0\0\0\0(4 字母 + 4 NUL )。因为 Python 镜像脚本按规范写、ArkTS 手写少了一个I,两边就此分叉------而镜像模式的断言恰好对比的是镜像自己的字节,永远发现不了。只有"设备上真跑一遍 → 拉回文件 → 按规范解字节"才暴露。修法是从前缀字符串字面量取字节,不再手写十六进制。
坑二:generatedAt 无时区偏移、且带小数秒
现象:判定器对该字段的正则 FAIL。
根因:安卓端用了 DateTime.now().toIso8601String(),得到的是
makefile
2026-09-28T17:09:51.283023
既没有时区偏移,又带了 6 位小数秒。而契约规定的格式是:
css
^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$
修正:本地时间 + 显式 ±hh:mm + 秒精度:
dart
String _localIsoNow() {
final now = DateTime.now();
final off = now.timeZoneOffset;
final sign = off.isNegative ? '-' : '+';
final hh = off.inHours.abs().toString().padLeft(2, '0');
final mm = (off.inMinutes.abs() % 60).toString().padLeft(2, '0');
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}T'
'${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}$sign$hh:$mm';
}
为什么较真时区:
generatedAt是溯源字段。没有时区偏移的时间戳,在不同时区的读取方那里会得到不同的绝对时刻------审计时说不清"到底什么时候生成的"。
坑三:显式水印的中文全丢,只画出「AI」
现象:安卓导出的成品图右下角只有「AI」两个字,「生成 · 悦舍堂」整段是空白。逐像素核对确认是自家产物,同一位置在 iOS 上是完整的。
根因:水印文字用的是 Dart image 包自带的 arial48 位图字体,只有 ASCII 字形 (包内也未自带任何字体),中文全部画不出来。影响面是安卓独有的------iOS 走 CoreGraphics 真字体、鸿蒙走 ArkUI Canvas 真字体、Web 走 DOM/Canvas 真字体。
修正:水印文字改走 dart:ui 文本引擎 (ParagraphBuilder → Picture.toImage → 取 alpha 掩膜)再进原有的合成流程,位图字体保留为最后兜底(无 Flutter binding 的纯 Dart 环境)并大声记日志。
这个坑最值得记的不是修法,而是"为什么前几轮都没发现" :EXIF 那几条断言完全管不到显式 水印的像素;而唯一的"水印改变了像素"断言只要求
changed > 0------而「AI」两个字已经能满足它。这是典型的断言强度不足以区分"画对了"与"只画了一部分"。改进后的守卫:比对"整串墨迹宽度"与"仅『AI』宽度"的比值 ------ 只出「AI」时比值≈1,正常≈5+。从断言层面锁死"中文静默丢失"。
5. 跨端一致性断言:什么该比、什么必须排除
四端(后端 / iOS / 鸿蒙 / 安卓)都实现后,我们加了一个"逐字节比对"的门禁。这里有个反直觉的坑:如果什么都比,门禁会假红。
最终冻结的断言范围:
| 比对项 | 是否断言 | 原因 |
|---|---|---|
| 键集合 | ✅ | 六键恒定是设计决定,必须一致 |
| 键序 | ✅ | 固定键序让判定器能做"完全相等"比较 |
aigc |
✅ | 恒 "1" |
provider |
✅ | 恒平台名 |
labelVersion |
✅ | 恒 v1 |
contentId |
⚠️ 有后端下发时应一致 | 端上只搬运不生成 |
model |
❌ 必须排除 | 端上导出不知道模型名,写 "";后端写真实模型名 |
generatedAt |
❌ 必须排除 | 时间戳必然不同 |
这条"必须显式排除"是收口时才发现的第三处不一致------两端都没上报,但它同样会让逐字节比对自相矛盾。判定脚本里要把 model 与 generatedAt 显式排除,不得因它们不同就判红。
6. 幂等与重试:别让标识叠成两层
生成任务会因为网络、超时而重试。标识的写入必须按 contentId 判重:
- 若目标文件已有同号标识 → 原样返回,不二次叠加;
- 若已有异号 标识 → 替换而非叠加。
水印叠加是不可逆操作,重复叠加会让水印越来越糊、字越来越重;EXIF 段重复插入则会让文件膨胀、读端可能取到旧段。所以"幂等"不是优化项,是正确性项。
同时定一条性能红线 :如果出图通道里 PIL 已经解码过一次图像,stamp 必须复用该 Image 对象,禁止二次解码------大图二次解码的耗时是肉眼可见的。
7. 取证脚本:为什么不"信" Pillow
验收口径是「证据是读数,不是截图 」。我们写了一个独立的判定器,不依赖 Pillow / exiftool,直接按 Exif 规范解 JPEG(APP1) 与 PNG(eXIf) 里的 TIFF 结构。
不用 Pillow 的理由很具体 :Pillow 把 ImageDescription 按 latin-1 解 ------ 而"该键必须是 ASCII"正是我们要判定的那条规则本身。用一个带解码假设的库去验"编码是否越规范",容易自证自话。
核心就三层,约 120 行:
python
def tiff_from_jpeg(data):
"""扫 JPEG 段,找到 APP1 且带 'Exif\\0\\0' 前缀,返回其后裸 TIFF"""
if data[:2] != b"\xff\xd8":
return None
i = 2
while i + 4 <= len(data):
if data[i] != 0xFF:
i += 1; continue
marker = data[i + 1]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
i += 2; continue
length = int.from_bytes(data[i + 2:i + 4], "big")
payload = data[i + 4:i + 2 + length]
if marker == 0xE1 and payload[:6] == b"Exif\x00\x00":
return payload[6:]
if marker == 0xDA: # 进入压缩数据,后面不会再有 APP1
return None
i += 2 + length
return None
class Tiff:
"""按字节序标记(II/MM)解 IFD,产出 (tag, type, count, raw)"""
def __init__(self, blob):
self.blob = blob
self.e = "little" if blob[:2] == b"II" else "big"
assert self.u16(2) == 42, "坏 TIFF 魔数"
self.ifd0 = self.u32(4)
def u16(self, off): return int.from_bytes(self.blob[off:off+2], self.e)
def u32(self, off): return int.from_bytes(self.blob[off:off+4], self.e)
def entries(self, ifd_off):
count = self.u16(ifd_off)
for n in range(count):
off = ifd_off + 2 + n * 12
yield self.u16(off), self.u16(off+2), self.u32(off+4), self.blob[off+8:off+12]
有了 Tiff,UserComment 的判定就变成"取字节 → 剥 8 字节前缀 → 解 JSON → 断言键序":
python
raw = tags[0x9286]
charset, body = raw[:8], raw[8:]
assert charset == b"ASCII\x00\x00\x00", "字符集前缀不符"
payload = json.loads(body.decode("ascii"))
assert list(payload.keys()) == ["aigc","contentId","provider","model","labelVersion","generatedAt"]
这个脚本可以直接当门禁 用(非全过即 exit 1),我们对 iOS / 鸿蒙的取证产物都跑了一遍,最终 ALL PASS(14 项判定)。
顺带说一个反直觉的结论:这个"按规范读字节"的判定器,比任何"写下来再读回"的单元测试都更有价值 。上文三个坑里有两个(8 字节前缀、鸿蒙少写一个
I)都是它抓出来的------因为镜像式断言的写侧和读侧共享同一套错误假设。如果只能做一个测试,就做这个"独立读端 + 真实产物"的判定器。
8. 一个容易忽略的架构点:四个通道的"字节持有"不同
标识该加在后端还是前端?取决于谁手里有图片字节:
| 通道 | 后端是否持有字节 | 标识落点 |
|---|---|---|
| 出图以 data URL 直返 | ✅ 内存中 | 后端 |
| 落盘到对象存储 | ✅ 落盘前 | 后端 + 对象元数据 |
| 第三方直连(只有上游 CDN URL) | ❌ 只有 URL | 只能前端在导出咽喉叠加 |
| 文本类(问答 / 文案) | ✅ 落库 | 后端台账 + 端上角标 + 导出带 |
第三方直连那条是关键 :后端只有 URL、没有字节,所以前端导出时必须叠加标识 ------这是前端"省不掉"的根因。我们最终在前端把"导出咽喉"收敛成单点(下载 / 保存 / 写文件三个出口共用一个函数),一次改动覆盖十几个调用点,而不是散落在各个页面。
9. 复盘:合规标识的工程难点在哪
回头看,这件事花掉的时间远超"写几个字段"的预期,难点集中在三处:
- 规范细节 :
UserComment的字符集前缀、ImageDescription的 type=ASCII 限制、generatedAt的时区要求------每一条都有"看起来对、读出来错"的写法。 - 跨端一致 :多端并行时,必须有一份冻结的契约(字段名、取值、编号格式、角标规格全部锁定,各端禁自创),否则商店审核会以"标识不规范"打回。
- 验证手段 :必须有一个不信任何库、只信字节 的独立读端,且要拿真实设备产物跑,而不是本地镜像测试。
如果只让我留一条经验:把"文件级读数"当作唯一验收标准,别用截图和源码自证 。截图证明不了元数据,源码里的 write(exif=...) 也不等于文件里真有那条 EXIF------中间隔着重编码、IFD 落点、字符集前缀三个坑。