登录与用户在 Func 里查询用户

在 Func 里查询用户

ctx.users 是项目的用户目录:find 按标识符精确找一个人,query 按条件分页翻名单。含用户对象的完整字段表、find 的三个陷阱、query 的过滤与排序规则,以及最关键的一节——平台不做角色判断,门禁必须由站点代码自己写。

站点的用户账号由平台管理,Func 通过 ctx.users 读写这份项目级用户目录:按标识符找一个人、按条件查一批人、校验和重设密码、更新用户自定义字段。它和 ctx.auth 是两件事——后者只关心"发起这次调用的是谁"。

先分清 ctx.auth 与 ctx.users

这两个命名空间最容易被混着用,而混用的后果不对称:把 ctx.users 当成"当前用户"来写,一个填错的邮箱就会改到别人头上。

命名空间作用域方法
ctx.auth发起这次调用的人,来自会话 CookiecurrentUser() / requireUser() / login() / register()
ctx.users整个项目的用户目录,能指到任何一个人find() / query() / checkPassword() / setPassword()

凡是修改类的调用,标识符要来自服务端刚刚确认过的事实,而不是浏览器传上来的字段。改当前用户的密码应该写 ctx.users.setPassword({ userId: ctx.auth.requireUser().id, ... }),不是 { email: input.email }——后者等于"谁自称是谁就改谁"。

用户对象

ctx.auth.currentUser()ctx.users.find()ctx.users.query() 返回的是同一种结构。字段名是下划线风格

字段说明
id用户主键,始终存在。这是唯一该用来做归属键的值
account / email / phone三种标识符,都可能为空——取决于用户是怎么注册的
name / avatar展示名与头像 URL。没有 nickname 字段
statusenableddisabled
profile站点自定义字段,见用户自定义字段
last_login_at / created_at / updated_at时间戳

对象里没有密码、会话 token、OAuth 绑定列表和任何内部 ID。这些都不会进入沙箱。

找一个人:find

ctx.users.find(ref) 按标识符精确定位一个用户,userId / email / account 三个字段只能填一个,填两个是 400。

import type { TalizenFuncContext } from 'talizen/func-runtime'

export function lookup(input, ctx: TalizenFuncContext) {
  const user = ctx.users.find({ email: input.email })
  if (!user) return { ok: false }   // 找不到返回 null,不抛错
  return { ok: true, name: user.name }
}

三个陷阱:

  • 找不到返回 null,不是抛错。"这个人存不存在"是站点要分支处理的正常结果,不该用 try/catch 包。
  • 同一个邮箱可能对应多个用户,这时 find 会抛 409 而不是随便挑一个。允许重复邮箱的项目应该用 accountuserId 查。
  • account 只能找到有密码身份的用户。纯 OAuth 注册的账号没有密码身份行,按 account 查不到——用 emailuserId

find 的结果直接暴露成"这个邮箱注册过没有"是一个账号枚举口子。忘记密码、注册这类面向匿名访问者的流程,两个分支必须返回完全一样的东西,详见找回与修改密码

查一批人:query

ctx.users.query(query) 按条件分页返回用户,形状与 ctx.db.query 一致:{ total, list, limit }

export function directory(input, ctx: TalizenFuncContext) {
  requireAdmin(ctx)   // 见下一节,这一行不能省

  const result = ctx.users.query({
    search: input.keyword,        // 模糊匹配 account / email / phone / name
    status: 'enabled',            // 'enabled' | 'disabled',不传则不过滤
    order_by: 'created_at desc',
    limit: 20,
    offset: (input.page - 1) * 20,
  })

  // 只把页面真正需要的字段交出去
  return {
    total: result.total,
    list: result.list.map((user) => ({
      id: user.id,
      name: user.name,
      createdAt: user.created_at,
    })),
  }
}
参数说明
searchaccountemailphonename 四个字段上做包含匹配。不传则不过滤
statusenableddisabled
limit默认 20,上限 100,超出会被截断;返回值里的 limit 是实际生效值
offset分页偏移
order_by可用 created_atlast_login_atid,默认 created_at desc

query 返回的用户对象不含 profile自定义字段可能包含只该给后台看的内容,列表接口一律不带;需要某个人的完整资料时,拿到 idfind 一次。

没有聚合、没有分组、没有按 profile 字段过滤。要按业务维度筛人(比如"买过课的用户"),把业务事实写进自己的 JSON 表并以 user.id 关联,然后查表——用户目录不是业务数据库。

门禁:这一节不能跳过

find 有一道天然门槛——你得先知道对方的标识符。query 没有这道门槛:它把用户目录变成可以整页翻的东西。一个忘了加权限判断的 Func,就是一个公开的客户名单导出接口。

// 平台不知道谁是管理员,这个判断只能你自己写
function requireAdmin(ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()          // 1. 必须登录
  const admin = ctx.db.get('admins', user.id)  // 2. 必须在你自己的授权表里
  if (!admin) throw new Error('forbidden')
  return user
}
  • 每一个调用 query 的 Func 都要先 requireUser(),再判断这个人有没有资格。平台只做项目隔离,不做角色判断。
  • 不要把 result.list 直接返回给浏览器。用户对象里有邮箱和手机号,按页面实际需要挑字段再返回。
  • 需要"管理员"概念时,用自己的 JSON 表存授权关系,用 user.id 关联。不要用邮箱后缀之类的规则硬编码在代码里。
  • 用户目录的读取结果不会被缓存,每次都反映库里的真实账号行。

用户自定义字段 profile

除了内置字段,每个项目可以给用户定义一组自定义字段,schema 在编辑器的后端 → 用户里配置。每个字段有两个开关:

开关含义
x-customer-readable浏览器能不能读到这个字段(默认能)
x-customer-writable浏览器能不能改这个字段(默认不能

这两个开关约束的是浏览器。Func 是服务端代码,find() 读到的是未经过滤的完整 profile——包括标了 x-customer-readable: false 的字段。所以在 Func 里把 user.profile 整个返回给页面,等于绕过了这个开关。

export function me(input, ctx: TalizenFuncContext) {
  const user = ctx.users.find({ userId: ctx.auth.requireUser().id })

  // 不要 return { profile: user.profile }:里面可能有只该后台看的字段
  return { plan: user.profile?.plan ?? 'free' }
}

query() 返回的用户不带 profile,就是为了让"顺手把列表整个转发出去"不至于变成一次批量泄漏。要某个人的完整资料,拿 idfind 一次——那是一次明确的、针对单个人的读取。

Func 里目前不能修改 profile。它只在 ctx.auth.register({ profile }) 建号那一刻可写,之后没有更新入口。所以会变化的用户数据——套餐状态、积分、偏好——应该放进自己的 JSON 表并用 user.id 关联,profile 留给注册时就定下来的少量属性。

什么时候不该用它

不要自建用户表

不要建 usersauth_users 这类身份表,也不要用邮箱当业务主键。账号、密码、会话、OAuth 都是平台能力,见在 Func 里实现登录

不要拿它做业务查询

用户目录只能按标识符和这几个内置字段筛选。业务维度的筛选写进自己的 JSON 表,用 user.id 关联。

不要在页面里做权限

"只有管理员能看到这个列表"必须在 Func 里判断。浏览器侧的隐藏只是显示效果,接口仍然可以被直接调用。

不要用它替代登录态

判断"当前是谁"永远用 ctx.authctx.users.find 能拿到任何人的对象,它不证明任何登录事实。

验收清单

  • 每个用到 ctx.users 的 Func 都先 requireUser(),并对越权访问做了判断。
  • 返回给浏览器的是挑选过的字段,不是完整用户对象,也不是完整 profile
  • 修改类调用的标识符来自服务端确认过的事实,不是浏览器传入的邮箱。
  • 面向匿名访问者的流程,用户存在与否两个分支返回完全一致。
  • 列表接口带 limitoffset,按 total 翻页。
  • 会变化的用户数据在 JSON 表里以 user.id 关联,没有指望 profile(它在 Func 里只读),也没有另建身份表。

Render diagnostics