news 2026/9/19 0:38:14

用ProtectedInt保护游戏内存数值:对抗CE精确扫描的客户端反作弊方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用ProtectedInt保护游戏内存数值:对抗CE精确扫描的客户端反作弊方案

前阵子在一个独立开发群里,看到有人贴了张截图:单机 RPG 的金币数量变成了 999999999,配文是“你们做的游戏是不是纸糊的”。乍看像玩笑,但做过客户端数值的人心里都清楚,他说得不算夸张——我们的金币、血量、经验值,在内存里绝大多数时候就是裸奔的 int,谁拿内存扫描工具都能轻轻松松改出一片天。

今天想聊的,就是怎么给这种“裸奔”上个锁。我这里的做法是用一个 struct 把关键数值包起来,做成一个 ProtectedInt。它不需要服务端权威校验,也不需要把整个游戏改成网络同步,就能在客户端层面挡住大多数“内存搜索—定位—修改数值”的常规作弊套路。尤其适合独立游戏、弱联网项目,或者团队里暂时没人力做完整反作弊体系的阶段。这篇文章会把设计思路、struct 为什么适合干这件事、具体代码实现和接入边界一次说清,你可以直接拿回去改造成自己的版本。

先从头拆解一个问题:为什么一个 int 在内存扫描器面前这么容易被人拿捏?这决定了我后面为什么要坚持把它变成一个 struct,而不是继续用一个原生 int。

1. 一开始的痛点:内存扫描为什么能三分钟定位一个 int

1.1 “改个金币”的完整链路

我见过不少刚入行的同学以为,反作弊是服务端的事,客户端只要把数值显示出来就完了。实际上单机或弱联网项目里,客户端内存才是玩家修改的重灾区。

最常见的工具就是 Cheat Engine 这类内存扫描器,操作套路几乎固定。第一步,游戏里记录当前金币是 999,工具里选“精确数值 999”,扫描整个进程内存。结果通常是几十万甚至上百万个地址。第二步,让金币变化一次,比如买瓶药水变成 899,再用 899 扫描一次,地址数量立刻掉到几百个。重复三四次之后,剩下的唯一地址往往就是你要找的那块内存。

这还没完,更专业的玩法是“找到是什么改写了这个地址”。CE 可以附加调试器,然后你回游戏里再让金币变化一次,它会告诉你具体是哪条指令、哪个模块里的代码写入了这个地址。到这一步,玩家已经可以做到:先用外挂脚本直接监听该地址,任何情况下只要金币一变就自动改回最大值。

整个链路里最关键的一环是什么?是游戏在内存里直接存放了一个“真实的 999”。扫描器根本不关心你变量叫什么名字、放在哪个结构体、有没有 offset,它只认数值本身。只要你把 raw 值裸露在堆或栈上,用 CE 的反复扫描就能把它挖出来。

1.2 为什么裸 int 和裸 float 面对扫描毫无还手之力

要说清楚这个问题,得回到 C/C++ 里 int 的存储本质:一个 4 字节的二进制补码,按小端序直接放在内存地址上。float 也一样,按 IEEE 754 位模式存放。这意味着任意一个第三方工具,都可以把进程内存当成一个大数组来读,然后按 4 字节为单位去比对“是不是 999”。

更关键的是,普通 int 没有任何“自校验”能力。它不会知道自己被谁改了,也不会在读取时怀疑自己的值是不是合理。你把 100 改成 10000,它照样能参与后续逻辑,甚至代码里那个if (hp <= 0)在多数情况下还能被绕过——因为玩家直接把血量锁死了。

所以,在客户端这种攻击者持有完整内存读写权限的环境下,防护思路不能是“藏住变量”——因为数值扫描的本质是扫值,而不是扫名字。真正有效的思路只有一条:让“内存里存的东西”不再等于“逻辑上显示的东西”。也就是说,哪怕攻击者把整个进程内存翻个底朝天,也找不到一个明文的 999 或 100。

而这,正好是 struct 能干的活。

2. 拆解 ProtectedInt 的设计:为什么 struct 是最合适的“锁壳”

2.1 核心目标:让内存里的数值不再是“数值”

我的设计目标非常明确,就三句话:

  • 内存中不能直接出现明文的关键数值。
  • 每次写入时都使用随机密钥对数值做混淆,即使同一个值连续写入多次,内存中的样子也要完全不同。
  • 读取时先解密,再校验,发现异常能检测出来,而不是傻乎乎地把错误结果交给逻辑。

基于这个目标,一个 int 明显不够用。我需要一个复合类型:一块区域放“密文”,一块区域放“密钥”,一块区域放“校验和”。三块信息必须作为一个整体出现,不能分散得让我每次使用都要手动传递关系。这就是 struct 出场的理由。

可能有人会想,那我用两个普通成员变量,比如uint32_t goldKey; uint32_t goldCipher;不也行吗?行是行,但每一处逻辑都要自己维护这两个变量的关系,写着写着你就会忍不住封装。封装完了,它本质上就是一个 struct,只是你没给它起名而已。不如一开始就把它变成一个正式的类型,还能用类型系统约束住:谁想往里传一个普通 int,编译器就帮他编译不过去。

2.2 为什么选 struct 而不是 class:C 语言 struct 用法的真正价值

这可能是整篇文章最值得展开的一处。C++ 里 struct 和 class 在语法层面只有一个区别:默认访问级别不同,struct 默认 public,class 默认 private。很多人因此觉得 struct 就是“弱化版 class”,但对这个场景来说,struct 有着非常现实的工程价值。

第一,struct 更贴近 C 语言的“数据布局”思维。做过网络封包解析的人应该都有体会:C 的 struct 本质是“按声明顺序排布的内存片段”。字段和字段之间有明确的内存偏移,编译器按照对齐规则给你排好,你甚至可以用 offsetof 拿到某个成员的偏移量。反作弊场景最需要的就是这种确定性:我知道我的密文放在第 0 个字段,密钥放在第 4 个字节,校验放在第 8 个字节,我可以对整体布局做静态断言,防止有人不小心改乱顺序。

第二,struct 保持了值语义。它没有虚表指针,没有 RTTI 信息,就是一个纯数据包。这意味着它可以方便地放进数组、放进容器、作为另一个结构体的成员,甚至通过 memcpy 在缓冲区之间搬运。虽然我不想建议你对带校验的结构体直接 memcpy,但它的可复制性确实让它在大型实体系统里的嵌入成本非常低。

第三,它在 C 和 C++ 混合项目里是天然的边界。如果你的引擎有底层模块用 C 写,或者你想把 ProtectedInt 的数据用在网络封包、存档结构里,struct 比 class 更不容易让底层代码产生抵触。C 语言里 struct 用法就是“字段的集合”,你自己写几个自由函数pi_load(struct ProtectedInt*)pi_store(struct ProtectedInt*, int32_t)也能做到类似效果,但在 C++ 下,把它升级为带成员函数和运算符重载的 struct,既有 C 语言的布局底气,又多了一层使用便利。

我选 struct 不是因为它比 class 更“高级”,恰恰相反,是因为它足够朴素,适合干这种“包一层格式”的事。

2.3 ProtectedInt 的三件套:密文、密钥、校验

基础设计是下面这张表:

字段作用攻击者在内存里看到的样子
m_cipher密文,等于 明文 XOR 密钥一个看似随机的 uint32
m_key每次写入随机生成的混淆密钥一个看似随机的 uint32
m_checksum对明文生成的完整性校验依赖明文的另一个 uint32

读取时执行反向运算:密文 XOR 密钥得到明文,再对明文算一次校验和,跟存储的 checksum 比对。如果被人直接改写了密文字段,或者把整个结构体用错误数据覆盖,校验都会失败。这个设计不过度复杂,但已经足够打掉“精确数值扫描”这种最原始的作弊手段。

3. 动手实现:字段布局、校验计算和运算符重载的细节

3.1 一个可以实际编译的 ProtectedInt

下面这段代码是这个方案的核心,我直接用 uint32_t 来承载数据和密钥。它比我见过的一些“随机加个固定异或”的玩具实现要完整得多,你可以先跑起来再改。

// ProtectedInt.h #pragma once #include <cstdint> struct ProtectedInt { ProtectedInt(int32_t init = 0) { store(init); } int32_t load() const { uint32_t plain = m_cipher ^ m_key; if (m_checksum != calcChecksum(plain)) { // 发现篡改或内存被错误覆盖 const_cast<ProtectedInt*>(this)->onTampered(); return 0; } return static_cast<int32_t>(plain); } void store(int32_t v) { m_key = randomKey(); m_cipher = static_cast<uint32_t>(v) ^ m_key; m_checksum = calcChecksum(static_cast<uint32_t>(v)); } // 让外部代码用起来像普通 int ProtectedInt& operator=(int32_t v) { store(v); return *this; } operator int32_t() const { return load(); } ProtectedInt& operator+=(int32_t v) { store(load() + v); return *this; } ProtectedInt& operator-=(int32_t v) { store(load() - v); return *this; } ProtectedInt& operator++() { store(load() + 1); return *this; } ProtectedInt& operator--() { store(load() - 1); return *this; } bool operator==(int32_t v) const { return load() == v; } bool operator!=(int32_t v) const { return load() != v; } private: uint32_t m_key; uint32_t m_cipher; uint32_t m_checksum; static uint32_t randomKey() { // xorshift64:比 rand() 快,且避免 rand() 被低质量种子猜出规律 static uint64_t s = 0x9E3779B97F4A7C15ULL; s ^= s << 13; s ^= s >> 7; s ^= s << 17; return static_cast<uint32_t>(s); } static uint32_t calcChecksum(uint32_t v) { // 乘法散列 + 高低位混洗,代价极低,但足以挡住低级修改 return v * 2654435761u ^ (v >> 16); } void onTampered() { // 项目里可以在这里打日志、上报统计,或进入安全降级状态 } }; static_assert(sizeof(ProtectedInt) == 12, "ProtectedInt 应保持 12 字节,布局不能随意改动");

解释几个实现选择。

  • 为什么用随机密钥而不是固定常量?因为如果所有玩家、所有存档的钱都用同一个 key 去异或,那只是把明文“平移”了一下,内存里虽然看不到 999,但同一个游戏的同一笔数值加密后的字节是完全固定的,攻击者截图对比一下就能总结出规律。每次写入重新产生随机 key,同一数值在不同实例、不同时刻的内存形态都不同,扫描器就很难直接匹配。
  • 为什么校验用乘法散列,而不用那些加密哈希?因为这里防的是“顺手改内存”,不是防逆向高手。性能代价要足够低,低到哪怕每帧执行一万次也毫无压力。乘法散列加一点位混洗,已经能让普通修改者用 CE 直接改出来的值大概率校验失败。
  • static_assert 是有意为之。我见过同事好心在结构体里加了一个bool m_corrupted标记,结果把整个结构体撑到 16 字节,还引入了 padding,后面序列化时直接踩坑。锁定大小和布局,能在一开始就避免这类问题。

3.2 读取流程:解密、校验、容错的三步舞

读数值时,代码严格按照“解密 → 校验 → 返回”的顺序走。先用 m_cipher XOR m_key 还原出明文的位模式,然后立即用同一个明文位模式计算校验和。

if (m_checksum != calcChecksum(plain)) { ... }

这个顺序非常关键。你不能只用 cipher 和 key 的关系来验证,因为攻击者如果同时改了 cipher 和 key,异或出来的结果照样可能是他自己想要的值。判断标准必须集中在明文维度上。

当校验失败时,我选择返回 0 并调用 onTampered 做记录。有些团队会想在失败时抛异常或者断言崩溃,但我不建议。原因很简单:客户端环境里触发校验失败的原因可能是插件冲突、内存覆盖 bug,也可能是攻击者故意破坏。如果你直接崩溃,攻击者就有了一个稳定的 DoS 入口;如果你直接返回一个安全默认值,至少游戏还能跑,只是数值不会按他的意愿走。线上项目里,对篡改的反馈最好是“日志 + 降级”,而不是“崩溃”。

3.3 运算符重载:让旧代码“感觉不到”自己换了个类型

我们在设计时最舒服的一点,是运算符重载把使用成本压到了最低。原来写player.hp -= damage;,现在还是写player.hp -= damage;,不需要到处改成 getter/setter。

但使用隐式转换要留个心眼。比如if (hp > 50)这种表达式,因为 ProtectedInt 提供了operator int32_t(),编译器会先调用 load() 得到明文,再参与比较,这是没问题的。但如果一段代码非常密集地读取某个 ProtectedInt,比如伤害公式里写了a + hp * b - hp / c,那么 hp 会被 load() 好多次,每次都是三条内存 load 加上异或和校验,性能损耗会被放大。

一个很常见的做法是:热点逻辑里,先把 ProtectedInt 一次性读成局部 int 变量,后面的计算全部用局部 int 做,最后再把结果写回 ProtectedInt。反作弊的意义并没有丢,因为“存档状态”和“显示状态”是锁住的,只是计算过程里不再重复解锁。

3.4 struct 与纯 C 环境的互操作思路

如果你所在的项目有大量 C 代码,或者你只是想沿用 C 语言 struct 的经典用法,可以保留这个头文件里的纯数据部分,把成员函数去掉,换成一组自由函数:

struct ProtectedInt { uint32_t key; uint32_t cipher; uint32_t checksum; }; int32_t pi_load(struct ProtectedInt* p) { uint32_t plain = p->cipher ^ p->key; if (p->checksum != pi_checksum(plain)) return 0; return (int32_t)plain; } void pi_store(struct ProtectedInt* p, int32_t v) { p->key = pi_random_key(); p->cipher = (uint32_t)v ^ p->key; p->checksum = pi_checksum((uint32_t)v); }

本质上做的同一件事。这正好说明了我前面那一点:struct 的跨语言能力来自它天然贴近 C 的内存布局,加不加成员函数只是风格差异,不影响它成为“锁壳”的价值。

4. 真实项目接入:替换字段、迁移路径、性能代价和踩坑

4.1 哪些字段值得上锁:先列清单,别全量替换

我不是建议你把项目里所有 int 都换成 ProtectedInt。全量替换会带来额外读取成本,而且会让序列化、存档、网络包的处理变得非常繁琐。我实践下来的优先级是这样的:

优先级字段类型原因
金币、钻石、积分、血量、魔法值、经验值玩家最关心的核心数值,修改收益最大
剩余抽奖次数、每日任务进度、体力值弱联网项目里这些常作为“本地防重刷”的关口
坐标、速度、动画状态每帧读取频率太高,而且服务端通常能校验位置合理性
不推荐临时循环变量、局部变量生命周期短,改了也影响不了全局状态

我只会在血量、金币、经验、体力和任务进度这几个关键字段上使用 ProtectedInt。其余普通 int 保持原样,代码可读性也更好。

4.2 迁移路径:把 Player 里的 int 字段换成 ProtectedInt

假设原来有这么个结构:

struct PlayerSaveData { int32_t hp; int32_t maxHp; int32_t gold; int32_t exp; int32_t potionCount; };

接入时我直接把字段类型替换一下:

struct PlayerSaveData { ProtectedInt hp; ProtectedInt maxHp; ProtectedInt gold; ProtectedInt exp; ProtectedInt potionCount; };

其余代码绝大多数情况不用动。因为 ProtectedInt 提供了operator int32_t()和赋值运算符,player.gold += 500;if (player.hp <= 0)这些写法都能继续编译。

但有几个坑必须提前知道:

  • 如果代码里有int32_t* p = &player.gold;这种取地址操作,ProtectedInt 不支持直接转成int32_t*,你需要提供类似int32_t rawValue() const的接口,并接受“取到的是一个临时快照,不是原地引用”。
  • 如果某些函数签名是void levelUp(int32_t& hp),传 ProtectedInt 进去会失败,因为引用不支持隐式转换。我一般会改成传值或传 const 引用,或者专门重载一个版本。
  • 如果你用 C++ 的offsetof或者反射系统去遍历结构体字段,ProtectedInt 的加入会让字段偏移变复杂。这也是我通过 static_assert 锁定 12 字节大小、避免 padding 问题的原因。

4.3 复制语义是个大坑:默认拷贝会让两个对象共享同一套密文

默认的拷贝构造函数会把三个字段原样复制过去,这意味着你如果复制了一个带 ProtectedInt 的实体,两个对象拥有完全相同的密文、密钥和校验和。这在数据完整性上没问题,但在反作弊层面会露出一个破绽:CE 扫描器很容易发现“两个不同对象的内存区域里有一段完全相同的数据”,从而推断它们共享同一个初始值,进而批量修改。

我现在的做法是显式实现拷贝构造,让每个实例复制后重新生成密钥:

ProtectedInt(const ProtectedInt& other) { store(other.load()); } ProtectedInt& operator=(const ProtectedInt& other) { if (this != &other) { store(other.load()); } return *this; }

这样每个实例的加密形态都是独立的,CE 就算扫到两个相同血量的对象,看到的密文也是不一样的。代价是拷贝时多了一次 load 和一次 store,但正常游戏逻辑里复制对象的频率远低于读取字段,完全划算。

4.4 序列化和存档:绝不能用 memcpy 直接拷贝

这里是我踩过最冤枉的一个坑。早期为了存档方便,我写过fwrite(&playerSaveData, sizeof(playerSaveData), 1, fp)。第一次用 ProtectedInt 后没注意,存档只读后要么校验失败、要么数值变成了随机的 0。

原因很明显:存档文件里存的是“当时的密文和密钥”。页面重启后,一旦 Key 重新生成,旧密文就解不出正确结果。这还不算最严重的,最严重的是如果你把存档放在用户可控的目录里,玩家直接用十六进制编辑器就能看到完整的三元组结构。

正确做法是序列化时调用 read()/store():

// 存档时 int32_t hp = saveData.hp; // 通过 load() 拿到明文 int32_t gold = saveData.gold; WriteToArchive("hp", hp); WriteToArchive("gold", gold); // 读档时 archive.Read("hp", &tempHp); saveData.hp = tempHp; // 通过 store() 重新加密

这样存档文件里永远是明文或你的自定义加密格式,不会把运行时混淆逻辑直接暴露出去。

4.5 性能与缓存:实测下来的大致开销

我不打算给你一个精密到增量的基准测试,因为不同平台差异很大。但可以说一个量级感受:ProtectedInt 的 load() 大概是 3 条内存 load(key、cipher、checksum)+ 1 次异或 + 1 次乘法散列 + 1 次比较;store() 大概是 3 条内存 store,外加一个 xorshift64 的移位更新。这个开销比一次系统调用小几个数量级,在 99% 的业务逻辑里都可以忽略。

真正需要注意的是缓存友好性。三个字段连续排列在一个 12 字节的小块里,通常都会落在同一缓存行内,这非常好。但如果你把 key 单独放到另一个内存池,或者把校验和放到结构体最远端,load() 就可能变成跨缓存行访问,性能会明显变差。所以我的建议是:保持三件套紧挨着,不要为了“更复杂”就把它们拆散到不同地方。

如果你有一个每帧遍历十多万个实体的系统,那也不要直接在循环里频繁读取 ProtectedInt。更稳的方法是在帧开头把所有 ProtectedInt 一次性读出到临时数组,业务逻辑基于临时数组跑,帧末再统一校验和写回。

5. 诚实边界:这把锁能挡住谁,挡不住谁

5.1 它真能防住的:CE 精确扫描、常见修改器、数值锁定

对比原生的 int,ProtectedInt 至少能做到三件事:

  • CE 的“精确数值扫描”直接失效。内存里根本没有明文 999,你搜不到。
  • “未知初始值扫描”加“变更扫描”这种更聪明的方式,也只能定位到那 12 个字节的区域,但无法直接判断哪个是明文。玩家看到满屏随机数据,通常就放弃了。
  • “锁定数值”类的功能会失效。因为每次写入都会重新生成密钥,如果你把某个地址锁死为固定值,它反而会被下一次写入破坏掉,校验和也会立刻偏离,最终读出来的要么是错误结果,要么触发 onTampered。

这个防线能拦住至少八成靠现成工具做修改的玩家。这些人不是逆向工程师,他们只是熟练使用搜索引擎和 CE 的普通用户,遇到“搜不到值”基本就到此为止了。

5.2 它防不住的:读进程内存配合逆向、函数 Hook、内存补丁

要诚实地说,这个方案不是银弹。

如果攻击者具备逆向能力,他会怎么做?他会先 dump 出进程内存,找到 ProtectedInt 的三个字段,观察游戏读取和写入时的汇编指令,分析出“密文 XOR 密钥”的逻辑。因为异或加密的本质是可逆的,只要分析出 key 的生成算法,他完全可以写一个小脚本批量扫描内存里的密文,自动还原所有明文。

再进一步,如果他 Hook 了 load() 或 store() 函数,或者在解密后的返回值上做文章,ProtectedInt 本身是防不住的。它只在“内存数值不能直接被看明白”这个层面有效,并不能阻止攻击者修改游戏逻辑。

还有一点很重要:它防不了“速度类”外挂。如果你把一个数值从 1 改成 100,这种修改通过校验能拦住;但如果你让游戏循环每秒多跑十倍,金币获取速率提高了十倍,ProtectedInt 完全感知不到,因为合法写入路径永远是正常的。

5.3 正确的定位:它是客户端反作弊的第一道门槛,不是最后一道

我自己的项目里,ProtectedInt 只是“第一道门槛”。它把廉价作弊的性价比降到最低,让修改成本高于收益,就已经完成了任务。后面真正关键的是服务端校验和游戏逻辑的合理性检查:比如玩家一局最多产出多少金币,如果单次上报超过这个阈值就判定异常;比如血量变化曲线是否符合战斗记录,如果出现凭空回满就触发风控。

对独立游戏和弱联网项目来说,这套组合已经够用:本地用 struct 加锁,让内存不可直接读;服务端做数值合理性校验,让修改过后的数据不容易被接受。

最后一个真实经验是:不要试图用这个方案去保护“每帧都在变化的速度、坐标、状态位”。那类数据频率太高,锁起来会严重影响性能,而且如果真的被改,服务端也能通过位置合法性检测发现。把有限的精力花在金币、血量、体力、任务进度这些“静态收益型”数值上,回报是最高的。如果你正被“本地数值被工具改掉”这种问题困扰,从换一个 ProtectedInt 开始,是性价比极高的一步。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 0:36:28

IDEA离线工作模式全解析:让开发在无网环境下流畅运行

1. 离线模式并不是“断开网络”&#xff0c;而是把这三层全部按住很多人在配置 IDEA 离线工作模式时都会踩同一个坑&#xff1a;把系统网络断开&#xff0c;或者把 WiFi 关了&#xff0c;然后打开 IDEA 继续干活&#xff0c;结果发现项目要么起不来&#xff0c;要么明明本地有依…

作者头像 李华
网站建设 2026/9/19 0:35:39

Jupyter Notebook网页打不开?端口、localhost与内核排查

深夜十一点半&#xff0c;你敲下jupyter notebook&#xff0c;终端很快吐出一行地址&#xff1a;http://localhost:8888/tree?token...&#xff0c;然后光标一闪一闪地停在那儿。你等着浏览器自己蹦出来&#xff0c;等了十秒&#xff0c;屏幕纹丝不动&#xff1b;手动把地址粘…

作者头像 李华
网站建设 2026/9/19 0:35:39

Technology Stack Versions

Technology Stack & Versions 【免费下载链接】BMAD-METHOD Breakthrough Method for Agile Ai Driven Development 项目地址: https://gitcode.com/gh_mirrors/bm/BMAD-METHOD Node.js 20.x, TypeScript 5.3, React 18.2State: Zustand (not Redux)Testing: Vitest…

作者头像 李华
网站建设 2026/9/19 0:31:52

Unicode、码点与编码单元:从乱码到UTF-8工程实践

接手一个从 GBK 迁到 UTF-8 的老项目&#xff0c;最让人上火的往往不是迁移本身&#xff0c;而是那些"以为已经改完"的角落&#xff1a;数据库里躺着一排问号&#xff0c;配置文件第一行多了个看不见的字符&#xff0c;前端明明限制了 20 个字&#xff0c;用户却能塞…

作者头像 李华
网站建设 2026/9/19 0:30:51

ISO/IEC 17025:2017换版实战:条款对照、数字化台账与内审检查表

简介&#xff1a;这是一份面向实验室管理人员、质量负责人及检测校准技术人员的ISO/IEC 17025:2017标准中文培训教材&#xff0c;用于系统学习实验室能力、公正性与持续运作的通用要求&#xff0c;也适合准备CNAS认可、编写体系文件或开展内审与管理评审的从业者作为参考。资源…

作者头像 李华