Logo

site iconInnei | 拾一

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

Inoreader Feedly Follow Feedbin Local Reader

Innei | 拾一 RSS 预览

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 把我拉进来
  • 给了我一个可以重新投入进去的项目
  • 也给了我机会接触很多之前没机会做的东西

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

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

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

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

对未来的一点想法

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

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

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

看完了?说点什么呢

写在离开 Folo 之后

2025-12-01 01:36:27

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

文中所有内容都只是我个人的经历和感受,不代表 Folo、团队或任何投资人的立场。

这篇文章是经过 AI 润色和调整的。

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

虽然这个月一直隐隐觉得情况不太妙,但真的落在自己身上的那一刻,人还是会愣一下:有点空、有点失落,也有种「啊,原来已经走到这一步了」的感觉。

想了几天,还是决定把这一年多在 Folo 的经历和一些个人反思写下来,当作一个存档。以后哪天心态稳定了,再回头看,至少能更清楚地知道自己这段时间到底经历了什么、学到了什么。

为什么会去做 Folo

最早接到 Folo 这个机会时,我刚经历人生第一次裁员。那段时间整个人状态挺糟的,对未来特别迷茫,对自己也没什么信心,各种躯体反应、抑郁、焦虑、失眠一股脑地冒出来。

就在那时候,DIY 找我聊到 Folo。那会儿项目还非常早期,很多东西都停留在想法、脑图和讨论里,连像样的 demo 都还没有。但对当时的我来说,有人愿意拉我一把,给我一个能重新把注意力投进去的事情,本身就挺不一样的。

现在回头看这段经历,哪怕后来大家的路径和选择各不相同,我依然很感谢当时这通「拉一把」:那确实是在我比较低谷的一个时间点,给了我一个出口。

另一方面就是一个很简单的判断:我本身就对这种阅读 / 时间线类的产品很感兴趣。

我一直喜欢做 ToC 的产品,喜欢琢磨交互细节、体验和界面呈现。

再一次从零开始创造一个东西,其实是很酷的事情,而且自由度也很高。Folo 在早期也给了我很多发挥空间。

在 Folo 的一年多,我都在做些什么

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

因为 Folo 很早期,很多基础的东西都需要从头搭起来,我主要做了这些:

  • 从零搭了一整套基础组件;
  • 一点点把 UI 的设计规则、风格和设计体系搭出来;
  • 把 App 的数据流架构重新理了一遍,把之前比较散的部分串起来;
  • 桌面端(Electron)的更新方案、一些踩坑和调优,也是我一点点摸出来的;
  • 围绕 Folo 的设计和优化,我也写过几篇博客记录当时的想法。

那段时间,我基本是 设计、前端、产品 三件事一起干。 经常是:白天写代码、改交互,晚上还在想组件要不要再重构一下,周末偶尔继续打磨。

我对细节的执着,在那个阶段被放大得很明显: 经常为了一个 1px 的对齐,或者一个 hover 状态,盯着屏幕看半天,不停调 spacing、调整节奏。

后面我的 commit 数一直往上爬,前端那块几乎所有角落都翻过一遍,一些历史遗留的问题也是趁着重构的时候顺手清。

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

从纯工程师视角看,这一年我学到的东西挺多的。

以前更多是「完成别人安排的需求」,现在多少对 Folo 这个产品的形状、系统架构、体验节奏,有了一些自己的判断。

对这段经历的一些个人反思

接下来这部分,就不局限在 Folo 这个项目本身了,更多是我在这段时间里,对「怎么做一个产品」的一些感受。

我不打算给任何人下结论,只讲我自己以后会怎么做得不一样。

1. 关于「商业化要不要想得很早」

在早期阶段,我确实更偏向「先把东西做出来」「先做一个好玩的产品」,对商业化的思考会往后放。事后回头看,对我自己来说,教训是:

不一定一开始就要把每一块都推到极致,但至少要在心里留一条大致的路: 这个产品大概是为谁做的,这些人未来有不存在「愿意为它长期买单」的可能。

如果脑子里完全没有这条路,日常决策就很容易被短期数据牵着走。很多看上去漂亮的增长,不一定是在为未来打基础,有时候只是让你在一条不太适合长期生存的路径上走得更远。

现在再做任何项目,只要心里有一点「它有机会变成一个长期产品」的念头,我都会尽量在比较早的阶段,先粗略想清楚:

  • 它大概会服务谁;
  • 这些人为什么会愿意长期留下来;
  • 他们愿意为什么买单。

这不只是对 Folo 的感受,其实是这几年所有项目叠加起来给我的提醒。

2. 关于「激励」和「真正的用户」

另一个对我很深的提醒是:激励是一种筛选器。

各种奖励、玩法,在短期内都非常有效,这点我在 Folo 以及别的一些项目里都见过。

但它筛出来的,更多是「对激励敏感的人」,而不一定是「对产品本身有强需求的人」。

如果前期一直用这种方式去拉新,很容易导致:

  • 数据好看、热度不低;
  • 但当你希望产品靠长期价值站住时,发现核心人群其实没有被真正建立起来。

以后再做类似的产品,我会更谨慎地区分:

  • 哪些是「为产品本身添砖加瓦」的设计;
  • 哪些只是「为了短期刺激」的玩法。

这个反思同样不是在评价哪个项目好或不好,而是提醒自己:

不能只被短期数字牵着走,要时刻记住自己真正希望留下的那一拨人是谁。

3. 方向没想清楚的时候,越努力反而越危险

这段经历里,还有一个挺扎心的感受:

大家都很努力,事情也很多, 但有时候你隐约知道,自己是在用努力填前面没想清楚留下的坑。

你越是全力往前冲,就越难停下来问自己一句:

如果照现在这个方向一路做到极致, 那个终点真的是我想要的吗?

这个问题不针对任何人,只是对我自己的一种提醒: 以后我会更刻意留出一点「按暂停键」的空间,哪怕只是定期问自己这个问题。 如果长期答不上来,可能就说明有哪里不太对劲了。

4. 时机本身也是一种成本

这几年回头看,会有一个很强的感觉:

有些窗口期是真的会关上, 关上之后再做同样的事,难度完全不一样。

不只是 Folo,我接触过的好几个项目都有类似的影子:在某个阶段其实都迎来过一小波关注或讨论度,如果那时能更早地意识到:

  • 要不要在那时候认真规划一下下一阶段;
  • 要不要借着关注度去争取更多缓冲时间;

后来的剧本可能会不太一样——当然,也未必就一定成功,但至少不是在热度退去之后再来补前面的课。

这件事给我的提醒是:

  • 产品有自己的节奏;
  • 市场也有它的节奏;
  • 有些决定,如果总觉得「以后再说」,其实就是一种隐性成本。

关于 Folo 和「人情」这部分

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

  • 在我第一次被裁、状态很差的时候,是 Folo 给了我一个可以重新投入的项目;
  • 给了我一个在桌面端 / 大型 ToC 产品上深度打磨的机会;
  • 也让我有机会接触到之前没做到的职责和领域。

不管后来每个人的选择如何,这件事本身我是一直记在心里的。

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

所以,对这段在 Folo 的经历,我的情绪是很复杂的:

  • 有遗憾,也有不甘心;
  • 但也有很多真诚的感谢。

它既不是简单的「成功」或「失败」两个字可以概括的,更像是一次把很多课提前塞给我的密集训练。

对未来的一点想法

短期内,要面对的还是很现实的问题:找下一份工作、解决生活、慢慢让自己从这段紧绷的节奏里抽离出来。

但可以确定的是,以后再谈「做一个可持续的产品」,我的态度会比以前更加谨慎,也会多一点敬畏。

如果你刚好也在经历类似的阶段,或者也在做一个早期项目,希望这些碎碎念能给你一点参考——哪怕只是让你提前避开我走过的一两个坑,也算是这段经历留下的价值之一。

谢谢你看到这里。

看完了?说点什么呢

在焦虑与创造之间寻找出口

2025-11-17 01:35:33

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

又是一个月。我想以后尽量保证每个月能写一篇手记,至少记录一下自己的生活历程。虽然我的生活依旧混乱而枯燥,状态也并不算好,未来的变数更是大得让人不安——总之,又是在焦虑之中。那就慢慢说来吧。

黑客马拉松:从灵感爆发到现实落差

有了 AI 之后,很多想法能更快落地。这种速度有时候让人觉得久违的兴奋,像是突然抓住一根绳子,可以朝某个方向继续拉下去。

上上个月提到我正打算重写 Mix Space,项目其实已经开了头,但因为其他更“重要”的项目,又不得不先搁置一段时间。所谓重要,其实也都还是 side project。怎么让这些项目变现,我现在也仍在探索中。

前天些天和厂长也是进行了一段时间的黑客马拉松,突然的想法复刻一个 trustMRR,但是是基于 App Connection 的数据。原因也是还没人做,然后 trustMRR 上了几天就赚了好多钱。我们要尽快做出来。

最后的结果是,由于前期没有调研清楚,导致最后不了了之了。虽然现在还是上线了 Apple MRR,但效果没有预期的那么好。不过这次确实吸取了经验——调研真的很重要,不然好几晚的熬夜就白费了。

不过现在用 AI 做 UI 确实越来越快了,Apple 的设计花了不久就搞出来了。只是再强的 AI 也只是加速器,细节部分还是得靠人去打磨。

Afilmory:被迫加速的一场追赶之战

我现在最主要再做的项目还是做 afilmory。前段日子,这个项目被抄了 UI,然后做了一个 Nuxt 版本的带 server 的。我感觉到有点压力了。原本我是不想做 server 的,这下不得不做了。花了两周的业余时间指挥 AI,把整个 dashboard + server 写出来了,我是完全按照 SaaS 去设计的,代码目前是开源的,但是应该不会写任何文档。我想通过 SaaS 的中心化的方式,让更多的人通过一个实例管理更多的 afilmory,后面就可以做一个大众的画廊。当然这个服务从 day 1 开始注定不会是 Free 的。

https://github.com/Afilmory/Afilmory

基础功能算是 ready 了,但还不够上线。我也顺便整理一下目前的成果。

:::gallery

:::

dashboard 采用 Linear design language,web 则是 Glassmorphic Depth Design System。不过 web 的 UI 后面应该会再进行一波大改,我不太想让别人轻易抄过去继续用。

羊蹄山之魂:逃离现实的一段旅程

《羊蹄山之魂》这个游戏真的挺好玩。前作对马岛我也通关了,但最近对比了一下,其实感觉完全是两款不同的游戏。除了美术一致,玩法几乎全改了。打击感更好,花样更多,完全没有罐头味,而且风景绝美。

目前我玩了三十多个小时,刚到第二章。每次做任务时都会被狐狸或金鸟吸引跑偏,总之主线完全不着急。等我通关之后,也许能再深入聊一聊。

人生和变数

最近又因为工作的事情焦虑,遇到一些调整,可能年前要重新找工作了。如果有合适的机会也可以推荐一下。对于工作稳定这件事还是太难了,总是会遇到意想不到的变数。

有时候我会突然停下来,想一想自己现在的生活轨迹:重复、枯燥、像是在原地绕圈,怎么走都走不出既定的范围。

意义感这种东西,好像越来越难抓住。偶尔甚至会觉得未来的路,不知道该通往哪儿。

还有那个静悄悄的孤独,一直都在。不是喧闹的悲伤,而是一种温度很低的空白,让人意识到很多时候我确实是一个人在走。

想到父母终会老去,而我可能只能陪他们走完最后一段路,就会突然涌起一种说不出口的无力感。甚至偶尔也会浮现一个念头:等他们离开之后,我好像也不会再有什么必须坚持下去的理由。

看完了?说点什么呢

🇸🇬 在赤道边缘的五日行记

2025-10-19 22:57:30

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

本文图片皆为随手拍,下面的链接可以查看本次旅行中较为合格的出片合集(已调色)。

https://afilmory.innei.in/?tags=%E6%96%B0%E5%8A%A0%E5%9D%A1

来到世界的第二十五年,第一次踏上了别的国土。

人生第一次出国,前往新加坡。开启一段长达五天的旅途。

初到坡上:热与秩序

十月十一日出发,错峰出行。

下午四点到达樟宜机场。晚餐和朋友们吃了松发肉骨茶。

该店也是狮城的一大特色,机场就有,去的时候还有很多人排队。黑胡椒味的排骨汤挺特别的,汤是可以免费续的。

吃饱喝足,在星耀樟宜逛了一下,首先打卡网红瀑布。

然后去到了观景台,使用广角拍下了他的全貌。

另外这里还有滑滑梯可以玩。

酒店 Check In 之后,去小印度逛了下。那片地方第一感受就是非常的混乱,确实有种来到印度的感觉。查了一下历史,之所以被称为小印度,是当年英国殖民统治期间,印度人居住的地方。

在此之前,我一直以为新加坡是一个高度文明并且法律严苛的城市,制定了非常多的罚款,其中就包括对横穿马路的罚款。而真正来到这里,你看到的是许许多多的人横穿马路。这里的道路,很少设有人行道,大概只有在一个比较的大的十字路口才有,很多双向道路只能通过横穿马路的形式穿过。

多数情况下,坡的交通并不拥堵,井然有序。最重要的原因可能是在新加坡用车成本很高,除了车辆需要进口之外,还需要支付巨额的用车证。我在街上几乎找不到在中国满大街的特斯拉,而在这里一辆标续的 Model 3 需要将近 20W 新币。在路上跑的基本还都是很老的油车,电车并不多见。

这里的打车并不便宜。为了保护当地就业,也只有新加坡公民才能开网约车或出租车。

鱼尾狮与牛车水的故事

第二天的行程。前往鱼尾狮公园。

这一天是周日,傍晚时分,人依旧非常多,很多排队拍接水照片。

鱼尾狮的对面都是金沙酒店了。这里大概就是类似上海的外滩了。

坐船去对面,去苹果店摸了一下 air,真的好薄好喜欢。

水上玻璃球。

滨海湾金沙中的室内划艇。

赌场。

晚上吃的印尼椰浆饭,在食阁里面,价格实惠。13 新左右。椰浆泡过的米,吃起来挺香。这家店也是米其林指南推荐。

新加坡遍地都是美食,哪怕是食阁也有很多小店都是米其林指南推荐或是曾经被推荐过(新加坡食阁:通常是开放式、有屋顶但不封闭的公共餐饮区,由政府或大型物业统一管理。座位是共享的,几十个摊位围绕而设。食阁类似中国的美食广场、小吃街、夜市档口区)。

食阁是新加坡饮食文化的核心象征,聚集了马来、中餐、印度菜等多民族美食。也正是这样在这里可以吃到各地的美食。

晚上去了牛车水。牛车水的英文名是 Chinatown,意为唐人街。

在19世纪的新加坡,还没有自来水系统。 那时候,住在这一带的华人居民——主要是福建人和潮州人——要靠牛车运水从安祥山(Ann Siang Hill)或珍珠山(Pearl’s Hill)一带的水源处,把水拉回家中使用。

所以这一区域便被称为 “牛车水”(Hokkien:Gu Chia Chwee、潮州话:Gau Chia Chui),字面意思就是「用牛车运水的地方」。

当时的新加坡是英国殖民地,英国人实行 “族群分区居住”(Ethnic Enclaves) 政策:

  • 华人住在牛车水一带
  • 马来人主要住甘榜格南(Kampong Glam)
  • 印度人住小印度(Little India)
  • 欧洲人住在更靠近政府山(Fort Canning)和海边的行政区

此时的牛车水还在庆祝中秋,正巧此时也是印度人庆祝过年的节日。所以在这里几乎也是看到非常多的印度人。

从战火到现代:坡的历史篇章

War Memorial Park

圣安德鲁大教堂

下午天实在太热了,后面就去室内避暑了。来到了国家博物院,门票 18 新。

纪念币 12 新。

南洋华人也曾被鸦片毒害。

1942年2月15日,英国宣布在新加坡投降。新加坡进入日本的军事占领时期,并被改名为“昭南岛”(日语意为“南方之光”)。战争在其他地方仍在继续,而新加坡人民则在食物和燃料短缺、疾病肆虐以及日本人的暴力与骚扰下艰难生存。

直至1945年,日本无条件投降,新加坡才重新迎来解放——这座“东方的直布罗陀”,在战火与苦难中,重新看见了曙光。

坡的近代历史:

🇬🇧 1945:结束日本占领,但仍是英国殖民地

1945 年 9 月,日本正式向盟军投降。盟军(主要由英国军队领导)重新接管新加坡,成立了 英国军政署(British Military Administration) 来恢复秩序和重建。

📍这一时期(1945–1946)新加坡实际上仍然由英国直接管理,属于“军事管制”状态。


🇬🇧 1946:成为英国“直辖殖民地”

1946 年 4 月 1 日,军政结束。英国正式宣布新加坡成为 “英属直辖殖民地”(Crown Colony of Singapore)。 这一举措也意味着新加坡从马来亚分离出来,拥有独立的殖民地行政系统,但仍受英国统治。


🏛️ 1959:获得自治权

经过多年政治改革与选举,1959 年,新加坡终于获得 内部自治(self-government),李光耀(Lee Kuan Yew)成为首任民选总理。 不过,当时新加坡的外交与国防仍由英国掌控。


🇲🇾 1963–1965:短暂并入马来西亚

1963 年,新加坡与马来亚、沙巴和砂拉越共同组成 马来西亚联邦。 但由于政治与种族冲突不断,1965 年 8 月 9 日,新加坡被迫退出联邦,才正式成为独立的共和国(Republic of Singapore)

年份 事件 状态
1942–1945 日本占领(昭南岛时期) 日军统治
1945–1946 英国军事管制 盟军管理
1946–1959 英国直辖殖民地 英国统治
1959–1963 内部自治 李光耀政府成立
1963–1965 并入马来西亚 马来西亚联邦一员
|

1965 | 正式独立 | 新加坡共和国成立 |

从 1942 年的“昭南岛”到 2012 年的“世界之城”,新加坡仅用了 70 年。 它从一座战争创伤的殖民港口,成长为现代化、绿色、智慧的城市国家——这段历程,是关于重建、团结与远见的奇迹。

出了博物馆往上走就是福康宁公园。网红树洞在这里,即便是工作日的周一和错峰出行,这里仍有很多人在排队拍照。

晚上在 Funan 二刷鬼灭之刃,首刷是看的枪版,二刷必须支持一下。善逸帅的不行。

然后出门走了一段距离就是克拉玛头。

玩了一下弹弓,非常的刺激。

🇸🇬沉浸式体验人体弹弓!太刺激了吧

云雾林与热带奇观

今天的行程从滨海湾花园开始。天气非常的热,购票进入云雾林。室内还是比较凉快。

:::gallery :::

OCBC Skyway

:::gallery :::

侏罗纪主题的食阁

晚上,聚餐。

告别在圣淘沙

最后一天的行程非常的紧凑了,4 点必须到达樟宜机场。所以这天找找的就起床了,办完退房,行李寄存,直奔圣淘沙环球影城了。这天是周三,环球影城依然有很多人,有两个项目排了挺久但是最后因为天气原因、设备故障浪费了许多等待时间。

游玩的路线是 Sesame Street Spaghetti Space Chase -> TRANSFORMERS The Ride: The Ultimate 3D Battle -> Accelerator -> Revenge of the Mummy -> Battlestar Galactica: Human vs. Cylon -> Shrek 4‑D Adventure

顺序 项目名称 所属园区 类型 刺激度
Sesame Street Spaghetti Space Chase 🏙️ New York 亲子暗轨/轻松启程 🌱 低
TRANSFORMERS The Ride: The Ultimate 3D Battle 🚀 Sci-Fi City 3D 模拟战斗体验 ⚡ 中高
Accelerator 🚀 Sci-Fi City 旋转类轻度项目 🌿 低
Revenge of the Mummy 🏺 Ancient Egypt 室内黑暗过山车 🔥 高
Battlestar Galactica: Human vs. Cylon 🚀 Sci-Fi City(回头) 双轨对冲过山车 🚀 极高
Shrek 4-D Adventure 🏰 Far Far Away 4D 动画剧院 🍃 低

其他的项目时间关系来不及了,只能遗憾离开。


总结本次旅行还是比较充实的,也没有特种兵。每天的行程也不是很多,挺适合我这种老年人的。本次旅行人均花费 5000CNY 上下。

感谢导游:Whitewater, Song, DIYGod

下次还去!

看完了?说点什么呢