能否在 user_badge webhook 的负载中包含用户名?

有没有可能让 user_badge Webhook 在返回 user_id 的同时也包含 username?目前 user_badge 事件的负载(payload)看起来是这样的:

{
  "user_badge": {
    "id": 123456,
    "granted_at": "2026-09-25T14:54:42.073Z",
    "created_at": "2026-09-26T05:34:12.965Z",
    "post_id": 234567,
    "post_number": 1,
    "badge_id": 123,
    "user_id": 456,
    "granted_by_id": -1,
    "topic_id": 56789
  }
}

要获取 user_id(以及如果不是 -1 的 granted_by_id)对应的用户名,需要额外调用 /admin/users/{user_id}.json。据我了解,这不仅需要额外的请求,还需要管理员权限。

有没有办法在使用 API 密钥的情况下,无需管理员权限即可根据给定的 user_id 获取 username?

我正在开发一个外部服务,希望能在特定徽章被授予时触发该服务;然而,该服务需要用户名,而徽章 Webhook 只返回用户 ID。:confused:

仅供参考,对于非标准 Webhook 或需要额外数据的情况,我的变通方法是先通过 Workflows 进行处理,然后再将结果发送到 n8n。

顺便提一下,从用户 ID 获取用户名只需要一次 API 调用。除非你瞬间手动授予成千上万个徽章,否则默认的 API 限制应该完全够用。

谢谢!我会研究一下 Workflows。

听到这个消息太好了!你能否提供一下那个 API 调用的详细信息?它显然不是 /admin/users/{user_id}.json(该接口需要管理员权限)。我没能找到一个可以使用 API 密钥且不需要管理员权限来完成此操作的端点。

管理员 API 密钥有什么问题?这应该是个后台任务吧?

最小权限原则。这个外部服务将运行在另一台服务器上,只需要对 Discourse 的只读访问权限即可完成其工作。在基础设施中流传的主密钥越少越好。

这就引出了一个有趣的问题:如果未选择 login_required,为什么没有提供一个面向公众的端点,通过用户 ID 获取仅显示公开字段的用户信息呢?这难道不是很有用吗?我很好奇为什么没有这样做。或者也许只有登录用户才能享有这种权限,但无论如何,应该有一种方式可以访问它,而不必使用管理端点……

@burke 一个更繁琐的变通方法是查看 /groups/trust_level_0/members.json(或任何更具体的组),并通过其 ID 找到该用户,因为其中也包含用户名。

这是因为在 Discourse 中,用户 ID 本质上是一种内部约定,适用于所有场景和目的。我们被期望依赖用户名,因为用户名在各个方面都更为突出。

这就回到了最初的需求。Webhook 应遵循该概念,发送用户名,而不是用户 ID。

同意!这似乎是最好的解决方案。添加 username 和 granted_by_username,并弃用 user_id 和 granted_by_id,可以实现一个非破坏性的过渡期。

目前,如果徽章由系统更改(即自动更改),Webhook 会返回 granted_by_id 为 -1。那么 granted_by_username 的等效值是什么?

  • ""(空字符串)
  • "<system>"(一个不可能作为用户名的魔法值)
  • null
  • 无——即,如果是系统驱动的事件,则从有效负载中省略 granted_by_username

我认为 null 或“无”是最合理的。

实际上,-1 是一个有效的用户 ID,它关联着一个用户名(通常是 system),就像其他任何用户一样。没有理由将其区别对待。

参见 meta 站点:https://meta.discourse.org/u/system/summary

哦,谢谢你的科普。那么,对于自动化的更改,granted_by_username 应该就只是 "system" 了。

@putty 我一直在研究 Workflows。在徽章事件包含用户名之前(或者即使不包含,因为我可以利用 Workflows 中的过滤器,只向我服务需要感知的更改发送 POST 请求),这看起来是一个极好的替代方案!再次感谢!

在使用 Webhook 时,有一个 X-Discourse-Event-Signature 请求头,它使用密钥对有效负载进行 HMAC-SHA256 签名,从而允许我验证发送者。请问有没有办法对来自 Workflows 的 HTTP 请求(POST)进行签名?也许只是我还没找到。我曾希望有一个类似“使用密钥签名请求”的设置,可以开启并提供密钥。我目前看到的最接近的方法是添加一个 header_auth 凭据,以便在请求头中发送密钥(例如 X-Discourse-Event-Secret: Discourse Rocks!)。这总比什么都没有好,但使用密钥进行签名(而不是直接发送密钥本身)会更理想。

不幸的是,我也依赖通过请求头来传递密钥。也许 @j.jaffeux 能建议一个更好的替代方案,我会立即采用 :sweat_smile:

我创建了一个代码步骤来计算签名。Web Crypto API 可以更轻松地计算 HMAC-SHA256,但它使用 Promise,而这在 eval 中无法正常工作。因此,我让 AI 编写了一个原生的 JavaScript 方法来计算 HMAC-SHA256,添加了一个键为 secret、值为该密钥的变量,最终得到了这样一个代码脚本:

// 可用变量:
//   $input.item.json       - 当前项目的 JSON 数据
//   $input.all()           - 所有输入项目的数组
//   $json                  - $input.item.json 的快捷方式
//   $("NodeName").item     - 访问来自其他节点的配对项目
//   $vars.KEY              - 工作流变量
//   $site_settings.NAME    - 站点设置
//   $execution             - 执行元数据(id、workflow_name 等)
//   $current_user          - 运行工作流的用户
//   $helpers.absoluteUrl(path) - 将相对路径转换为绝对 URL
//   console.log/warn/error - 日志记录

function hmacSha256(message, key) {
  function utf8Bytes(str) {
    const bytes = [];

    for (let i = 0; i < str.length; i++) {
      let code = str.charCodeAt(i);

      // 处理代理对
      if (
        code >= 0xd800 &&
        code <= 0xdbff &&
        i + 1 < str.length
      ) {
        const next = str.charCodeAt(i + 1);

        if (next >= 0xdc00 && next <= 0xdfff) {
          code =
            0x10000 +
            ((code - 0xd800) << 10) +
            (next - 0xdc00);
          i++;
        }
      }

      if (code <= 0x7f) {
        bytes.push(code);
      } else if (code <= 0x7ff) {
        bytes.push(
          0xc0 | (code >> 6),
          0x80 | (code & 0x3f)
        );
      } else if (code <= 0xffff) {
        bytes.push(
          0xe0 | (code >> 12),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      } else {
        bytes.push(
          0xf0 | (code >> 18),
          0x80 | ((code >> 12) & 0x3f),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      }
    }

    return bytes;
  }

  function rotr(x, n) {
    return (x >>> n) | (x << (32 - n));
  }

  function sha256(bytes) {
    const K = [
      0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
      0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
      0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
      0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
      0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
      0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
      0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
      0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
      0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
      0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
      0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
      0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
      0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
      0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
      0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
      0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
    ];

    const H = [
      0x6a09e667,
      0xbb67ae85,
      0x3c6ef372,
      0xa54ff53a,
      0x510e527f,
      0x9b05688c,
      0x1f83d9ab,
      0x5be0cd19
    ];

    const data = bytes.slice();
    const bitLength = data.length * 8;

    // SHA-256 填充
    data.push(0x80);

    while ((data.length % 64) !== 56) {
      data.push(0);
    }

    // 追加 64 位大端消息长度。
    // 对于常规工作流负载大小,JS 整数在此处是安全的。
    const high = Math.floor(bitLength / 0x100000000);
    const low = bitLength >>> 0;

    data.push(
      (high >>> 24) & 0xff,
      (high >>> 16) & 0xff,
      (high >>> 8) & 0xff,
      high & 0xff,
      (low >>> 24) & 0xff,
      (low >>> 16) & 0xff,
      (low >>> 8) & 0xff,
      low & 0xff
    );

    const w = new Array(64);

    for (let offset = 0; offset < data.length; offset += 64) {
      for (let i = 0; i < 16; i++) {
        const j = offset + i * 4;

        w[i] =
          ((data[j] << 24) |
            (data[j + 1] << 16) |
            (data[j + 2] << 8) |
            data[j + 3]) >>> 0;
      }

      for (let i = 16; i < 64; i++) {
        const s0 =
          rotr(w[i - 15], 7) ^
          rotr(w[i - 15], 18) ^
          (w[i - 15] >>> 3);

        const s1 =
          rotr(w[i - 2], 17) ^
          rotr(w[i - 2], 19) ^
          (w[i - 2] >>> 10);

        w[i] =
          (
            w[i - 16] +
            s0 +
            w[i - 7] +
            s1
          ) >>> 0;
      }

      let a = H[0];
      let b = H[1];
      let c = H[2];
      let d = H[3];
      let e = H[4];
      let f = H[5];
      let g = H[6];
      let h = H[7];

      for (let i = 0; i < 64; i++) {
        const S1 =
          rotr(e, 6) ^
          rotr(e, 11) ^
          rotr(e, 25);

        const ch = (e & f) ^ (~e & g);

        const temp1 =
          (
            h +
            S1 +
            ch +
            K[i] +
            w[i]
          ) >>> 0;

        const S0 =
          rotr(a, 2) ^
          rotr(a, 13) ^
          rotr(a, 22);

        const maj =
          (a & b) ^
          (a & c) ^
          (b & c);

        const temp2 = (S0 + maj) >>> 0;

        h = g;
        g = f;
        f = e;
        e = (d + temp1) >>> 0;
        d = c;
        c = b;
        b = a;
        a = (temp1 + temp2) >>> 0;
      }

      H[0] = (H[0] + a) >>> 0;
      H[1] = (H[1] + b) >>> 0;
      H[2] = (H[2] + c) >>> 0;
      H[3] = (H[3] + d) >>> 0;
      H[4] = (H[4] + e) >>> 0;
      H[5] = (H[5] + f) >>> 0;
      H[6] = (H[6] + g) >>> 0;
      H[7] = (H[7] + h) >>> 0;
    }

    const result = [];

    for (let i = 0; i < H.length; i++) {
      result.push(
        (H[i] >>> 24) & 0xff,
        (H[i] >>> 16) & 0xff,
        (H[i] >>> 8) & 0xff,
        H[i] & 0xff
      );
    }

    return result;
  }

  function bytesToHex(bytes) {
    return bytes
      .map(b => b.toString(16).padStart(2, "0"))
      .join("");
  }

  const blockSize = 64;

  let keyBytes = utf8Bytes(key);
  const messageBytes = utf8Bytes(message);

  // 长度超过 SHA-256 块大小的密钥会先进行哈希处理。
  if (keyBytes.length > blockSize) {
    keyBytes = sha256(keyBytes);
  }

  // 将密钥填充至 64 字节。
  while (keyBytes.length < blockSize) {
    keyBytes.push(0);
  }

  const innerPad = new Array(blockSize);
  const outerPad = new Array(blockSize);

  for (let i = 0; i < blockSize; i++) {
    innerPad[i] = keyBytes[i] ^ 0x36;
    outerPad[i] = keyBytes[i] ^ 0x5c;
  }

  const innerHash = sha256(
    innerPad.concat(messageBytes)
  );

  const hmac = sha256(
    outerPad.concat(innerHash)
  );

  return bytesToHex(hmac);
}

const payload = JSON.stringify({
    "the-json-payload": "goes-here"
});

const signature = "sha256=" + hmacSha256( payload, $vars.secret);

return {
    "payload": payload,
    "signature": signature
};

接下来是一个 HTTP 请求步骤,其中包含一个键为 X-Discourse-Workflow-Signature、值为 {{ $("Code").item.json.signature }} 的头部,内容类型为 JSON,请求体为 {{ $("Code").item.json.payload }}。

结果是一个如下的 HTTP 请求:

User-Agent: Faraday v2.14.4
Content-Length: 41
Accept: */*
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Content-Type: application/json
X-Discourse-Workflow-Signature: sha256=9ad2820b545013bdd18d4e7e6139624b91f576498e528201fa7759ec58dd598c

{"the-json-payload": "goes-here"}

X-Discourse-Workflow-Secret 是使用 secret 参数值生成的 HMAC-SHA256 签名……就像签名 Webhook 一样!:slight_smile:

这可能看起来不太美观,但它似乎正在正常工作。

更新:我在签名前添加了 sha256= 前缀,以匹配 Webhook 签名的风格。

看起来挺靠谱的!感谢分享!

仅供参考,我用随机消息和密钥测试了这个原生 HMAC-SHA256 与 Web Crypto API,确认它们的结果一致,签名也匹配。我无法保证不存在某些特殊情况下两者会出现差异,但我觉得已经足够可靠,可以在生产环境中使用了。:slightly_smiling_face: