在 Func 里查询用户
ctx.users 是项目的用户目录:find 按标识符精确找一个人,query 按条件分页翻名单。含用户对象的完整字段表、find 的三个陷阱、query 的过滤与排序规则,以及最关键的一节——平台不做角色判断,门禁必须由站点代码自己写。
站点的用户账号由平台管理,Func 通过 ctx.users 读写这份项目级用户目录:按标识符找一个人、按条件查一批人、校验和重设密码、更新用户自定义字段。它和 ctx.auth 是两件事——后者只关心"发起这次调用的是谁"。
先分清 ctx.auth 与 ctx.users
这两个命名空间最容易被混着用,而混用的后果不对称:把 ctx.users 当成"当前用户"来写,一个填错的邮箱就会改到别人头上。
| 命名空间 | 作用域 | 方法 |
|---|---|---|
ctx.auth | 发起这次调用的人,来自会话 Cookie | currentUser() / 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 字段 |
status | enabled 或 disabled |
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 而不是随便挑一个。允许重复邮箱的项目应该用account或userId查。 account只能找到有密码身份的用户。纯 OAuth 注册的账号没有密码身份行,按account查不到——用email或userId。
把
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,
})),
}
}
| 参数 | 说明 |
|---|---|
search | 在 account、email、phone、name 四个字段上做包含匹配。不传则不过滤 |
status | enabled 或 disabled |
limit | 默认 20,上限 100,超出会被截断;返回值里的 limit 是实际生效值 |
offset | 分页偏移 |
order_by | 可用 created_at、last_login_at、id,默认 created_at desc |
query 返回的用户对象不含 profile。自定义字段可能包含只该给后台看的内容,列表接口一律不带;需要某个人的完整资料时,拿到 id 再 find 一次。
没有聚合、没有分组、没有按
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,就是为了让"顺手把列表整个转发出去"不至于变成一次批量泄漏。要某个人的完整资料,拿 id 再 find 一次——那是一次明确的、针对单个人的读取。
Func 里目前不能修改 profile。它只在
ctx.auth.register({ profile })建号那一刻可写,之后没有更新入口。所以会变化的用户数据——套餐状态、积分、偏好——应该放进自己的 JSON 表并用user.id关联,profile 留给注册时就定下来的少量属性。
什么时候不该用它
不要自建用户表
不要建 users、auth_users 这类身份表,也不要用邮箱当业务主键。账号、密码、会话、OAuth 都是平台能力,见在 Func 里实现登录。
不要拿它做业务查询
用户目录只能按标识符和这几个内置字段筛选。业务维度的筛选写进自己的 JSON 表,用 user.id 关联。
不要在页面里做权限
"只有管理员能看到这个列表"必须在 Func 里判断。浏览器侧的隐藏只是显示效果,接口仍然可以被直接调用。
不要用它替代登录态
判断"当前是谁"永远用 ctx.auth。ctx.users.find 能拿到任何人的对象,它不证明任何登录事实。
验收清单
- 每个用到
ctx.users的 Func 都先requireUser(),并对越权访问做了判断。 - 返回给浏览器的是挑选过的字段,不是完整用户对象,也不是完整
profile。 - 修改类调用的标识符来自服务端确认过的事实,不是浏览器传入的邮箱。
- 面向匿名访问者的流程,用户存在与否两个分支返回完全一致。
- 列表接口带
limit和offset,按total翻页。 - 会变化的用户数据在 JSON 表里以
user.id关联,没有指望profile(它在 Func 里只读),也没有另建身份表。
