从密码后台到飞书扫码登录:一次多公司管理员登录改造复盘

这篇文章记录一次用户管理后台的登录改造:我们把原来依赖固定后台口令的登录方式,调整成基于飞书 OAuth 的管理员扫码登录。改造过程中还有一个很现实的问题:两个不同公司的飞书用户不能简单塞进同一个飞书应用里一起登录。最后我们采用了“一个后台入口 + 多个飞书应用 Provider + 各自白名单”的设计。

文中的域名、公司名、应用 ID、应用 Secret、Open ID、Token 都做了脱敏。示例只展示结构,不展示真实生产配置。

背景

用户管理后台负责创建用户、充值、查看流水、配置倍率等操作。这些都是高权限能力,不能再靠一个可传来传去的固定口令保护。

我们想要的目标很直接:

  1. 管理员用飞书扫码登录。
  2. 只有指定的飞书用户能进后台。
  3. 登录系统不能影响已有客户 API Key、余额、流水和 MCP 调用。
  4. 后续加管理员时,尽量只改白名单,不改代码。
  5. 支持两个不同公司的管理员一起使用同一个后台。

最终落地后,后台业务用户数据没有迁移,也没有改表结构。飞书登录只保护管理后台,不参与客户 API Key 的鉴权链路。

最终架构

整体结构可以理解为三层:

浏览器
  |
  | GET /admin-ui
  v
用户管理前端
  |
  | GET /auth/me
  v
用户服务:返回当前登录状态 + 可用飞书 Provider 列表
  |
  | 点击某个 Provider 的飞书登录
  v
GET /login/feishu?provider=company_a
  |
  | 302 跳转到飞书授权页
  v
飞书 OAuth
  |
  | 回调 /auth/feishu/callback?code=...&state=...
  v
用户服务:换 token -> 拉用户信息 -> 校验白名单 -> 写 session cookie
  |
  v
进入管理后台

管理后台接口本身统一挂管理员认证依赖:

router = APIRouter(
    prefix="/admin",
    tags=["admin"],
    dependencies=[Depends(verify_admin)],
)

也就是说,前端页面可以打开,但真正读取用户列表、创建用户、充值、看流水、改倍率时,必须有合法管理员 session。

为什么一开始不能一个飞书应用解决所有人

我们最初的直觉是:建一个飞书应用,配置一个回调地址,然后把几个管理员的账号都加到白名单里。这样看起来最简单。

实际测试时发现,不同公司的人不一定能直接使用同一个公司的飞书应用授权登录。典型现象是:

你没有“某某后台登录”的使用权限
当前登录账号为 XXX(另一个组织),下列账号均无权限,你可使用其他账号登录

这说明问题不在我们的白名单逻辑,而在飞书应用本身的组织边界。一个企业自建应用天然归属于某个租户/组织。另一个公司的用户即使扫码,也可能在飞书授权阶段就被拦住,根本到不了我们的回调接口。

所以最后的设计不是“一个飞书应用管所有公司”,而是:

同一个后台系统
同一个回调接口
多个飞书应用配置
每个公司一个 Provider
每个 Provider 一份自己的管理员白名单

用户仍然访问同一个后台地址,只是在登录时选择自己所属公司的飞书登录入口。选择之后,系统会跳到对应公司的飞书应用完成授权。

配置模型

代码里保留了单应用配置,也新增了多 Provider 配置。多 Provider 配置存在时,优先使用多 Provider。

脱敏后的配置结构如下:

ADMIN_SESSION_SECRET=change-to-a-long-random-string
ADMIN_UI_PATH=/admin-ui

FEISHU_REDIRECT_URI=https://admin.example.com/auth/feishu/callback
FEISHU_ALLOW_ALL=false

FEISHU_PROVIDERS_JSON=[
  {
    "id": "company_a",
    "name": "公司 A",
    "app_id": "<FEISHU_APP_ID_A>",
    "app_secret": "<FEISHU_APP_SECRET_A>",
    "admin_open_ids": "<OPEN_ID_A_1>,<OPEN_ID_A_2>",
    "allow_all": false
  },
  {
    "id": "company_b",
    "name": "公司 B",
    "app_id": "<FEISHU_APP_ID_B>",
    "app_secret": "<FEISHU_APP_SECRET_B>",
    "admin_open_ids": "<OPEN_ID_B_1>,<OPEN_ID_B_2>",
    "allow_all": false
  }
]

真实生产环境里,这个 JSON 是一行环境变量,不提交到仓库。app_secret 只放在服务器环境变量或 .env 中,不出现在前端,也不写入博客、README 或 issue。

代码里对应的数据结构是:

@dataclass(frozen=True)
class FeishuProvider:
    id: str
    name: str
    app_id: str
    app_secret: str
    admin_emails: str = ""
    admin_open_ids: str = ""
    allow_all: bool = False

Provider 的 id 用来区分不同公司,name 用来给前端显示,app_id/app_secret 用来和飞书 OAuth 交互,admin_open_ids 是这一家公司的管理员白名单。

登录入口怎么生成

前端先调用:

GET /auth/me

后端返回当前是否已登录,以及哪些飞书 Provider 可用:

{
  "authenticated": false,
  "user": null,
  "feishu_enabled": true,
  "feishu_providers": [
    {"id": "company_a", "name": "公司 A"},
    {"id": "company_b", "name": "公司 B"}
  ],
  "legacy_password_enabled": false
}

注意这里不会返回 app_secret,也不会返回白名单。前端只知道 Provider 的 id 和展示名称。

前端根据 Provider 渲染登录按钮:

box.innerHTML = providers.map(p => `
  <button onclick="location.href='/login/feishu?provider=${encodeURIComponent(p.id)}'">
    ${providers.length > 1 ? p.name + ' · ' : ''}飞书登录
  </button>
`).join('');

用户点击“公司 A · 飞书登录”时,请求会进入:

GET /login/feishu?provider=company_a

后端找到对应 Provider,生成飞书授权地址:

state = make_oauth_state(feishu_provider.id)
query = {
    "client_id": feishu_provider.app_id,
    "redirect_uri": redirect_uri,
    "state": state,
}
return RedirectResponse(f"{feishu_auth_url}?{urlencode(query)}")

这里有一个关键点:state 里带了 Provider ID,并且是签名过的。这样回调回来时,我们知道这次登录来自哪个公司的飞书应用,不能被用户随便伪造。

二维码是谁生成的

这里的“扫码登录”不是我们自己生成二维码。我们的系统只负责把浏览器重定向到飞书 OAuth 授权地址:

https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=...&redirect_uri=...&state=...

用户看到的二维码、扫码确认、账号选择、组织权限判断,都是飞书授权页完成的。我们只在飞书回调回来以后处理 codestate

所以多公司方案里,每个公司实际对应一个不同的 client_id。用户从同一个后台入口出发,但点的是不同 Provider 的登录按钮,最终跳到对应公司的飞书应用授权页。

飞书应用侧要配置什么

每接入一个公司,就需要在这个公司的飞书开放平台里准备一个应用。核心配置只有几类:

飞书开放平台应用管理入口:

https://open.feishu.cn/app?lang=zh-CN

这里进入的是飞书开放平台的应用管理台。登录后,选择对应公司/组织,再创建或进入用于后台登录的应用。我们不是在自己的系统里创建二维码,也不是自己管理飞书账号密码,而是把“用户身份确认”交给飞书应用完成。

  1. 应用凭据:拿到 App ID 和 App Secret,放到服务端环境变量。
  2. 回调地址:配置成同一个后端回调接口,例如:
https://admin.example.com/auth/feishu/callback
  1. 登录能力:开启网页登录/扫码登录相关能力,让飞书能把授权结果回调给我们。
  2. 应用可用范围:确保该公司的管理员账号有权限使用这个应用。
  3. 用户信息权限:至少能在回调后拿到基础用户信息,尤其是 Open ID。

这里最容易出错的是回调地址。飞书后台配置的回调地址,必须和服务端传给飞书的 redirect_uri 完全一致。域名、协议、路径、末尾斜杠不一致,都可能触发“重定向 URL 有误”。

回调地址的原理是:用户在飞书页面扫码确认后,飞书不会直接告诉前端“这个人是谁”,而是把浏览器重定向回我们配置好的 callback,并带上一次性 code 和原样返回的 state。我们的后端收到 callback 后,再用服务端保存的 App Secret 去飞书换取 access token,随后调用 user_info 拿到用户身份。这样 App Secret 不会暴露在浏览器里。

state 为什么要签名

OAuth 回调里会带回 state。如果不签名,用户理论上可以改 URL 里的 Provider ID,让系统拿错应用配置处理回调。

我们的 state 是一个带过期时间的 HMAC 签名 token:

def make_signed_token(payload: dict, ttl_seconds: int) -> str:
    now = int(time.time())
    body = dict(payload)
    body["iat"] = now
    body["exp"] = now + ttl_seconds
    data = base64url(json.dumps(body))
    return f"{data}.{hmac_sha256(data, secret)}"

解析时会做三件事:

  1. 校验签名是否匹配。
  2. 校验 exp 是否过期。
  3. 校验 kind 是否是 feishu_oauth_state

所以回调只能使用我们刚刚发出去的 state,而且短时间内有效。

回调处理的数据流

飞书授权完成后,会回调:

GET /auth/feishu/callback?code=xxx&state=yyy

后端处理顺序是:

1. 检查 code 是否存在
2. 解析并校验 state
3. 从 state 里拿 provider_id
4. 找到对应 FeishuProvider
5. 用 code + app_id + app_secret 向飞书换 access_token
6. 用 access_token 调飞书 user_info
7. 从 user_info 里拿 name/email/open_id/union_id
8. 校验该用户是否在该 Provider 的管理员白名单里
9. 通过后生成后台 session cookie
10. 跳回 /admin-ui

核心代码逻辑如下:

token_resp = await client.post(
    feishu_token_url,
    json={
        "grant_type": "authorization_code",
        "client_id": provider.app_id,
        "client_secret": provider.app_secret,
        "code": code,
        "redirect_uri": redirect_uri,
    },
)

access_token = token_payload["data"]["access_token"]

user_resp = await client.get(
    feishu_user_info_url,
    headers={"Authorization": f"Bearer {access_token}"},
)

user = user_payload["data"]

用户信息示意:

{
  "name": "管理员姓名",
  "email": "optional@example.com",
  "open_id": "<OPEN_ID>",
  "union_id": "<UNION_ID>",
  "tenant_key": "tenant_xxx"
}

飞书不一定返回 email,尤其是权限、通讯录范围或账号设置不同的时候。因此我们最后主要用 open_id 做白名单。

白名单判断

白名单判断故意做得很简单:

def feishu_admin_allowed(user: dict, provider: FeishuProvider) -> bool:
    if provider.allow_all:
        return True

    allowed_emails = split_csv(provider.admin_emails)
    allowed_open_ids = split_csv(provider.admin_open_ids)

    email = str(user.get("email") or "").lower()
    open_id = str(user.get("open_id") or "").lower()

    if allowed_emails and email in allowed_emails:
        return True
    if allowed_open_ids and open_id in allowed_open_ids:
        return True
    return False

生产环境里 allow_all 必须保持 false。否则只要能通过对应飞书应用授权的人都能进入后台,风险太大。

如果用户飞书登录成功,但不在白名单里,系统会返回 403 页面,并展示脱敏前的关键信息:

飞书登录成功,但该账号未加入管理员白名单
公司/应用:公司 A
姓名:xxx
Email:-
Open ID:<OPEN_ID>

这个页面的作用很实际:第一次添加管理员时,不需要查数据库,也不需要额外写脚本。让对方扫码一次,失败页会告诉我们应该把哪个 Open ID 加到哪个 Provider 的白名单。

session 怎么做

白名单通过后,后端不会把飞书 access token 存下来,而是生成自己的后台 session:

payload = {
    "kind": "admin_session",
    "provider": "feishu",
    "provider_id": provider.id,
    "provider_name": provider.name,
    "open_id": user.get("open_id") or "",
    "union_id": user.get("union_id") or "",
    "email": user.get("email") or "",
    "name": user.get("name") or "",
}
session = make_signed_token(payload, ttl_seconds=86400)

然后写入 cookie:

response.set_cookie(
    "admin_session_cookie_name",
    session,
    max_age=86400,
    httponly=True,
    secure=True,
    samesite="lax",
)

几个安全点:

  1. httponly=True:前端 JS 读不到 cookie。
  2. secure=True:只走 HTTPS。
  3. samesite="lax":降低跨站请求风险,同时不影响正常 OAuth 回跳。
  4. session 自带过期时间。
  5. session 用服务端 secret 签名,客户端不能改。

退出登录时删除这个 cookie:

GET /logout

为什么没有影响客户 MCP 调用

这是这次改造里最重要的一点。

后台管理员登录和客户 API Key 鉴权是两条链路:

管理员后台:
浏览器 -> 用户服务 /admin-ui -> 飞书 session -> /admin/*

客户 MCP / Skills 调用:
客户端 -> Gateway -> 用户服务 /internal/check -> skill 后端

飞书登录只保护 /admin/* 管理接口,不参与 Gateway 的 Authorization: Bearer sk-... 检查,也不改客户余额表、流水表、API Key 生成逻辑。

所以改造时我们遵守了一个原则:只替换“管理员是谁”的认证方式,不碰“客户是谁、余额多少、能不能调用 skill”的业务链路。

两家公司共用后台的最终方案

最终方案可以画成这样:

                    ┌────────────────────┐
                    │ 同一个用户管理后台 │
                    └─────────┬──────────┘
                              │
                      /auth/me 返回两个 Provider
                              │
              ┌───────────────┴───────────────┐
              │                               │
       公司 A 飞书登录                  公司 B 飞书登录
              │                               │
       app_id/app_secret A              app_id/app_secret B
              │                               │
       open_id 白名单 A                 open_id 白名单 B
              │                               │
              └───────────────┬───────────────┘
                              │
              同一个 /auth/feishu/callback
                              │
                  state 里区分 provider_id

这个设计的好处:

  1. 后台地址只有一个,不需要部署两套用户系统。
  2. 每个公司的飞书应用独立,符合飞书组织边界。
  3. 每个公司的管理员白名单独立,避免互相污染。
  4. 回调接口只有一个,Caddy 和后端路由更简单。
  5. 后续再加第三家公司时,只需要新增一个 Provider 配置。

代价也很明确:

  1. 前端登录页需要让用户选择所属公司。
  2. 每个公司都要单独建飞书应用、配置回调地址。
  3. 每个 Provider 都有自己的 Open ID,不能把 A 公司的 Open ID 直接拿去 B 公司用。

新增一个公司管理员的操作流程

后续添加管理员时,我们按下面流程走:

  1. 确认这个人属于哪个公司。
  2. 让他从后台登录页选择对应公司飞书登录。
  3. 如果还没加入白名单,会看到“未授权”页面。
  4. 从页面里复制 Open ID。
  5. 把 Open ID 追加到对应 Provider 的 admin_open_ids
  6. 重启用户服务,让环境变量生效。
  7. 让他重新扫码登录。

只加同公司管理员时,不需要新建飞书应用。只有新增另一个飞书组织/公司时,才需要新建 Provider。

新增一个公司 Provider 的操作流程

如果要接入第三家公司,大致步骤如下:

  1. 打开飞书开放平台应用管理台:
https://open.feishu.cn/app?lang=zh-CN
  1. 在该公司的飞书开放平台创建应用,或进入已有的后台登录应用。
  2. 开启网页登录/扫码登录能力。
  3. 配置回调地址:
https://admin.example.com/auth/feishu/callback

这个 callback 必须和服务器环境变量里的 FEISHU_REDIRECT_URI 一致。后端发起飞书授权时,会把同一个地址作为 redirect_uri 传给飞书;如果两边不一致,飞书会直接拒绝并提示重定向 URL 有误。

  1. 获取该应用的 App ID 和 App Secret。
  2. 在服务器环境变量里给 FEISHU_PROVIDERS_JSON 追加一项:
{
  "id": "company_c",
  "name": "公司 C",
  "app_id": "<FEISHU_APP_ID_C>",
  "app_secret": "<FEISHU_APP_SECRET_C>",
  "admin_open_ids": "",
  "allow_all": false
}
  1. 重启用户服务。
  2. 让管理员扫码一次,拿到 Open ID。
  3. 把 Open ID 加入该 Provider 的白名单,再重启一次。

常见问题

1. 飞书提示“重定向 URL 有误”

这通常是飞书应用后台配置的回调地址和代码里传给飞书的 redirect_uri 不一致。

需要检查三处是否完全一致:

飞书应用后台回调地址
FEISHU_REDIRECT_URI
代码中实际发给飞书的 redirect_uri

协议、域名、路径、末尾斜杠都要一致。

2. 飞书提示“没有应用使用权限”

这通常发生在跨公司登录时。用户属于另一个飞书组织,但当前选择的是公司 A 的飞书应用。解决办法不是加白名单,而是给他的公司单独建飞书应用 Provider。

3. 登录成功但显示“不在管理员白名单”

说明飞书授权已经成功,我们也拿到了用户信息,只是白名单没放行。

处理方式:

复制页面上的 Open ID
追加到对应 Provider 的 admin_open_ids
重启用户服务
重新登录

4. 为什么不用手机号做白名单

手机号通常不是 OAuth 用户信息的默认字段,涉及更高权限和隐私授权。为了最小化权限,我们没有把手机号作为主识别方式。

实际实现里,email 也可能拿不到,所以最终以 Open ID 白名单为主。

5. Open ID 会不会变

Open ID 通常在同一个飞书应用/同一个租户上下文里稳定,但它不是跨公司、跨应用都通用的全局 ID。因此我们的白名单是挂在 Provider 下面的:

公司 A 的 open_id 只放公司 A Provider
公司 B 的 open_id 只放公司 B Provider

如果将来要做更复杂的跨组织身份统一,可以再评估 union_id、飞书开放平台应用形态和通讯录权限。但对管理员后台来说,Provider 级 Open ID 白名单更简单,也更可控。

安全边界

这次改造里,我们刻意做了几件事:

  1. 不再使用 URL token 进入管理后台。
  2. /admin/* 统一要求飞书管理员 session。
  3. App Secret 只在服务端环境变量里使用。
  4. 前端只拿 Provider 名称和 ID,不拿密钥和白名单。
  5. OAuth state 签名并设置短 TTL。
  6. 后台 session 签名并设置过期时间。
  7. Cookie 开启 HttpOnlySecureSameSite=Lax
  8. allow_all 默认关闭。
  9. 用户服务数据库不因飞书登录改造而迁移或重建。
  10. 客户 API Key 鉴权链路不走飞书,不受影响。

还有一个部署层面的要求:用户服务、数据库、上游代理等内部端口应该只监听本机或内网,由反向代理暴露必要路径。管理后台可以走公网域名,但数据库和内部服务不能直接暴露。

这次设计的核心经验

飞书扫码登录本身并不复杂,复杂的是组织边界和运维边界。

如果只有一个公司,一个飞书应用 + 一个白名单就够了。可一旦涉及多个公司,不能假设所有人都能进同一个飞书应用授权页。更稳妥的方式是把“公司/组织”抽象成 Provider:

Provider = 一套飞书应用凭据 + 一个展示名 + 一份管理员白名单

前端只负责让管理员选 Provider,后端通过签名 state 记住 Provider,回调时用对应的 App Secret 换 token,再按 Provider 自己的白名单放行。

这个设计保持了后台入口统一,也尊重了飞书的组织隔离。更重要的是,它没有侵入客户侧 API Key、余额、计费和 MCP 调用链路。管理员登录升级了,客户调用继续保持原样。

Read more

面向 AI Agent 的 ClickHouse 集群调优实战:从病根定位到"近乎白捡"

我们最近把一套面向 AI agent 的分析型数据服务,从单机迁到了 ClickHouse 集群(1 分片 × 3 副本 + HAProxy 入口),并做了一轮系统的性能调优。 这篇不止于"我们改了哪些参数",更想讲清楚背后的思路:怎么用数据定位病根、怎么在"空间/复杂度/收益"之间取舍、怎么做到改完能验证、出事能秒回滚、上线不断服务。如果你也在为高重复、只读、模板化的负载(AI agent、看板、报表 API)调 ClickHouse,这套方法可以直接借鉴。 一条主线贯穿全文:先吃透流量特征,再分层优化,每一步都可验证、可回滚。 一、起点:先吃透你的流量长什么样 任何优化的第一步不是动手,

By ladydd

用阿里云 text-embedding-v4 搭一个便宜好用的语义召回层

很多系统一开始都靠关键词匹配。 用户搜“车载腰靠”,数据库里有“汽车腰枕”“lumbar support pillow for car”,如果只做 LIKE 或倒排词,召回很容易断掉。Embedding 解决的是这个问题:把文本变成向量,让“意思接近”的内容在向量空间里靠近。 阿里云百炼里的 text-embedding-v4 很适合做这件事。它接入简单,兼容 OpenAI 风格接口,价格也低,适合拿来做搜索召回、RAG 知识库、商品词聚类、类目匹配、相似标题推荐。 本文只讲一件事:怎么把 text-embedding-v4 接进自己的系统。 一句话结论 如果你要给文本做语义召回,可以这样设计: 业务文本 -> 清洗/去重 -> text-embedding-v4 -&

By ladydd

一个带进度条的 tar.gz 多核解压脚本

大文件解压这件事,平时看起来很小,真遇到几十 GB 的 tar.gz 包时就会变得很烦。 最常见的命令是: tar -xzf archive.tar.gz -C output/ 它能用,但有几个问题: * gzip 解压基本是单核,机器有很多核也用不上。 * 没有进度条,不知道还要跑多久。 * 目标目录已经存在时容易把新旧文件混在一起。 * 脚本化重跑时,参数和目录约定容易写散。 所以我写了一个小脚本:extract.sh。它不是为了炫技,而是把一次大包解压里最容易踩坑的地方都收起来。 它解决什么问题 这个脚本做的是一件很具体的事: 把 .tar.gz 或 .tgz 文件解压到普通目录,同时显示百分比、速度和 ETA;如果机器上有 pigz,自动使用多核解压。 典型用法: bash extract.sh archive.

By ladydd

三台机器部署 ClickHouse 高可用集群实战记录

本文是一份可发布版部署记录。真实 IP、域名、账号、密码、下载链接、业务目录名、机器唯一标识等敏感信息已经替换为占位符。命令中的 <...> 需要按自己的环境替换。 目标与拓扑 这次目标是用三台数据节点部署一套 ClickHouse 高可用集群,拓扑采用: 1 shard x 3 replicas 含义是:集群只有一个逻辑分片,三台机器都保存同一份数据的完整副本。任意一台数据节点宕机时,只要 ClickHouse Keeper 仍然有多数派,剩余节点仍可继续提供读写服务。 规划节点如下: 主机名示例地址角色ch-01<ch-01-ip>ClickHouse Server + ClickHouse Keeperch-02<ch-02-ip>ClickHouse Server + ClickHouse Keeperch-03<ch-03-ip&

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号