关于 portaly-user
portaly-user 用于将 Portaly Vibe 的用户管理 API 集成到产品用户生命周期中。它协助映射现有用户字段、添加同步钩子并回填已有用户,且要求先取得明确同意并确认 API 密钥。
主要功能
- 01将应用字段映射到 Portaly schema
- 02通过 Portaly API 同步用户
- 03添加基于生命周期的增量同步钩子
- 04回填已有用户
- 05要求确认用户同意及 API 密钥
适用场景
- 01在 Portaly 控制台查看产品用户
- 02将账户变更同步到 Portaly
- 03部署后同步现有用户
- 04将注册推荐码关联到用户
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ portaly-ai/ portaly-skills --skill portaly-user安装后在 Claude Code 中输入/portaly-user确认已加载。
附带文件references/api-contract.mdscripts/sync_user.mjs
详细介绍
Portaly 用户管理集成
使用此技能帮助用户集成 Portaly Vibe 的用户管理 API。创作者可以在 Portaly Vibe Dashboard 中查看自己的用户,以及哪些用户正在付费。
核心概念
- 数据源:用户数据存储在 Vibe 编码者的系统中。Portaly Vibe 是只读镜像,并叠加订阅状态。
- 同步 API:推送式。Vibe 编码者调用
POST /api/creator-subscription/admin/users/sync,将用户数据发送到 Portaly Vibe。 - Dashboard:创作者可在
https://portaly.ai/dashboard/members查看用户。此处为只读,所有变更都通过同步 API 完成。 - 订阅信息补充:每位用户对应的行都会显示其 Portaly 订阅状态(如有)。没有订阅则显示为“Free”。
API 主机
https://portaly.ai(默认值;可通过 PORTALY_API_HOST 环境变量覆盖)。
生成调用 Portaly API 的代码时,优先使用以下模式,而不是硬编码 URL:
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
scripts/sync_user.mjs 中的示例辅助函数已采用此模式。后端兼容性约定请参阅仓库根目录下的 PROVIDER.md。
身份验证
使用相同的 Creator Subscription API Key(pcs_live_* / pcs_test_*)。
- 同步 API(
POST .../users/sync)仅接受 API Key 身份验证(需要apiKeyId来识别数据归属)。 - GET 端点接受 API Key 和 Firebase JWT。
工作流
步骤 1 — 征得同意
开始任何操作前,AI Agent 必须征求用户的明确同意。请展示以下内容,并在继续步骤 2 之前等待用户回复:
此技能会将你系统中的用户同步到 Portaly Vibe,以便创作者在 Portaly Dashboard 中查看用户及其订阅状态。
这需要修改你的代码库:
- 读取你的用户模型,将字段映射到 Portaly 的架构
- 在注册、登录、更新和删除流程中添加自动同步钩子
- 对现有用户进行一次性回填——由我直接执行,或通过你在部署后运行的一次性脚本完成,无需 UI 按钮
你要继续吗?
**在用户明确同意前,请勿继续。**如果用户拒绝,请在此处停止,不要继续任何后续步骤。
步骤 2 — 映射用户架构
帮助 vibe coder 将其用户字段映射到 Portaly 架构。
先阅读 vibe coder 的用户模型(数据库架构、ORM 模型或类型定义),然后创建一张映射表,展示:其字段 → Portaly 字段。询问是否有缺失字段。
| Portaly 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
email | string | 是 | 去重键(每个 profile + api_key 唯一) |
external_user_id | string | 否 | Vibe coder 的内部用户 ID |
display_name | string | 否 | 用户显示名称 |
status | enum | 否 | active(默认值)、deleted(移除用户) |
role | string | 否 | 用户角色(例如 admin、member、viewer) |
plan_name | string | 否 | Vibe coder 自己的方案名称(不是 Portaly 订阅方案) |
last_login_at | ISO 8601 | 否 | 上次登录时间戳(例如 2026-04-15T08:30:00.000Z) |
created_at | ISO 8601 | 否 | 用户在 vibe coder 系统中的注册时间戳(例如 2026-01-10T12:00:00.000Z) |
metadata | object | 否 | 任意键值数据(最大 10KB) |
signup_ref_code | string | 否 | 注册时记录的折扣码/推荐码(例如来自 ?ref=EARLY2026)。此用户之后开始结账时,如果其邮箱已验证、代码仍处于有效状态且未达到每位客户的使用上限,Portaly 会自动应用匹配的规则。首次写入优先 — 记录后,后续同步无法覆盖。用户使用该代码注册之前,必须先通过 portaly-payment skill 在 Portaly 中创建该代码。 |
映射方法: 阅读 vibe coder 的用户模型,然后将可用字段与 Portaly 架构匹配。仅映射实际存在的字段;跳过系统中不存在的字段。email 是唯一必填字段。
- 不适合核心架构的字段 → 放入
metadata - 删除用户:使用
status: "deleted"同步(该记录会从 Portaly 中移除)
步骤 3 — 确认 API 密钥并选择初始同步方式
编写任何代码之前,先与用户确认两件事。两者都解决之前,不要继续步骤 4。
3a — 确认 PORTALY_API_KEY 是否可用
该 skill 需要 PORTALY_API_KEY 来调用 Sync API — 增量钩子(步骤 4)和初始回填(步骤 5)都依赖它。
- 检查用户的本地环境文件(
.env、.env.local、.env.development或框架的环境配置)中是否已有密钥。 - 如果找到了,请向用户确认:这是要同步到的环境所对应的正确密钥吗?
pcs_test_*密钥用于 Portaly 测试环境;pcs_live_*密钥用于生产环境。 - 如果没找到,请让用户从
https://portaly.ai/dashboard获取并粘贴。收到后,将其保存到适当的本地环境文件中。
如果密钥不可用,请在此处停止。 没有密钥,该 skill 就无法完成任务。告知用户获取密钥后重新运行该 skill。
3b — 选择初始同步方式
询问用户实际用户数据存放在哪里。答案将决定如何执行一次性回填。请原样使用以下提示(翻译成对话所用的语言):
初始同步需要将每位现有用户推送到 Portaly 一次。我可以通过以下两种方式之一完成:
- 选项 A — 我现在从你的本地环境运行。 我从本地代码所连接的数据库读取用户,并直接调用 Portaly API。如果你的本地数据库中有需要同步的用户(例如,你直接对接生产环境进行开发,或者已在本地加载生产数据),请选择此项。
- 选项 B — 我生成一个一次性脚本,供你部署后在生产环境运行。 我会创建
scripts/sync-portaly-once.mjs(或.ts)。设置好PORTALY_API_KEY并完成部署后,你在生产环境运行一次该脚本。如果你的开发数据库和生产数据库彼此独立,请选择此项。哪种方式适合你的设置?
等待用户选择后再继续。 步骤 5 会根据此答案分支。
步骤 4 — 添加同步辅助函数和增量钩子
添加 syncToPortaly 辅助函数,然后将其接入框架的用户生命周期钩子。步骤 5 中的初始同步也会复用同一个辅助函数。
批量同步辅助函数:
根据步骤 2 中的映射生成 syncToPortaly 函数。只包含 vibe coder 系统实际具有的字段。以下是完整示例 — 删除不适用的字段:
const PORTALY_API_KEY = process.env.PORTALY_API_KEY
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
async function syncToPortaly(users: Array<{
email: string;
id?: string | number; // → external_user_id
name?: string; // → display_name
role?: string; // → role
planName?: string; // → plan_name
lastLoginAt?: Date | null; // → last_login_at (ISO 8601)
createdAt?: Date | null; // → created_at (ISO 8601)
status?: string; // → status ('active' or 'deleted')
metadata?: Record<string, unknown>;
signupRefCode?: string; // → signup_ref_code (only on initial registration sync)
}>) {
const BATCH_SIZE = 100
const results = { synced: 0, created: 0, updated: 0, errors: [] as any[] }
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const batch = users.slice(i, i + BATCH_SIZE)
const payload = batch.map(user => ({
email: user.email,
external_user_id: user.id != null ? String(user.id) : undefined,
display_name: user.name,
role: user.role,
plan_name: user.planName,
last_login_at: user.lastLoginAt?.toISOString(),
created_at: user.createdAt?.toISOString(),
status: user.status || 'active',
metadata: user.metadata,
signup_ref_code: user.signupRefCode,
}))
try {
const res = await fetch(
`${PORTALY_API_HOST}/api/creator-subscription/admin/users/sync`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${PORTALY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: payload }),
}
)
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') || '5', 10)
await new Promise(r => setTimeout(r, retryAfter * 1000))
i -= BATCH_SIZE // retry this batch
continue
}
if (!res.ok) {
const text = await res.text()
throw new Error(`API error ${res.status}: ${text}`)
}
const { data } = await res.json()
results.synced += data.synced
results.created += data.created
results.updated += data.updated
results.errors.push(...data.errors)
} catch (err) {
results.errors.push({ batch: i / BATCH_SIZE + 1, reason: String(err) })
}
}
return results
}
将此辅助函数放在共享模块中 — 例如 lib/portaly-user-sync.ts(根据项目惯例调整)— 并导出 syncToPortaly。下面的增量钩子和步骤 5 中的初始同步入口都要导入同一个模块。该辅助函数对单个用户调用(传入只含一位用户的数组)和批量调用采用相同的处理方式。
插入增量钩子:
使用框架的 钩子 / 事件系统(例如 Payload afterChange、Prisma 中间件、Mongoose post-save)。同步辅助函数只调用 Portaly 外部 API — 绝不能调用应用自身的 API。
关键:所有同步调用都必须以发后即忘的方式执行。
// ✅ Correct: sync failure does not block the main flow
try {
await createUser(userData) // main business logic
} catch (err) {
return res.status(500).json({ error: 'Registration failed' })
}
// fire-and-forget — only log errors
syncToPortaly([userData]).catch(err => console.error('[Portaly Sync]', err))
// ❌ Wrong: sync failure causes the whole request to fail
await createUser(userData)
await syncToPortaly([userData]) // if this fails, user registration fails too
同步调用的插入位置 — 传入每个钩子位置可用的所有映射字段:
- 用户注册 — 注册成功后,使用所有可用字段同步新用户。如果注册表单或 URL 捕获了推荐 / 促销参数(例如
?ref=EARLY2026),请将其作为signup_ref_code传入,以便 Portaly 在该用户下次符合条件的结账时自动应用相应折扣。需要支持的常见 URL 模式:?ref=、?code=、?promo=、?coupon=。该代码必须已存在于 Portaly 中(通过portaly-paymentskill 创建);未知代码会被静默丢弃,并返回errors: [{ reason: 'unknown_signup_ref_code' }]— 用户仍会完成同步。首次写入优先 — 只有首次成功同步会记录该代码;后续同步如果传入不同的代码,该代码会被丢弃,并返回errors: [{ reason: 'signup_ref_code_already_recorded' }]。 - 个人资料更新 — 保存成功后,同步已更新的字段
- 登录 — 在框架的身份验证钩子中调用同步(例如 Payload
afterLogin、NextAuthevents.signIn、Supabase 身份验证 Webhook、Djangouser_logged_in信号、Flask-Loginuser_logged_in信号),并传入设为当前时间的last_login_at,格式为 ISO 8601。无需将其存储在 vibe coder 自己的数据库中 — 只需在调用时生成时间戳并发送给 Portaly。 - 账户删除 — 使用
status: "deleted"进行同步,以便从 Portaly 中移除该用户 - 候补名单注册 — 如果商家在可自托管模式(模式 B)下使用
portaly-emailskill,/waitlist/[creatorSlug]页面会收到关注者提交的邮箱和姓名。将其视为新用户,并在 POST 到/api/waitlist成功后调用syncToPortaly([{ email, name, status: 'active' }]),以发后即忘的方式执行
步骤 5 — 执行初始同步
请注意 — Portaly 可能会在此次同步时自动发送欢迎邮件。 当
syncToPortaly对用户执行 upsert 时,Portaly 默认会发送welcome_free邮件(如果用户有有效订阅,则发送welcome_paid邮件)。如果 vibe coder 的应用已经有自己的欢迎邮件流程,请在执行初始同步前禁用对应的模板,否则回填会导致每位现有用户都收到一封重复邮件。禁用方法:调用GET /api/creator-email/templates/welcome_free获取当前模板,然后将相同的载荷连同enabled: false一起通过PUT发送回去(该端点要求subject、greeting和body都是非空字符串;不能只发送{ enabled: false })。welcome_paid也采用相同的流程。详情请参阅portaly-emailskill。
根据步骤 3b 中选择的路径执行分支。
选项 A — 立即从本地运行
由 Agent 直接执行回填。无需 UI、按钮或额外路由。
- 使用框架的 Local API 或 ORM 从数据库读取所有用户(参见下方示例)。不要调用应用自身的 HTTP API。不要安装原始数据库驱动——使用框架已有的功能。
- 根据第 2 步中的映射,将每位用户映射到 Portaly 架构。
- 从第 4 步创建的共享模块中导入
syncToPortaly,并将映射后的用户传入调用——该辅助函数会处理分批、429 退避和错误报告。 - 向用户打印结果
{ synced, created, updated, errors }。如果errors不为空,则展示错误并提供重试选项。
框架读取示例:
- Payload CMS:
const payload = await getPayload({ config }); const { docs } = await payload.find({ collection: 'users', limit: 10000 }) - Prisma:
const users = await prisma.user.findMany() - Supabase:
const { data } = await supabase.from('users').select() - Mongoose:
const users = await User.find() - Drizzle:
const users = await db.select().from(users)
使用 node、tsx 或项目已有的任意临时运行器执行。对于非常大的数据集(数万名用户),提醒用户运行可能需要一段时间——有关节奏控制建议,请参见下方的防护措施。
选项 B — 生成一次性脚本
创建 scripts/sync-portaly-once.mjs(如果项目主要使用 TypeScript,也可创建 .ts 文件)。脚本必须:
- 从第 4 步创建的共享模块中导入
syncToPortaly(不要将辅助函数内联重复编写——保持单一事实来源可避免偏差) - 使用与选项 A 示例相同的框架 ORM/Local API 读取所有用户
- 按第 2 步进行映射,并调用
syncToPortaly(users) - 打印结果
{ synced, created, updated, errors },如果errors不为空则以非零状态码退出 - 从环境中读取
PORTALY_API_KEY和PORTALY_API_HOST(不要硬编码)
然后逐字告诉用户(翻译成对话所用的语言):
脚本位于
scripts/sync-portaly-once.mjs。在生产环境中设置PORTALY_API_KEY并部署后,请在生产环境中运行一次:PORTALY_API_KEY=pcs_live_xxx node scripts/sync-portaly-once.mjs只需运行一次。之后,第 4 步添加的增量钩子会自动保持 Portaly 同步。仅当数据出现偏差时才重新运行(例如,之前的同步失败,或数据库在应用之外被修改)。
第 6 步 — 验证并完成
实现所有同步钩子后,最终检查代码库、检查环境变量,并向用户展示结果。
6a — 端点检查清单
检查代码库并向用户提供检查清单。对于每个用户生命周期事件,检查是否存在 Portaly 同步调用:
## Portaly Sync Endpoint Checklist
✅ / ❌ User registration — {file path and line}
Reason: {why}
✅ / ❌ User login (update last_login_at) — {file path and line}
Reason: {why}
✅ / ❌ User profile update — {file path and line}
Reason: {why}
✅ / ❌ User deletion (status: "deleted") — {file path and line}
Reason: {why}
规则:
- 如果该钩子点存在即发即忘的
syncToPortaly调用,则使用 ✅ - 如果不存在同步调用,则使用 ❌——说明原因(例如“系统没有用户删除功能”或“此端点缺少同步,需要添加”)
- 如果缺少本应存在的钩子,请先添加,再继续
6b — 后续步骤与完成
告知用户集成已完成,然后列出以下操作项——这些事项必须由用户自行完成。必须包含全部三个环境变量——不得省略 PORTALY_CALLBACK_SECRET。 第 3 项仅适用于在第 3b 步选择了选项 B 的用户——如果选择了选项 A(初始同步已在第 5 步完成),请完全省略此项。
操作项:
-
设置环境变量,在生产/预发布环境中配置。请从
https://portaly.ai/dashboard获取这些变量。三个变量都必须设置:PORTALY_API_HOST=https://portaly.ai PORTALY_API_KEY=pcs_live_xxxxxxxxxxxxxxxxxxxxxxxx PORTALY_CALLBACK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxpcs_test_*密钥会同步到 Portaly 测试环境;pcs_live_*密钥会同步到生产环境。 -
部署应用——增量同步钩子只有在部署后才会触发。
-
部署后在生产环境运行一次性回填:
PORTALY_API_KEY=pcs_live_xxx node scripts/sync-portaly-once.mjs
然后说明:完成初始同步后,日常使用无需手动操作。 用户注册、登录、更新个人资料或删除账户时,系统会自动实时同步到 Portaly。只有在数据出现偏差时,才需要重新运行脚本(选项 B)或重新触发该技能的初始同步(选项 A)——例如,先前的同步失败,或数据库在应用之外被修改。
最后,指引用户前往 Portaly Dashboard 验证:https://portaly.ai/dashboard/members
防护要求
- 即发即弃:同步 API 调用必须采用非阻塞方式。Portaly 发生故障时,绝不能影响氛围编程者的核心业务流程。
- 批次上限:每次同步调用最多处理 100 位用户。更大的批次需拆分。
- 电子邮件是去重键:
UNIQUE(profile_id, api_key_id, email)。重复推送会安全地执行插入或更新。 - 元数据上限:每位用户 10KB。
- 调用间隔:v1 暂无速率限制,但建议批量迁移时每个批次之间间隔 200ms。
- 模式隔离:测试数据和线上数据完全分离。
- 删除:通过
status: "deleted"同步以从 Portaly 移除用户。没有单独的 DELETE 端点。 - 同步日志:Portaly 侧会记录每次同步调用。创建者可在 Dashboard 中查看同步历史和错误。
输出偏好
- 优先使用代码片段,而非架构说明。
- 使用氛围编程者现有的框架和语言。
- 始终使用即发即弃模式封装同步调用。
- 在任何 API 调用之前,先展示
.env配置。
参考文档
references/api-contract.md— 完整 API 规范(5 个端点)