mcpField
P
portaly-user

portaly-user

github.com/portaly-ai数据与数据库264 次安装3 天前更新
代码仓库

关于 portaly-user

portaly-user 用于将 Portaly Vibe 的用户管理 API 集成到产品用户生命周期中。它协助映射现有用户字段、添加同步钩子并回填已有用户,且要求先取得明确同意并确认 API 密钥。

主要功能

  1. 01将应用字段映射到 Portaly schema
  2. 02通过 Portaly API 同步用户
  3. 03添加基于生命周期的增量同步钩子
  4. 04回填已有用户
  5. 05要求确认用户同意及 API 密钥

适用场景

  1. 01在 Portaly 控制台查看产品用户
  2. 02将账户变更同步到 Portaly
  3. 03部署后同步现有用户
  4. 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 中查看用户及其订阅状态。

这需要修改你的代码库:

  1. 读取你的用户模型,将字段映射到 Portaly 的架构
  2. 在注册、登录、更新和删除流程中添加自动同步钩子
  3. 对现有用户进行一次性回填——由我直接执行,或通过你在部署后运行的一次性脚本完成,无需 UI 按钮

你要继续吗?

**在用户明确同意前,请勿继续。**如果用户拒绝,请在此处停止,不要继续任何后续步骤。

步骤 2 — 映射用户架构

帮助 vibe coder 将其用户字段映射到 Portaly 架构。

先阅读 vibe coder 的用户模型(数据库架构、ORM 模型或类型定义),然后创建一张映射表,展示:其字段 → Portaly 字段。询问是否有缺失字段。

Portaly 字段类型必填说明
emailstring是去重键(每个 profile + api_key 唯一)
external_user_idstring否Vibe coder 的内部用户 ID
display_namestring否用户显示名称
statusenum否active(默认值)、deleted(移除用户)
rolestring否用户角色(例如 admin、member、viewer)
plan_namestring否Vibe coder 自己的方案名称(不是 Portaly 订阅方案)
last_login_atISO 8601否上次登录时间戳(例如 2026-04-15T08:30:00.000Z)
created_atISO 8601否用户在 vibe coder 系统中的注册时间戳(例如 2026-01-10T12:00:00.000Z)
metadataobject否任意键值数据(最大 10KB)
signup_ref_codestring否注册时记录的折扣码/推荐码(例如来自 ?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)都依赖它。

  1. 检查用户的本地环境文件(.env、.env.local、.env.development 或框架的环境配置)中是否已有密钥。
  2. 如果找到了,请向用户确认:这是要同步到的环境所对应的正确密钥吗?pcs_test_* 密钥用于 Portaly 测试环境;pcs_live_* 密钥用于生产环境。
  3. 如果没找到,请让用户从 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-payment skill 创建);未知代码会被静默丢弃,并返回 errors: [{ reason: 'unknown_signup_ref_code' }] — 用户仍会完成同步。首次写入优先 — 只有首次成功同步会记录该代码;后续同步如果传入不同的代码,该代码会被丢弃,并返回 errors: [{ reason: 'signup_ref_code_already_recorded' }]。
  • 个人资料更新 — 保存成功后,同步已更新的字段
  • 登录 — 在框架的身份验证钩子中调用同步(例如 Payload afterLogin、NextAuth events.signIn、Supabase 身份验证 Webhook、Django user_logged_in 信号、Flask-Login user_logged_in 信号),并传入设为当前时间的 last_login_at,格式为 ISO 8601。无需将其存储在 vibe coder 自己的数据库中 — 只需在调用时生成时间戳并发送给 Portaly。
  • 账户删除 — 使用 status: "deleted" 进行同步,以便从 Portaly 中移除该用户
  • 候补名单注册 — 如果商家在可自托管模式(模式 B)下使用 portaly-email skill,/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-email skill。

根据步骤 3b 中选择的路径执行分支。

选项 A — 立即从本地运行

由 Agent 直接执行回填。无需 UI、按钮或额外路由。

  1. 使用框架的 Local API 或 ORM 从数据库读取所有用户(参见下方示例)。不要调用应用自身的 HTTP API。不要安装原始数据库驱动——使用框架已有的功能。
  2. 根据第 2 步中的映射,将每位用户映射到 Portaly 架构。
  3. 从第 4 步创建的共享模块中导入 syncToPortaly,并将映射后的用户传入调用——该辅助函数会处理分批、429 退避和错误报告。
  4. 向用户打印结果 { 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 步完成),请完全省略此项。

操作项:

  1. 设置环境变量,在生产/预发布环境中配置。请从 https://portaly.ai/dashboard 获取这些变量。三个变量都必须设置:

    PORTALY_API_HOST=https://portaly.ai
    PORTALY_API_KEY=pcs_live_xxxxxxxxxxxxxxxxxxxxxxxx
    PORTALY_CALLBACK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
    

    pcs_test_* 密钥会同步到 Portaly 测试环境;pcs_live_* 密钥会同步到生产环境。

  2. 部署应用——增量同步钩子只有在部署后才会触发。

  3. 部署后在生产环境运行一次性回填:

    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 个端点)

评论

支持 Markdown