Logo

site iconInnei | 拾一

数字游民,NodeJS全栈开发,前支付宝、小红书。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Innei | 拾一 RSS 预览

我是如何使用 AI 辅助创作的

2025-12-28 20:29:16

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/how-to-use-ai-for-assisted-creation

如标题所见,在最近的编写的一些文章中,我使用了 AI 的辅助。其实早在过去一年编写的手记中,也常规的使用这个方式,只不过更多的用于标题的生成,我对文章的取名一直非常头疼,后来就写完整篇之后让 AI 综合全文的内容帮我提取几个关键字来取几个文艺点的标题。

在最近编写的文章中,如那篇「回头看见自己」中基本都是 AI 辅助编写的。我在「此站点」中增加了一个 Q&A:

在 25.11 月发布的文章都会公开:在编写文章时是否使用了 AI 的辅助。

可能很多人会反对使用 AI 进行创作,编写博客等等。那么当你看到文章顶部的 AI 声明之后你有权利选择直接关闭。

我个人并不反对使用 AI 辅助写作,当编写技术文章时,我可以借助 AI 帮我快速根据相关实现绘制流程图,以便读者更加清晰的理解,而在前 AI 时代,则往往需要花费大量的时间,或者因为这个理由而放弃绘图。当编写生活记录时,我往往借助 AI 帮我取一个标题,我承认我不是一个大作家,写不出更好的文笔或者叙事结构,我想记录故事,而 AI 帮我整理故事。

编写这篇文章首先是澄清关于 AI 的使用,另外也同时分享如何使用 AI 写出更好的文章。

提效 or 咬文嚼字

首先是一个问题,你认为使用 AI 辅助创作会节省大量时间吗?至少对我来说,需要分为两个维度。

在编写技术类文章时,确实会大幅节省,而且比手作的内容会更加完整。正如前面所说,在手作的时候,在画流程图会花费大量时间,往往会因为时间成本而放弃;在内容结构上也会精简掉很多。而借助 AI ,它可以阅读项目的代码,再加上你告诉他当时的实现思路,它可以很迅速的分析然后编写出叙事清晰的文字、流程,甚至使用 excalidraw 绘制图表。

在编写手记生活类文章,并不会节省时间,甚至会花费很多的时间,至少对我来说是这样。AI 并不知道你的生活发生了什么,生活不是代码,无从读起。我会把所有的经历说给 AI 听,这个过程中,相当于我已经完成了一遍文章的编写。在没有借助 AI 之前,差不多到这个时间就截稿了。取上标题和分段,就直接发布了。而现在,需要让 AI 结构,整理,调整上下文过度。然后我还要校对几次确保没有把我需要表达的内容曲解。有些措辞我感觉不好,就会多次询问 AI 修改等等。花的时间不止一点。

技术类文章辅助

在编写 Better Auth 的多租户用户鉴权的构想 文章时,初次使用借助 AI 辅助作图的能力。比如解释为什么的时候,可以画一个图:

不得不说这图画的比我好太多了

再加上文章中有很多 Demo 代码,实际上都是实际业务中的代码抽离出来删掉很多多余的逻辑的,这个过程如果手动去改的话还挺容易删出问题,而且还需要增加注释和删除代码后的格式化问题,之前很多 demo 代码我都懒得管。而 AI 通过读项目,可以非常快速的写完 demo 代码,并且加上注释:

生活类文章辅助

在今年的年终总结 2025 · 仍在路上,半径之外 中,使用了 LobeHub 进行故事整理。

在开始之前我使用内置的 Agent Builder 构建了一个专门的写作助手:

随后在文档中进行内容创作:

和 Agent 讲故事

我会分段的和 Agent 讲故事,他会把故事写到文章里。你会发现这个过程中,我自己也会完全写作一遍,我的陈述和最后的插入的内容基本也是一致的。

在最后的过程中,可以进行小标题的重构:

然后进行一次 review,把很多段落重新调整,阅读一遍,发现有些措辞不太好,但是也不知道怎么改会比较好,我会询问 GPT。

找到一种我想要表达的方式,而不是过分的曲解。

再比如取名:

在这个案例看来,这篇年终总结所花费的时间比往年的更长,故事叙述大概花了 4 个多小时,然后是结构整理,改一些过度承接,取名等等。

感谢你能看到这里,此篇文章为手作。

看完了?说点什么呢

2025 · 仍在路上,半径之外

2025-12-27 23:38:02

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/205

车与自由

去年年底提了车。今年年初,家里人都回来了,我也正好需要磨练车技,就带着他们去了周边很多地方。

买车这件事我一直觉得离我很远。那时我已经远程工作快两年,住在小县城里也基本足不出户,实在想不出它能带来什么改变。无非是带来更大的花销,生活又多一笔隐形的负担。

有车之后,最大的变化是生活不再局限在一亩三分地:我拥有了随时出门的自由。往年我也经常往返于杭州、上海,和从前的同事、朋友见面,但这些安排总得提前几天买好车票、把行程安排妥当。现在最好的地方在于,我不必再提前计划。而现在可以只是一个临时的念头,就能立刻出发,对我这种 P 人来说实在太友好了。偶尔也可以玩到很晚,不需要因为赶不上定的高铁票而在外面借宿一晚或者被时间限制不得不在约定的时间提前结束活动。我可以在凌晨上高速,安静地把车开回去,顺便体验夜路驾驶的乐趣。

今年第一次出国旅游,目的地是新加坡。第一次出国的新鲜感很强。新加坡华人很多,基本都说华语,没有什么语言障碍;好吃的也多,好玩的也多,算是开眼看世界了。旅行本身没有太多戏剧性,更多是把自己从熟悉的坐标系里挪开一下,同样的生活节奏,在别的城市里会显得轻一点。

https://innei.in/notes/199

长焦

今年买了一台人生第一台相机,富士 XT-5,开始走上摄影的败家之路了。

由于买不到套机,我是加价买的16-50 的套头,再加一个 35 定。用了一阵子之后,很快就意识到焦距不够长。没办法拍到远处的东西,感觉还不如手机。于是又添了一支 70-300。结果买了没几天就撞上 618,价格直接掉了 1k,心在滴血。但事已至此,也只能用一句“早买早享受”给自己收尾。相机到手后的第二天就去了湖州拍照。也正是在那次出行里,我意识到长焦有多适合我这种 I 人,主要是拍妹妹更好了(不是)。

有了相机之后,也开始 push 自己多出去走一走了。背上相机,记录一些原本会被忽略的静止瞬间,也会多留意身边的事物了。七月,时隔一年再次来到南京,这次停留得更久。去了朋友即将毕业的大学校园,也去了朋友即将“毕业”的小米园区;夜里在老西门闲逛,白天去了红山森林动物园。在城市里慢慢走、慢慢看,相机也成了我重新认识一座城市的方式。

https://innei.in/notes/195

随着去的地方越来越多,我也开始意识到,需要一个属于自己的空间来存放这些被记录下来的瞬间,需要一个像照片的个人博客一样的地方。

加速度

印象很深的是,那正好是五月份,Sonnet 4 的发布,在编码能力和工具调用能力都有了明显提升,「Vibe Coding」这个说法也开始出现。第一次使用这个模型时的感觉非常惊艳:只需要非常简短的描述,就能生成一个完整、可用的简单项目。

也是在那个时候,我完成了第一个大部分代码都由 AI 完成的项目,Afilmory。

https://github.com/Afilmory/Afilmory

有了 AI 之后,很多以前不敢想的事情都变成了现实。比如在 Afilmory 这个项目里,通过多次和 AI 反复探讨实现细节,最终做出了一个支持超高像素的 WebGL 图片预览器,这在过去几乎是不太敢轻易尝试的事情。

后来随着越来越频繁地使用 AI,我也慢慢想明白了一件事:我之所以一直喜欢写代码、喜欢做 ToC 的项目,并不是因为代码本身有多迷人,而是因为我享受把一个想法打磨成产品的过程,反复调整细节,直到它真正好用。现在有了 AI,这个过程的门槛和成本被明显降低了,我可以更快地把想法做出来,让它跑起来,并且产生真实的价值。

从那之后,我也算是真正“开上了自动挡”。到现在为止,已经借助 AI 完成并上线了不少项目:比如 Torrent Vibe,还有后来重构成 SaaS 的 Afilmory 也算是一个真正意义的比较大的项目,还有一些摄影相关的小工具。这些项目都已经跑在生产环境里,并且运行得相当稳定。

今年也是见证了 AI 的飞速发展,模型厂商内卷,几乎每个月都有更好用的模型出来,还有针对编码场景的特调版本。前半年在使用 AI 的时候,还需要编写完整的流程,探讨编码计划,一步一步引导式,最后甚至要写一个 Plan;后来进化到使用上下文工程,开始写 PRP。到了后半年 CodeX 的出现,它可以长时间执行一个任务,通过探索所有相关代码一路跑下去。我第一次用它的时候,一口气跑了一个小时最后完美实现需求,那种感觉很难不被惊艳到。凭着非常优秀的上下文和压缩能力,我一度觉得它的上下文高得有点可怕,基本不需要我再刻意去感知它。

一不小心又说了太多 AI 的故事了,容我控制下情绪。

工匠

年初花了一两个月做 React Native 的移动端 App。为了打磨细节,也顺手研究了不少 iOS native 的东西,甚至手搓了一些原生模块,只是为了性能能更好一点。我还自己实现了一套 React Native 的 Navigation,底层是基于 React Native Screens。初衷很简单:大家都在用的 React Navigation 那套声明式写法会影响后面的 DX,我总觉得它应该更接近 iOS UIKit 的 Navigation,所以就自己对齐了一遍。前半年 AI 还没现在这么能打,很多东西基本都是手搓,时间和精力确实花了不少,但也挺有意思的。

https://innei.in/posts/tech/build-simple-navigation-with-react-native-screens

工作这块,一整年基本还是围着一个 RSS Reader 打转。后面有一段时间,我几乎把所有精力都在 RSS Reader 的 AI 实现部分上。其实很早就能放 Demo 出来,但效果一直不满意,就这么拖着,迟迟没有发布。前前后后折腾了半年,最后效果也并不好。

这半年倒也不算完全白忙。我学到了不少 AI 相关的知识,也认识了一些业界有名的大佬,和他们的对话里学到很多。这也是一个契机,以至于后面我加入了一个 AI 的创业公司。

后面的故事就没那么顺利了。熟悉我的人大概也知道是怎么一回事。如果不知道,也没必要刻意去吃瓜了。

https://innei.in/notes/204

总之我去年被裁了,今年又被裁了。我是什么裁神爷,一年干黄一个项目。

又回到风里

后来我又加入了一家创业公司。我还是很喜欢创业公司的,即便已经是工作三年的老登了,仍怀着创业的热情。

为什么说是“又”:短短 5 年里,我加入了 4 家创业公司,其中有一家实习了一年半;剩下两家,一家呆了一年,一家呆了一年半不到一些。如果可以的话,我也想一直干下去。

我也在中大厂待过一段时间,所以我很清楚自己喜欢什么样的氛围。至少现在,初心犹在。

我没有把“被裁”当成故事的结尾。我还不太会安稳,只能让自己不停往前走。做点事,心里才踏实。

败家

今年也买了不少设备。除了 XT-5 和一堆镜头之外,也换了手机。一年一换几乎成了常态,真的有点败家。

还买了一台投影仪,现在终于可以躺在床上追剧了。

电脑也换了:Mac Studio M4 Max,128G 顶配内存。买之前想了很久,一直犹豫。后面因为要跑大项目,咬咬牙直接上了顶配。现在开发同时起四五个项目都不卡,太值了。

我人生里一共拥有过四台苹果电脑。这台是我人生中购入的第二台苹果电脑。

第一台是 MacBook Pro 16 的一款 Intel 芯片机型,那是我刚进入社会实习时老板发的 bonus,也算是开启了我后面的开发之路,我很感激他。

第一台 Mac Studio 是我在第二家创业公司时送的,一直陪我开发到现在,也很感谢它。

而我自己买的第一台是 MacBook Air M2,到现在已经三年多了。我平时在家的时间多,所以也一直没有换。

这些机器堆在一起,像是把我的几年拆成了几段:有人给过我起步的机会,也有人在路上借我一把力。我不太擅长表达,但都记得。

剧与游戏

今年看了不少剧,大部分都是韩剧。最近还在追《模范出租车》第三季。

也玩了一些 3A 大作,比如《死亡搁浅 2》。和前作一样,沉迷送快递,乐此不疲。

后面玩了《羊蹄山之魂》,画风绝美。和前作相比,可玩性更高了,有一种玩塞尔达的感觉:总是在赶路的时候,被偶遇的事件吸引走。

我喜欢随机事件把我带跑偏。现实里跑偏要付出代价,游戏里跑偏反而是奖励。


今年写得比去年少,掰着手指算也就 30 篇。可这一年并不空,只是没写出来。

以上就是全部的内容,感谢能看到这里。

其他的总结

依然是干劲满满的一年

里程碑

  • GitHub followers 2,422 -> 3,212
  • X 7,385 -> 9,673
  • 设备更新
    • iPhone 17
    • Mac Studio M4 Max 128G
    • Vidda C3S
    • Nuphy Air 75 v3

关于未来

我没指望未来会突然变好。只希望明年风小一点,我能更稳地往前走。

往年回顾

看完了?说点什么呢

在安稳中寻求生存

2025-12-14 13:32:01

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/204

这段时间,事情一件接一件。先从日常说起吧。

日常片段

最近看了新的 IP:疯狂动物城,前作看了一半之后忍不住直接去电影院先去把 2 看了。

https://www.themoviedb.org/movie/1084242-zootopia-2

复仇爽剧:模范出租车出第三季了,速看。

https://www.themoviedb.org/tv/119769-taxi-driver

上个月说的羊蹄山之魂通关了,正在二周目新游戏+。最近有点忙,还没有时间玩。

然后再来说说一系列的难受的事情。

体面告别

裁员那天,HR 找我聊。我以为会是一次体面的告别,甚至在某个瞬间,我还真的把它当作“和平结束”。

我接受得很平静。没有赔偿,只有十天的通知费,设备还要折旧回收。

这和老板之前的说法不一样——他说会有一笔金额,最后并没有兑现。说到底,给不给,很多时候更像是取决于公司怎么选择,我也很难指望一定会有结果。我当时理解香港这边的规则和大陆不太一样,很多保障并没有那么到位,至少对我来说是这样。相比之下,大陆的劳动法在这件事上,确实更能保障一点。

删文回声

后来我写了一篇文章,是对这段时间的总结、复盘和经验整理。我写到了一些我认为真实发生过的事情,于是被要求删掉。他们似乎不希望外界因此去质疑产品的未来。但在我看来,这个项目对外的更新与动作已经停滞了一段时间,我很难再用更乐观的词去形容它。

我还是删了。调整内容,重新发布。

删掉的那几个小时,反而让吃瓜的人传得更开:更多人开始围观一个可能从来没用过的产品,开始猜测、开始补全故事。

我没想过会这样。我只是把文字放在自己的博客里,没有在任何社交媒体发链接。可它还是自己长出了腿,走得比我想象的远。

总之,事情已经发生了。只是我没想到,最安静的地方,也会有回声。

气愤起点

后来的事情,让我开始感到气愤。

我从一些同样被裁的人那里,听到了很多过去从未得知的细节。起初我也知道:所谓「事实」,往往只是当事人愿意倾述的版本;人们更愿意相信自己想相信的那一套。可再往后,一些细节在不同人口中反复出现,我很难再把它当成纯粹的「版本」。

而这些信息里,我感受到的是一种让我很不舒服的做法。我很难把它当成善意。

我甚至有种恍惚:我正在经历的流程,像是他们已经很熟悉的流程。很多表达让我感觉更像是在引导情绪,让人先心软,再一点点降低预期。

有些话当时听起来很重,像是在替你争取,像是站在你这边。可很快我又看到各种说法开始变化,解释一层层叠上来,最后结论还是往下走。有人情绪激动,场面也变得很难堪,我也在那种尴尬和同情里破防。

我就是在那一刻意识到:这不是沟通,也不是协商。我当时的感受是,这一切太熟练了,像是事情最终会走向哪里,大家心里早已有数。

我当时接受得很平静,而此刻回想起来,更像一个小丑。

我其实并不在意有没有赔偿。入职之前我就查过资料,也问过 GPT,知道香港的法律大抵如此:很多东西写不写进合同、给不给到位,最后都落在“愿不愿意”上。换句话说,我对结果并不意外。

让我气愤的不是钱。是前后说法的落差,是那种被轻轻带过的感觉。

是他们先把话说得像承诺,让你以为自己会被认真对待;又在最后一刻轻描淡写地收回,仿佛从来没有说过。你甚至很难抓住一个明确的“反悔瞬间”,它更像是一种温柔的推诿:说过,但不算数;答应过,但没保证;本来想给,但现在不行。

公关开场

直到那天的晚上,老板出面发布了第一个公关帖子。

在发帖前后,我注意到他之前转发过的一些内容不见了。这种变化让我很难受,像是我们被悄悄从叙事里抹掉了:那是裁员当天,我们在 X 上发帖找工作,他还转推了,看起来像是"好聚好散"。

可现在,那些转推都不见了。仿佛他从未表态过,仿佛我们从未存在过。舆论开始发酵,像一把火终于找到了引线。

我很气愤那些让我觉得不真诚的说法,但我还是忍着。

很多认识我的朋友,在我把事情的全貌讲清楚之后,都站在我这边。有人在评论区替我打抱不平,也有一些从未谋面的、一直默默关注我的人给我留言。谢谢你们。那一刻我才意识到,原来我并不是一个人在扛。

招人疑云

可真正让我彻底破防的,是这篇公关帖之后发生的另一件事:后来我看到有人公开说“还在招人”。这和我当时的处境放在一起,让我非常困惑。

我后来从别人转述里听到,确实有人收到了类似的招聘信息。真假我不想再追究,但它带来的观感已经足够刺痛。

一边是把所有开发裁掉,一边又在招人。即使我已经大概能猜到这个项目的结局,我仍然会本能地怀疑:他们到底在招什么?他们到底在维护什么?他们到底在演给谁看?而更可怕的是,我已经不会再相信他们说的任何一句话了。

这件事荒诞到像段子,可它真的在发生。

于是开始出现各种猜测。我不想替任何猜测背书,但我能理解大家为什么会这么想,因为许多细节拼在一起,确实让人很难安心。

我那一刻是真的崩溃了。回想过去一年多,自己亲手从零开始打磨那个项目,很多夜晚、很多细节、很多你以为“会留下些什么”的东西,最后把我推到这个下场。突然就显得很可笑。

反击爆点

后面我开始反击。

我把我知道的、我听到的、那些彼此矛盾的说辞一条条摆出来,把不满也摆出来。不是为了吵赢谁,而是因为我突然明白:如果我继续沉默,他们就会替我把故事写完——写成一个“大家都理解、都体面、都没问题”的结尾。

第二天,情绪被推到了顶点。

奇怪的是,这段时间里没有人再来找我“沟通”、也没有人再来做公关。倒像是他们自己开始上演一出反向公关。

先是招人的事。我看到那条内容挂了一会儿,后来又不见了。

然后,有一位老板在公开场合说了这样一句话:

“大意是:创业不是慈善,加入前就该知道风险。”

后面那半句更锋利:他甚至还在强调公司对我们“没有任何可以挑出问题的地方”。

我当时看到这段话,心里一下子就凉了。

原来他们最后选择的立场不是“解释”,也不是“道歉”,甚至不是“沉默”。而是把一切包装成一句轻飘飘的逻辑:你选择了创业,所以你就应该承担一切;你受伤了,也只是你没想清楚风险;至于他们许下过什么、表演过什么、推诿过什么,都可以被这句话一笔抹平。

你们的委屈不是委屈,你们的付出也不算付出——你们只是在“应该如此”。

而真正荒诞的地方在于:他们一边说“风险自担”,一边又需要你相信他们;一边裁掉所有开发,一边又试图招新人;一边要求大家体面收场,一边又在公众叙事里把责任切得很干净。

懂事道具

我从来没指望创业像童话。我只是没想到,最后会被当成一个“应该懂事”的道具。

更荒诞的是,话说到这里还没有结束。那个说出“创业不是慈善”的老板,转头又出现了一些私下的说法,像是在推动大家选边站,也像是在形成某种“口径”。于是新的版本开始流出来。而且每一个版本都比上一个更像谣言。

他说我是自愿离职的,不是被裁的。

他说公司裁员不给赔偿,是因为“你没签国内合同,这是你当时的选择”。可事实是:这根本就不是“选择”。对我来说,那份合同从一开始就没有真正的可选项。你甚至没有站在十字路口,你只是被推着往前走,走到最后再被反过来指责:你看,是你自己走的。

这两天我真的累了。我开始想结束这一切,不想再把更多人的时间和精力卷进来。朋友的,围观者的,甚至我自己的。

二次公关

可偏偏就在我想把情绪压下去的那天晚上,他发了第二篇公关小作文。很长,很“认真”。开头先说外界的讨论被片段化信息带偏了,说这次人员调整是正常的商业决策:为了长期发展、为了降低成本、为了对用户负责,并且不是突发,而是按节奏推进。接着,他把矛头对准了“某一位离职员工”,暗示情绪之所以失控,是因为有人在夸张表达、带动对立。

我看完反而更气愤。是因为他把重点换掉了。他没有正面回应那些承诺为什么会消失、那些话为什么会变成空气。他只是把故事重写成另一种更安全的版本:公司在做正确的事,而问题出在“表达方式”和“情绪”。

更让我不舒服的是,他把生活也拖进了工作里。奖金、回忆、一起旅行、一起拍照,这些东西被摆出来。我承认,加班可能是我自己的选择,奖金也是对方的选择。我只是觉得,选择不等于可以互相抵扣。钱当然重要,但它换不回生活,也换不回那些被占用的深夜和周末。

朋友迷思

至于所谓朋友,我更不愿意被拿来当论据。老板和员工之间存在强利益关系,权力和选择从来都不对等。它可以是合作,可以是尊重,甚至可以是欣赏,但很难是朋友。朋友不会在争议发生时,用私人回忆来证明自己更正确。那更像一种提醒:你应该懂事……,你应该感恩,你不该把事情说成这样。

那一刻我意识到,对我来说这更不像沟通。他们只是要一个体面的结尾——而这个体面里,不包括我。

我再也压不住情绪,于是主动联系了他。在那段漫长的沟通里,我其实只想尽快结束这个闹剧。

越聊越像陷进去的泥。一边是迫切想停下来的疲惫,一边是被情绪推着走的冲动。再加上一点点生活外的交情,哪怕只是曾经的礼貌,曾经的温和,我心软了。我以为只要把话说开,把情绪压下去,事情就能回到“到此为止”的轨道上。

但现实不是这样运作的。

我在对话里意外透露了一些来自前同事的说法。它原本只是倾诉,只是“我听说过”。可话转了几层之后,性质就变了。传到另一个老板耳朵里,它可能就不再是信息,而变成了立场。不再是叙述,而变成了证词。于是那个说过“创业不是慈善”的人,随后我也听到,有人反复联系前同事,话里话外像是在把责任重新分配。

我成了夹在中间的人。两边都不满意,两边都觉得我有问题。我既得罪了他们,也伤到了那些曾经支持我的人。

放下接受

最后,事情还是结束了。非常不愉快。

我得到了什么呢。得罪了两边的人,伤害了曾经站出来替我说话的人。然后我发现这一切都无法挽回。那一晚,我对自己的鲁莽感到深深的自责。情绪冲昏了头脑,像个小孩子一样,急着证明,急着结束,急着把痛苦从胸口掏出来给别人看。结果只换来两败俱伤。

我甚至开始反问自己,我预想的结局到底是什么。是一个道歉,一个解释,一句真话,还是一个终于可以睡觉的夜晚。可到了最后,什么都没有。只有更大的噪音,和更难收拾的残局。

那天回家的路上,我在车里坐了很久很久。车外的世界还在动,灯在走,我却像被按在原地。然后我给前同事发了一段很长的消息,更像是一封迟到的道歉。

发完那段话,我才意识到一个更刺人的事实。有些话一旦说出口,就不再属于你了。它会在别人手里变形,会被拿去站队,会被拿去证明某个叙事。你站在中间,怎么解释都像狡辩,怎么沉默都像默认。

那一晚,我第一次认真地承认,我不是输给了他们的公关。我输给了自己的冲动。

那天之后,我删掉了 X。直到一周之后,世界仍然在运转,吃瓜的人已经散伙,一切又恢复了平静。

平静来得很快,快到像是什么都没发生过。可有些东西并不会因为热度退去就被证明是误会,它只是被遗忘了。

那个曾经说还会继续迭代维护的人,直到现在也没有看到对应的变化。对我来说,那句话更像是一种当时的安抚,而不是后续会兑现的计划。

而我也慢慢确定了一点,我很难再信任他们对外的表述。不是因为我想否定什么,而是因为我已经看过太多前后不一致。

不是因为真话太复杂,也不是因为真话太难讲。我能想到的解释是,真话一旦说出口,就会让体面露出裂缝。于是更多时候,他们选择沉默,选择拖延,选择用一句句看起来正确的话,把真实的部分藏起来。

在那一周里,我无意经过一座寺庙。我进去站了一会儿,很诚心地祈祷,希望自己能在安稳中找到一块生存地。

出来的时候,看见门口有两块石头,上面分别刻着两个字。

放下 接受

像是冥冥之中在告诉我,事情已经到这里了。

回去的路上我又路过一家书店。玻璃上印着一句话,我站在那儿看了很久。

希望你能记住我,记住我曾经这样存在过。 ——村上春树

我不知道为什么那一刻忽然很想哭。也许不是因为谁,也不是因为那个项目,而是因为我突然意识到,我真正害怕的并不是失去工作。

我害怕的是,我花了那么多时间和心力,最后连“好好告别”都不被允许。连一句真话都不能留下。连我曾经那样认真地存在过,也要被改写成不合时宜。

感谢你能看到这里。

看完了?说点什么呢

Better Auth 的多租户用户鉴权的构想

2025-12-04 23:07:18

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/better-auth-multi-tenant-auth-concept

最近又把 Afilmory 捡起来做的,这次受 ChronoFrame 影响,我也决定给它加一层 CMS 能力,顺手往「做一个 CMS 的 SaaS 平台」这个方向靠一靠。

既然要做多租户 SaaS,身份验证这块就躲不过去。Better Auth 本身没有内建 multi-tenancy 的概念,所以整个用户模型、OAuth 流程、以及和 ORM 的边界都需要重新想一遍。

这篇主要是把我现在的构想和落地方案整理一下,后面如果再踩坑也方便回头翻。

每个租户一套 Better Auth?

一开始我想得比较直觉:

既然是多租户,那干脆让每个租户自己配置 OAuth / 鉴权,后端就给每个租户开一个 Better Auth 实例:

  • Tenant A → Better Auth 实例 #1,配自己的 GitHub / Google
  • Tenant B → Better Auth 实例 #2,再配一遍

这种做法的问题其实也很明显:

  • 配置地狱:租户一多,每个租户一套 OAuth 配置,维护成本会爆炸。
  • 实例地狱:Better Auth 实例越开越多,内存占用和初始化开销都上来了,极端一点甚至 OOM。
  • 逻辑重复:大部分逻辑其实是一样的,只是换了几个 client id / secret。

所以这条路基本可以确定是走不远的。

统一 Auth Provider

后来想了一圈,感觉更合理的一种模型是:

Auth Provider(Better Auth 实例)只有一个,是全局单例。但在业务层面,同一个人可以在多个租户下拥有不同的身份。

比如:Innei 在 Tenant A 里是 Admin,在 Tenant B 里只是一个普通 User;Cupchino 在 Tenant A 是 User,在 Tenant B 刚好是 Admin。

也就是说:「账号」是同一个 GitHub / Email,但落到租户里,都是不同的 user 记录,权限、数据都完全隔离。

这个关系可以简单理解成:tenant 有很多 authuser,同一个 GitHub 账号,可以在多个 tenant 下绑定多个 authuser。

Not support render this content in RSS render

即便是使用同一个账户登录后在不同的租户下都会是一个不同的用户。不同租户下的数据完全隔离,但是 auth provider 却是一个单例。

数据库层的设计

在数据库定义上,处理 better-auth 基准的字段之外,需要额外增加一个 tenantId 标识。

// Custom users table (Better Auth: user)
// Note: Multi-tenant design - same email can exist in different tenants
export const authUsers = pgTable(
  'auth_user',
  {
    // Add this
    role: userRoleEnum('role').notNull().default('user'),
    tenantId: text('tenant_id').references(() => tenants.id, {
      onDelete: 'set null',
    }),
  },
  (t) => [
    // Multi-tenant: same email can exist in different tenants
    unique('uq_auth_user_tenant_email').on(t.tenantId, t.email),
    index('idx_auth_user_tenant').on(t.tenantId),
  ],
)

// Custom sessions table (Better Auth: session)
export const authSessions = pgTable('auth_session', {
  // Add this
  tenantId: text('tenant_id').references(() => tenants.id, {
    onDelete: 'set null',
  }),
})

// Custom accounts table (Better Auth: account)
// Note: Multi-tenant design - same social account can exist in different tenants
export const authAccounts = pgTable(
  'auth_account',
  {
    tenantId: text('tenant_id').references(() => tenants.id, {
      onDelete: 'set null',
    }),
  },
  (t) => [
    // Multi-tenant: same social account can exist in different tenants
    unique('uq_auth_account_tenant_provider').on(
      t.tenantId,
      t.providerId,
      t.accountId,
    ),
    index('idx_auth_account_tenant').on(t.tenantId),
  ],
)

export const tenants = pgTable(
  'tenant',
  {
    id: snowflakeId,
    slug: text('slug').notNull(),
    name: text('name').notNull(),
  },
  (t) => [unique('uq_tenant_slug').on(t.slug)],
)

Not support render this content in RSS render

Better Auth 初始化

在 better-auth 的实例初始化中,需要额外定义扩展字段:

betterAuth({
  session: {
    freshAge: 0,
    additionalFields: {
      tenantId: { type: 'string', input: false },
    },
  },
  account: {
    additionalFields: {
      tenantId: { type: 'string', input: false },
    },
  },

  user: {
    additionalFields: {
      tenantId: { type: 'string', input: false },
      role: { type: 'string', input: false },
      creemCustomerId: { type: 'string', input: false },
    },
  },
})

这里所有 tenantId 都标成 input: false,意思是:外部请求不能直接写这些字段;只能通过我们自己的 hooks / adapter 在服务端填充,避免被前端篡改。

只定义字段还不够,还需要在「创建 user / session / account」的时候,把租户信息真正写进去。

核心就是:在这些 before 钩子里,通过 ensureTenantId() 拿到当前请求上下文对应的租户,然后写到数据里。

betterAuth({
  databaseHooks: {
    user: {
      create: {
        before: async (user) => {
          const tenantId = await ensureTenantId()
          if (!tenantId) {
            throw new APIError('BAD_REQUEST', {
              message: 'Missing tenant context during account creation.',
            })
          }

          return {
            data: {
              ...user,
              tenantId, // 设置租户 ID
              role: user.role ?? 'user',
            },
          }
        },
      },
    },
    session: {
      create: {
        before: async (session) => {
          const tenantId = this.resolveTenantIdFromContext()
          const fallbackTenantId =
            tenantId ?? session.tenantId ?? (await ensureTenantId())
          return {
            data: {
              ...session,
              tenantId: fallbackTenantId ?? null, // 设置租户 ID
            },
          }
        },
      },
    },
    account: {
      create: {
        before: async (account) => {
          const tenantId = this.resolveTenantIdFromContext()
          const resolvedTenantId = tenantId ?? (await ensureTenantId())
          if (!resolvedTenantId) {
            return { data: account }
          }

          return {
            data: {
              ...account,
              tenantId: resolvedTenantId, // 设置租户 ID
            },
          }
        },
      },
    },
  },
})

做到这里,写入这条链路基本是多租户感知的了。同一个 GitHub 登录到不同子域名,就会在各自的租户下创建独立的 user / account / session。

Better Auth 查用户时根本不知道 tenantId

真正比较坑的是 的这部分。

Better Auth 在 OAuth 回调时,会做类似这样的事情:

  1. 拿到 Provider 传回来的 codestate
  2. 根据 state 找到之前那次登录请求;
  3. 拿出 email / providerId / accountId 后,去 DB 里查用户。

问题就在第 3 步: Better Auth 默认只会按 email / provider 查用户,并不会自动加上 tenantId。

这会导致一个很危险的情况:

  • 用户在 Tenant A 用 GitHub 登录了一次 → 创建了 user@tenantA
  • 同一个用户后来打开 Tenant B,用同一个 GitHub 登录
  • 回调的时候,只按 email + provider
  • DB 里第一条匹配的记录是 tenantA 的那条
  • 结果就是:Tenant B 的登录错绑到了 Tenant A 的用户记录上 → 跨租户越权 / 数据串租。

也就是说,上游业务层明明已经区分了租户,但到了 Better Auth 内部这层,它是看不到 tenant 的。 只要 ORM 层不管租户,框架就没法帮你保证隔离。

Not support render this content in RSS render

既然 Better Auth 本身不知道 tenantId,那就只能从适配器这层把它「强行带进去」。

思路是这样:写一个 tenantAwareDrizzleAdapter,把 multi-tenant 的边界下沉到 ORM / Adapter 层。

这个适配器的职责:

  1. 在每次查询前,调用 ensureTenantId() 拿到当前租户;

  2. 对于 user / account 相关的查询,自动追加:

    where ... AND tenant_id = currentTenantId
    
  3. 对于写入,自动把 tenantId 补到数据里(如果上层没写的话)。

这样一来,在 Better Auth 看来:

  • 它仍然是在做「按 email / provider 找用户」这种看起来很单租户的事情;
  • 但实际发出去的 SQL 已经被 adapter 自动加上了 tenant_id = ... 条件;
  • 也就是说:「多租户感知」这件事对框架是透明的,被我们藏在 ORM 这一层。

实现细节就不展开了,大致就是在 Drizzle 的 query builder 一层做 wrap,把所有跟用户相关的查询 /写入都套上 tenant 条件。

对应的代码在这里:

https://github.com/Afilmory/afilmory/blob/ae21438eb766fb944b37ca5949d2f25185bccccb/be/apps/core/src/modules/platform/auth/tenant-aware-adapter.ts

Not support render this content in RSS render

多租户多域名下,怎么优雅地统一配置 OAuth?

在多租户、多子域名的 SaaS 里,一个很常见的诉求是:

  • a.example.com
  • b.example.com

这两个租户都想复用同一套 GitHub(或其他)OAuth 应用,而不是每个子域名各配一套。

问题在于,大多数 OAuth Provider(比如 GitHub)在配置回调地址(redirect_uri)时,都要求是精确匹配,不能写成通配符,比如:

  • https://*.example.com/api/auth/callback/github
  • https://auth.example.com/api/auth/callback/github

也就是说,在 Provider 那边,你只能填一个固定 URL。但在我们这边,又希望最终的回调是落到各个租户自己的域名上:

  • https://a.example.com/api/auth/callback/github
  • https://b.example.com/api/auth/callback/github

所以这里需要引入一个专门做 OAuth 回调分发的网关,比如:

  • 统一对外暴露:https://auth.example.com/api/auth/callback/github
  • 真正创建 Session 的逻辑,仍然在各租户自己的后端里,只是由网关把请求转发(302 跳转)过去。

这样,Provider 侧只认一个「入口」,网关负责把这个入口再按租户「分流」出去。

网关怎么知道这次登录属于哪个租户?

当 GitHub 把用户重定向回:

https://auth.example.com/api/auth/callback/github?code=...&state=...

的时候,请求里看不到诸如 tenant=a 这种显式信息。我们又不想在网关上维护什么 session 或额外的状态。

这里可以利用 OAuth 协议里本来就存在的 state 参数来解决:让上游的认证服务负责把「租户信息」塞进 state 里,网关只负责解包并转发。

一个典型流程大概是这样(对应 @oauth-gateway 这个服务的设计):

  1. 用户在 a.example.com 点击「使用 GitHub 登录」。
  2. 后端(比如 be/apps/core 里的 Better Auth)在构造 GitHub 授权 URL 的时候,不是直接生成一个 state,而是:
    • 先生成内部真正用的 innerState(给 Better Auth 自己用)
    • 再包一层:{ tenant: "a", innerState: "<better-auth-state>" }
    • 用网关共享的密钥做一层加密 / 签名,变成一个 wrappedState
  3. 浏览器被重定向到 GitHub 授权页面,之后 GitHub 回调回统一地址:

    https://auth.example.com/api/auth/callback/github?code=...&state=<wrappedState>
    
  4. 这时 OAuth Gateway 做两件事:
    • 用自己的密钥解开 state,拿到:
      • tenant(比如 "a"
      • innerState(要还给 Better Auth 的那份)
    • 根据 tenant 和基础域名(比如 example.com)拼出目标地址:
      • https://a.example.com/api/auth/callback/github?code=...&state=<innerState>
  5. 网关直接返回一个 302

    Location: https://a.example.com/api/auth/callback/github?code=...&state=<innerState>
    

a.example.com 自己的后端视角看,只是收到了一个完全正常的 GitHub OAuth 回调;它只关心 codestate=<innerState>,根本不需要知道中间还经过了一个网关。

Not support render this content in RSS render

总结

目前这套设计,大概是把多租户问题拆成了三层:

  1. 数据模型层 每个租户有自己的 user / account / session, 唯一键全部变成 (tenantId, …)

  2. ORM / Adapter 层tenantAwareDrizzleAdaptertenantId 自动拼进所有查询 / 写入, 对 Better Auth 这种上层框架来说是透明的。

  3. OAuth 流程层 借助 state + OAuth Gateway,在多域名、多租户的场景下共享一套 Provider 配置, 同时又能把回调正确落回对应租户的后端。

感谢你看到这里。如有不足欢迎在评论区指出。

看完了?说点什么呢

写在离开 Folo 之后 原稿

2025-12-01 14:37:48

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/experience/after-leaving-folo-backup

前几天收到通知,Folo 解散了,我这段工作也一起画上句号。

虽然这个月一直有不太妙的预感,但真的落在自己身上的那一刻,还是会愣一下,有点空,有点失落,也有种「啊,真就走到这一步了」的感觉。

想了几天,还是决定把这一年多的经历和反思写下来,当作一个存档。以后哪天心态稳定了,回头再看,应该会比较清楚自己这一段到底经历了什么。

为什么会去做 Folo

最早接到 Folo 这个机会时,我刚经历了人生第一次裁员,整个人状态挺糟的,对未来特别迷茫,对自己也没什么信心,出现了严重的躯体反应,抑郁和焦虑,整夜整夜的失眠。那个时候,DIYgod 找我聊了 Folo 的事情,那个时候项目刚开始,连 demo 也没有,当时没想那么多,只记得那段时间我整个人挺焦虑的,能有人拉我一把,就已经很不一样了。所以现在回头看这段经历,哪怕项目最后没有按大家一开始想的那样走下去,我对 DIY 其实还是挺感激的——那确实是在我比较低谷的一个时间点,给了我一个出口。

然后就是那种比较简单的判断:做这个项目本身我也感兴趣。我很喜欢做 ToC 的产品,一直以来。

老实说,再一次从零开始创造一个产品也是挺酷的,而且很有自由度。

这一年多我都在干什么

如果只看事情本身,这一年多过得其实挺密。

早期很多基础的东西,基本都是我一点点搭起来的:

  • 所有基础组件的建立,自己撸了一整套
  • UI design 的规则、设计体系怎么定,也是从零开始搭
  • App 数据流架构重构了一遍,把之前比较散的部分都理顺
  • Electron 的热更新方案也是我去踩坑慢慢调出来的
  • 有关 Follow(早期产品名称)设计和优化我也曾在博客中写过挺多文章

同时我还兼着产品,一边写代码一边拍脑袋想功能,很多功能其实是带了不少个人偏好的。打磨细节也是我非常执着的一个点,经常真的是为了一个 1px 盯着屏幕看半天,调 spacing、对齐、hover 状态这些小东西。

那段时间基本上是:设计、前端、产品我都在干。周末有时候还在琢磨组件要不要再重构一下,或者把哪个交互再改顺一点。

虽然每天都会干到很晚,有时候周末也在打磨,但那个阶段整体还是挺开心的,有那种「在把一个东西从一团糊糊慢慢变顺眼」的满足感。

后面我的 commit 数也爬到仓库第一了,前端那一块几乎所有角落我都翻过一遍,很多历史遗留的地方也是趁重构的时候顺手清掉。

有群友跟我说,我这状态有点像面包,把公司项目做成了个人项目,这话虽然有点玩笑的成分,但当时确实是那种「恨不得所有细节都先过一遍我眼睛」的状态。

从纯工程师视角看,这一年我学到的东西挺多。以前只是「写别人安排的需求」,现在多少对整个产品的形状和系统有了一些自己的判断。

但最后还是走到了终点

当然,光技术上长进不代表项目能活下去。

Folo 最大的坑,其实在一开始就埋下了:我们起步的时候,并没有认真想过商业化。

早期的 Folo 带点 Web3 的影子,搞过邀请码、搞过活跃激励,拉过一批那种对玩法和「赚点什么」很感兴趣的用户。

在那个时间点,这种判断也不算离谱,只是现在回头看,问题其实挺大:

  • 没有想清楚真正要服务的是谁
  • 没有想清楚未来谁会为它付费
  • 很多设计是围绕「玩法」和「活动」长出来的,而不是围绕「长期愿意付费的那一拨人」

后面环境变了,我们开始往 Web2 + AI 这个方向靠,想把它变成一个「正常的订阅制阅读 / 时间线工具」。这时候割裂感就出来了:

  • 对一部分早期 Web3 用户来说,Folo 之前是一个「有点 DeFi 味道的玩具」,后面突然变成一个正儿八经做体验、做订阅的 Web2 + AI 产品,他们很难买账,也不觉得这东西值得付费
  • 而真正需要一个 Web2 + AI 阅读工具的那拨人,其实并不会因为你「曾经是 Web3 项目」而对你多一点好感,甚至有些人会直接带着偏见看

中间这段过渡期挺别扭的:老用户觉得你变了,新用户又不知道你以前是谁,两边都没完全站稳。

更讽刺的是,Folo 曾经「有点小辉煌」的那段时间,整体热度很高,数据也还可以,圈子里讨论不少,甚至有很多人在闲鱼出售大量邀请码。

没有认真设计商业化路径,也没有趁着那波势头去融一轮钱,让项目多一点缓冲时间。现在看,那大概是这个项目离「也许能活久一点」最近的一次,只是当时谁都没把它当回事。

等热度退下去,我们才开始回头想商业化和融资,那时候难度已经完全不是一回事了。在这个阶段,当大家开始严肃讨论「订阅」「营收」「可持续」这些问题时,之前没想清楚的东西就一起堆过来了:

  • 靠激励吸引来的用户,和你想象中的「愿意为了阅读体验付费」的用户,并不是同一批
  • 产品很多地方其实是迎合前者成长起来的
  • 一旦开始收费,早期用户的预期和心态就会产生很明显的反噬

那段时间的感觉挺真切的:你以为自己是在从 0 开始做付费,实际上更像是在从负数一点点往 0 爬。

对这段经历的一些反思

我尽量简单讲,不写成那种鸡汤总结。

1. 「以后再想商业化」基本就等于没想

一开始我们确实是抱着做玩具的心态去做的,甚至觉得:

现在不缺钱就不考虑商业化,也不为后续铺路,直接封死路口

但现实就是:如果脑子里完全没有一个「谁会掏钱、为什么掏」的粗略想法,那么你在日常做决策的时候,很容易被短期数据带着跑。

很多「看上去很不错的增长」不一定是在为以后做准备,有时候只是让你在一条不太对的路上越走越远。

以后再做任何项目,只要心里有一点「它有机会商业化」的念头,我应该都会尽量早点把商业模式大致想一想,再说是不是要认真做下去。

所以,我最近做的 Afilmory Cloud,不管有没有人用我都会放开付费计划的口子。

这里再打一下广告:https://afilmory.art 欢迎来用。

2. 激励拉来的用户,很可能不是你的用户

这个教训应该会刻在我脑子里挺久。

各种代币、积分、奖励、活动,这些东西短期非常有效。 但它的本质是一个「筛选器」:

  • 你用工具本身的价值筛选来的,是「需要这个工具」的人
  • 你用激励筛来的,是「对激励敏感」的人

这两类人不完全重合。

当你后期要靠订阅、靠长期价值来活的时候,前者才是关键用户。

但如果前期一直在放大后者,那后面就会非常难办。

3. 方向不对的时候,越努力越危险

这段时间我经常有这种感觉:

  • 大家都在很努力
  • 每天有很多活要干
  • 也一直在「解决问题」

但内心深处其实知道,现在的很多努力是在补前面那些没想清楚留下的坑。有时候你越投入,越难有力气抽身去想「要不要换条路」。

这件事对我最大的提醒就是:

忙不是问题,关键是要定期停一下,问自己:

如果我们照现在的方向一路做下去,哪怕做到极致,那个终点是我能接受的吗?

如果这个问题迟迟回答不上来,那多半说明有不对劲的地方了。

4. 时机也是成本

这两年回头看,会有一个很强的感觉:有些窗口期是真的会关上,而且关上以后,再做同样的事情,难度是完全不一样的。

Folo 在那段热度最高的时候:

  • 名字在圈子里多少还是有人认的
  • 数据和关注度也都比后期好看得多

如果那时候我们不把它当玩具,而是稍微认真一点:

  • 提前设计好一条清晰的商业化路径
  • 或者在热度在的时候试着去融一轮钱,给项目留一点犯错空间

后来事情的走向可能会不一样。

当然也不一定就能成功,但至少不是现在这种「热度没了再去补前面的课」。

这件事给我的提醒就是,产品有节奏,市场也有自己的节奏

  • 不是什么东西都能「以后再说」
  • 错过的一些时间点,本身就是成本

以后再做一个项目,如果感觉它处在一个「可能是风口边缘」的位置,我大概率会更早去想:

要不要趁这个时间点做点更重的决定,而不是一味地觉得「反正先当玩具做,想钱以后再说」。

关于人情这块

前面讲了很多产品、商业、技术上的东西,但对我个人来说,这段经历里面,还有一块很重要的是「人情」。

  • 在我刚被裁员、状态很糟的时候,是 DIY 把我拉进来
  • 给了我一个可以重新投入进去的项目
  • 也给了我机会接触很多之前没机会做的东西

不管项目最后是什么结果,这件事本身我是一直记在心里的。

有时候就是这样,你在别人低谷的时候伸一把手,对那个人来说,意义会很长。

所以即使团队解散了,我对这段经历的情感并不是简单的「失败」「浪费时间」,更多是一种复杂的混合:

  • 有遗憾,也有不甘心
  • 但也有一些很真诚的感谢

对未来的一点想法

短期要面对的,还是比较现实的问题:找下一份工作、解决生活、慢慢把心态从这个项目里抽出来。

之后,对“做一个可持续的产品”这件事,会比之前更谨慎,也更有敬畏感。

感谢能够读到这里,如果有什么不错的机会的话,也欢迎评论区撩。

看完了?说点什么呢