协作开发总览

Align 开放平台让外部开发者以「插件」形式扩展平台能力,主体源码完全不外露。

协作模式

Align 采用插件隔离协作模式:

  • 主体仓库(私有):核心源码,只有 Align 团队访问
  • 插件仓库(每功能一个独立私有 repo):协作者只能看到自己负责的插件
  • SDK 仓库公开):定义了插件与 Align 交互的完整接口
架构图
Align 主体(私有)
  └── pluginLoader → /api/plugins/{name}/*
         ↑
         |  实现 AlignPlugin 接口
         |
   align-plugin-wecom   (私有,企微开发者)
   align-plugin-payment (私有,支付开发者)
   align-plugin-xxx     (未来更多...)
         |
         └── 依赖 @aimacgao-lab/align-plugin-sdk (公开)

现有插件仓库

仓库可见性功能状态
align-plugin-sdk 公开 TypeScript 类型合约 ✅ 已发布
align-plugin-wecom 私有 企业微信集成 🚧 骨架待实现
align-plugin-payment 私有 支付(微信/支付宝) 🚧 骨架待实现

协作者接入流程

收到仓库邀请

点击邀请邮件里的「Accept invitation」,获得对应插件仓库的访问权限。

克隆 + 安装

bash
git clone https://github.com/aimacgao-lab/align-plugin-wecom.git
cd align-plugin-wecom
npm install

实现 TODO

打开 src/index.ts,找到 TODO 注释,逐一实现各路由 handler。

提 PR

bash
git checkout -b feat/your-feature
npm run build          # 确认编译通过
git add -A && git commit -m "feat: ..."
git push origin feat/your-feature

在 GitHub 上创建 PR → 等待 Align 团队 review → merge。

快速入门

从零到提交第一个 PR,约 30 分钟。

📋 前置条件
已收到插件仓库邀请、Node.js 18+、npm 或 pnpm

环境搭建

bash
git clone https://github.com/aimacgao-lab/align-plugin-wecom.git
cd align-plugin-wecom
npm install

目录结构

目录
align-plugin-wecom/
├── src/
│   └── index.ts       ← 插件入口,export default plugin
├── package.json
├── tsconfig.json
└── README.md          ← 功能清单和约束说明

写一个 Handler

typescript
import type { AlignPlugin, PluginContext } from '@aimacgao-lab/align-plugin-sdk';

async function getContacts(ctx: PluginContext): Promise<void> {
  const { userId } = ctx.iam!;
  const binding = await ctx.db.collection('bindings').findOne({ userId });
  if (!binding) {
    ctx.res.status(400).json({ error: '未绑定企微账号' });
    return;
  }
  // 调企微 API 拿外部联系人...
  ctx.res.json({ contacts: [] });
}

const plugin: AlignPlugin = {
  name: 'wecom',
  version: '1.0.0',
  routes: [
    { method: 'get', path: '/contacts', handler: getContacts },
  ],
};

export default plugin;

本地调试技巧

无需启动完整 Align 服务,直接 mock ctx:

typescript
const ctx = {
  iam: { userId: 'test-user', orgId: 'org-1', role: 'user', email: '', name: '' },
  req: { body: {}, query: {}, params: {}, headers: {} },
  res: {
    json: (d) => console.log(JSON.stringify(d, null, 2)),
    status: () => ctx.res,
    send: console.log,
  },
  db: { collection: () => mockCollection },  // 你的 mock
  align: { getUser: async () => null, /* ... */ },
};

await getContacts(ctx);

PR 规范

项目要求
编译npm run build 必须通过(0 TypeScript 错误)
提交格式feat/fix/chore(scope): 描述
PR 描述说明实现了哪些路由、怎么验证
环境变量新增的 env key 在 PR 描述里列出,由主库配置
幂等性回调接口必须幂等(同一事件多次通知只处理一次)

常见问题

Q: 我能访问 Align 的数据库吗?

不能。ctx.db 只能访问以 plugin_{名称}_ 为前缀的集合,Align 主体数据完全隔离。

Q: 路由挂在哪里?

插件名 wecom,路由 /contacts 实际挂载到 /api/plugins/wecom/contacts

Q: 我需要处理鉴权吗?

不需要。route.auth !== false 时 Align 自动验证 JWT,ctx.iam 里就是已验证的用户信息。

Plugin SDK 手册

完整的运行时接口参考。SDK 源码: align-plugin-sdk

AlignPlugin(插件入口)

每个插件的 src/index.ts 必须默认导出一个 AlignPlugin 对象:

typescript
const plugin: AlignPlugin = {
  name: 'wecom',        // 英文小写,决定路由前缀
  version: '1.0.0',
  routes: [...],
  onInstall: async (ctx) => { /* 可选,首次加载时执行 */ },
};
export default plugin;

PluginContext

字段类型说明
iam?IamInfo当前登录用户(auth:false 时 undefined)
reqPluginRequest请求信息(body/query/params/headers)
resPluginResponse响应工具(json/status/send)
dbPluginDB插件专属数据库(集合自动加前缀)
alignAlignServicesAlign 平台能力(查用户/发 IM/通知/SSE)

ctx.iam

字段类型说明
userIdstringAlign 用户 ID
orgIdstring所属组织 ID
rolestring'user' | 'admin' | 'super_admin'
emailstring邮箱
namestring姓名

ctx.db

隔离保证
ctx.db.collection('orders') 实际操作 MongoDB 集合 plugin_{name}_orders,无法访问 Align 主体集合。
方法返回说明
findOne(filter)T | null查一条
find(filter, opts?)T[]查多条,支持 limit/sort
insertOne(doc)T & {_id:string}插入
updateOne(filter, update, opts?)void更新($set),支持 upsert
deleteOne(filter)void删除
count(filter)number计数

ctx.align

方法说明
getUser(userId)查 Align 用户信息(id/name/email/orgId/role/avatar)
getOrg(orgId)查组织信息(id/name/memberCount)
sendIM(userId, content)向用户发系统 IM 消息(出现在聊天页)
notify({userId, title, body, link?})发铃铛通知
emit(userId, domain, action, id)触发前端 SSE 实时刷新

路由定义

typescript
{
  method: 'get' | 'post' | 'put' | 'delete' | 'patch',
  path: '/your-path',         // 相对路径
  handler: async (ctx) => { ... },
  auth: true,               // 默认 true;Webhook 等设 false
}
// 最终挂载: /api/plugins/{pluginName}{path}

环境变量

插件通过 process.env 读取变量,变量由 Align 主库负责人配置到生产服务器:

typescript
const CORP_ID = process.env.WECOM_CORP_ID;
if (!CORP_ID) throw new Error('WECOM_CORP_ID not configured');
⚠️ 安全
新增的环境变量名必须在 PR 描述里列出,由主库负责人配置。不要在代码里硬编码密钥。

企业微信集成规格

仓库:align-plugin-wecom(私有)— OAuth 绑定 · 外部联系人 · 消息收发

功能范围

功能路由状态
员工 OAuth 绑定企微账号GET /oauth/redirect, /oauth/callbackTODO
查看外部联系人GET /contactsTODO
向外部联系人发消息POST /messages/sendTODO
接收企微回调(Webhook)POST /webhookTODO

环境变量

变量名说明获取位置
WECOM_CORP_ID企业 ID企微管理后台 → 我的企业
WECOM_AGENT_ID自建应用 AgentId应用管理 → 自建应用
WECOM_SECRET应用 Secret同上
WECOM_TOKEN消息推送 Token应用接收消息设置
WECOM_AES_KEY消息加解密 Key(43位)同上

OAuth 绑定流程

流程
Align 前端 → GET /api/plugins/wecom/oauth/redirect
          ← 返回企微 OAuth URL(前端跳转)
             ↓ 用户授权
企微 → GET /api/plugins/wecom/oauth/callback?code=xxx
     → 存绑定关系 plugin_wecom_bindings
     ← 重定向回 Align
⚠️ 48h 限制
企微外部联系人消息接口要求客户在 48小时内有过互动才可主动发送。超出限制返回错误码 130822,需给用户友好提示。

Access Token 缓存

企微 Access Token 有效期 7200 秒,每日请求上限 2000 次,必须缓存:

typescript
async function getAccessToken(ctx: PluginContext): Promise<string> {
  const cache = await ctx.db.collection('token_cache').findOne({ key: 'access_token' });
  if (cache && cache.expiresAt > Date.now() + 60_000) return cache.token;

  const resp = await fetch(
    `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${CORP_ID}&corpsecret=${SECRET}`
  );
  const { access_token, expires_in } = await resp.json();

  await ctx.db.collection('token_cache').updateOne(
    { key: 'access_token' },
    { key: 'access_token', token: access_token, expiresAt: Date.now() + expires_in * 1000 },
    { upsert: true }
  );
  return access_token;
}

Webhook 解密

企微推送使用 AES-CBC-256 加密 + XML 格式:

解密流程
1. 验签
   msg_signature = sha1(sort([token, timestamp, nonce, encrypt_msg]))

2. AES-CBC 解密
   key = base64_decode(WECOM_AES_KEY + '=')   // 补全 padding
   iv  = key[0..16]
   plaintext = aes_cbc_decrypt(ciphertext, key, iv)
   // plaintext = random(16) + msg_len(4) + xml_content + appid

3. 解析 XML → MsgType / Content / FromUserName

4. 存入 DB + 通知员工

开发优先级

  1. OAuth 绑定(必须先做,其他功能依赖绑定关系)
  2. 外部联系人列表(验证绑定是否正确)
  3. 发送消息(核心功能)
  4. Webhook 接收(完成双向通信闭环)

参考文档

支付集成规格

仓库:align-plugin-payment(私有)— 微信支付 · 支付宝 · 回调幂等 · 退款

功能范围

功能路由状态
创建支付订单POST /orders/createTODO
查询订单状态GET /orders/:orderIdTODO
微信支付回调POST /notify/wxpayTODO
支付宝回调POST /notify/alipayTODO
退款(admin only)POST /orders/:orderId/refundTODO
🚨 金额规则
所有金额必须用整数分(fen),禁止浮点。99.00元 → 9900,不允许 99.0

环境变量

微信支付(APIv3)

变量名说明
WXPAY_MCH_ID商户号
WXPAY_APP_IDAppID
WXPAY_API_KEY_V3APIv3 密钥(32位)
WXPAY_CERT_SERIAL证书序列号
WXPAY_PRIVATE_KEY商户私钥(PEM 文本)

支付宝

变量名说明
ALIPAY_APP_ID应用 AppID
ALIPAY_PRIVATE_KEY应用私钥(RSA2 PKCS8)
ALIPAY_PUBLIC_KEY支付宝公钥

创建订单

请求/响应
POST /api/plugins/payment/orders/create
{
  "subject": "购买高级会员",
  "amount": 9900,        // 分(9900 = ¥99.00)
  "channel": "wxpay",    // 或 "alipay"
  "notifyUrl": "https://align.xin/api/plugins/payment/notify/wxpay"
}

→ {
  "orderId": "PAY_20240101_abc123",
  "payParams": { /* 前端唤起支付参数 */ }
}

回调幂等处理

幂等是强制要求
同一订单可能收到多次回调通知,处理前必须检查已处理状态,已处理则直接返回成功。
typescript
async function handleWxpayNotify(ctx: PluginContext) {
  // 1. 解密验签拿 orderId
  const { orderId, trade_state } = decryptNotify(ctx.req.body, ctx.req.headers);

  // 2. 幂等检查
  const order = await ctx.db.collection('orders').findOne({ orderId });
  if (order?.status === 'paid') {
    ctx.res.json({ code: 'SUCCESS', message: '幂等' });  // 直接返回
    return;
  }

  // 3. 更新状态
  await ctx.db.collection('orders').updateOne(
    { orderId },
    { status: 'paid', paidAt: new Date().toISOString() }
  );

  // 4. 通知用户
  await ctx.align.sendIM(order.userId, `支付成功:${order.subject}`);

  ctx.res.json({ code: 'SUCCESS' });
}

退款权限

🔒 退款仅限管理员
骨架代码已内置权限校验,不能删除。普通用户调用退款接口会收到 403。
typescript
if (!ctx.iam || !['admin', 'super_admin'].includes(ctx.iam.role)) {
  ctx.res.status(403).json({ error: '无权限' });
  return;
}

开发优先级

  1. 微信支付建单 + 回调(先跑通一个渠道全链路)
  2. 支付宝建单 + 回调
  3. 退款接口
  4. 查询接口(最简单,直接查 DB)

参考文档