通过云函数和Webhook自动为文章生成Sink短链
想法
有时候觉得分享文章的时候默认的连接比较长
正好手上有短域名部署了Sink短链服务
想到可以通过云函数功能配合Webhook事件来请求Sink的API生成短链,并用于文章分享
Mix-space 后端部分
1.创建 Function
在 Mix-space后台-附加功能-配置与云函数新增一个函数
- 名称:
sink - 引用:
shiro - 类型:
Function - Secret 环境变量:
| 名称 | 示例值 | 说明 |
|---|---|---|
| blog_url | https://exmaple.com | 博客地址,结尾不加/ |
| sink_hostname | sink.cool | Sink实例地址,不加https://前缀,结尾不加/ |
| sink_token | 123456 | Sink的NUXT_SITE_TOKEN,用于请求API时鉴权 |
| webhook_secret | 123456 | 自己填入一个密匙,用于Webhook请求鉴权,在后面添加Webhook时会用上 |
在编辑器中填入以下内容:
export default async function handler(ctx) {
const { req, secret, broadcast } = ctx;
const method = req.method;
if (!secret.sink_hostname || !secret.sink_token || !secret.blog_url) {
return { error: "Missing configuration" };
}
if (method === 'GET') {
const { id, type } = req.query;
if (!id || !type) {
return "ID and type are required";
}
const shortId = parseInt(id.slice(-8), 16).toString(36).slice(-4);
const prefix = type.toLowerCase();
if (prefix !== 'p' && prefix !== 'n') return "Invalid type";
const slug = `${prefix}${shortId}`;
return `${secret.sink_hostname}/${slug}`;
}
if (!secret.webhook_secret || req.query.secret !== secret.webhook_secret) {
return { error: "Unauthorized webhook request" };
}
const data = req.body;
const event = req.headers['x-webhook-event'];
if (!event || !data) return { message: "Empty payload" };
const docId = data._id || data.id;
if (!docId) return { error: "ID not found" };
const shortId = parseInt(docId.slice(-8), 16).toString(36).slice(-4);
const getSlug = (ev) => {
if (ev.startsWith('POST_')) return `p${shortId}`;
if (ev.startsWith('NOTE_')) return `n${shortId}`;
return null;
};
const targetSlug = getSlug(event);
if (!targetSlug) return { message: "Ignored event" };
let apiUrl = "";
let requestBody = {};
let actionName = "";
if (event === 'POST_CREATE' || event === 'NOTE_CREATE') {
actionName = "生成";
apiUrl = `https://${secret.sink_hostname}/api/link/create`;
let targetUrl = "";
if (event === 'POST_CREATE') {
const categorySlug = data.category?.slug || 'default';
targetUrl = `${secret.blog_url}/posts/${categorySlug}/${data.slug}`;
} else {
targetUrl = `${secret.blog_url}/notes/${data.nid}`;
}
requestBody = {
url: targetUrl,
slug: targetSlug,
comment: `MixSpace - ${data.title || 'Untitled'}`
};
} else if (event === 'POST_DELETE' || event === 'NOTE_DELETE') {
actionName = "删除";
apiUrl = `https://${secret.sink_hostname}/api/link/delete`;
requestBody = { slug: targetSlug };
} else {
return { message: "Unsupported action" };
}
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${secret.sink_token}`
},
body: JSON.stringify(requestBody)
});
const responseText = await response.text();
let result = {};
if (responseText) {
try {
result = JSON.parse(responseText);
} catch (e) {
result = { raw: responseText };
}
}
return { success: response.ok, slug: targetSlug, result };
} catch (err) {
return { error: "Request failed", message: err.message };
}
}
设计逻辑
最开始计划的逻辑是slug留空给sink生成,这样能保证短链尽可能短,并且不会重复。
但是根据 Sink API 文档 可知,在请求删除一个短链时请求体参数需要slug,虽然可以先 LIST 找到 slug 再 DELETE,但是在短链变多后会变得很麻烦,LIST 只能分页查询。
因此需要能根据文章的唯一 _id 反推 slug。
_id实际上是 MongoDB 的 ObjectId。它是一个 24 位的十六进制字符串。前 8 位:是时间戳(精确到秒)。由文章创建时间决定。中间 10 位:是机器标识码和进程 ID(通常是随机生成的)。最后 6 位:是一个自增计数器(每次插入新数据时加 1)。
因此,短链生成的逻辑是:
1. 在计算出的4位前面加上 p 或 n 用于标识 post或note ,最终得到的slug形如 pabva 和 n1afc
为什么不直接取id的后四位?
id是一串16进制字符,那么它的命名空间只有0-9,a-f,这导致命名空间大幅减少,使得两篇文章slug重复的可能性增大,并且也不够美观。
当前方案slug重复的可能性有多大?
理论上存在,但对于个人博客来说,实际重复风险极低,基本可以忽略不计。
数学原理: Base36 包含 36 个字符(0-9,a-z)。截取最后 4 位,意味着共有 364 = 1,679,616(约 167 万)种唯一的组合。
底层机制:MongoDB ID 的尾部是一个严格自增的计数器。当你截取转换后的后 4 位时,实际上是在做类似取模的循环。这意味着,除非博客文章和日记总数达到 167 万篇,否则在单台服务器上几乎不可能发生“转了一圈又撞上”的情况。
2.添加Webhook
在 附加功能-Webhooks-添加Webhook
- Payload URL:
https://example.com/api/v2/fn/shiro/sink?secret=<YOUR_WEBHOOK_TOKEN> - Secret 留空
(因为我似乎发现填写了此值也会显示"未配置Secret",故弃用,直接填在URL后面) - 触发事件:
POST_CREEATPOST_DELETENOTE_CREATNOTE_DELETE - 触发范围:系统事件
- 启用状态:启用
点击 发送测试 会提示失败,因为测试的payload与实际payload不同,实际使用时不会报错。
新建一篇文章测试,点击右上角发布,查看webhook日志,在Response中有以下字段,代表创建短链成功:
"shortLink": "https://rua.ee/pkrw6"
3.前端获取短链
云函数中定义了一个API,在浏览器中输入地址可以获取已知id文章或手记的短链
GET https://example.com/api/v2/fn/shiro/sink?id=<id>&type=p
id为文章或手记的唯一idtype为类型,p代表posts,n代表notes
前端部分
由于更改的文件过多,本想通过提供github commit 链接做参考
但是我的仓库为了尊重原作者的工作也是 private,故给出以下详细提示词,可以使用 AI Agent 辅助完成修改
这个份提示词适用于单域名部署的Yohaku站点,如果你采用的是双域名的部署方式,可能需要修改 第 5 行 的描述
这是Codex GPT 5.5-Medium 给出的plan,执行修改后一次成功,您可以复制给AI Agent尝试
# 通过短链分享文章/手记
## Summary
- 将 posts 和 notes 的桌面侧栏、移动底栏分享入口统一改为“通过短链分享”。
- 点击后请求 `/api/v2/fn/shiro/sink?id=<id>&type=<type>`,复制返回的短链到剪贴板,并用项目 toast 显示“已复制短链。”
- 移除原来的系统分享 API、分享弹窗降级逻辑和不再使用的 `ShareModal`。
## Key Changes
- 在 `PostActionAside.tsx` 中替换 `ShareMark` 和 `ShareButton`:
- 从当前 post 数据读取 `id`。
- 请求 `type=p`。
- 删除 `navigator.share`、`useModalStack`、`ShareModal`、`routeBuilder`、`Routes`、`urlBuilder` 相关依赖。
- 在 `NoteActionAside.tsx` 中替换 `ShareTab` 和 `WashiShareButton`:
- 从当前 note 数据读取 `id`。
- 请求 `type=n`。
- 删除 `navigator.share`、`useModalStack`、`ShareModal`、`buildNotePath`、`urlBuilder` 相关分享依赖。
- 新增一个小型共享 helper,供 post/note 复用:
- 调用 `$fetch('/api/v2/fn/shiro/sink', { query: { id, type } })`。
- 接受纯字符串响应;也兼容常见 JSON 响应字段如 `url`、`link`、`shortUrl`、`shortLink`、`data`。
- 成功后 `navigator.clipboard.writeText(shortLink)`,再 `toast.success(t('copy_short_link_success'))`。
- 获取或复制失败时 `toast.error(t('copy_short_link_failed'))`。
- 删除 `apps/web/src/components/modules/shared/ShareModal.tsx`,前提是替换后全局无引用。
## i18n
- 更新所有 locale 的 `common.json`:
- `aria_share_post`: 改为“通过短链分享”语义。
- `aria_share_note`: 改为“通过短链分享”语义。
- 新增并使用 `copy_short_link_success`,中文值为 `已复制短链。`
- 新增并使用 `copy_short_link_failed`,用于接口或剪贴板失败提示。
- 覆盖 `zh`、`zh-TW`、`en`、`ja`、`ko`,保持 message key 同步。
## Test Plan
- 运行 `pnpm --dir apps/web vitest run src/messages/message-usage.test.ts`,确认新增 i18n key 被引用且没有未使用 key。
- 运行 `pnpm --dir apps/web lint` 或至少对相关文件执行类型/ lint 检查。
- 手动验证:
- post 页面桌面侧栏点击分享,请求带 `type=p` 和 post `id`,剪贴板得到短链,toast 显示成功文案。
- post 页面移动底栏同样生效。
- note 页面桌面侧栏/嵌入侧栏点击分享,请求带 `type=n` 和 note `id`。
- note 页面移动底栏同样生效。
- API 失败或剪贴板失败时显示失败 toast,不再弹出系统分享面板或 ShareModal。
## Assumptions
- 短链接口是同源相对路径,可从浏览器直接请求。
- 接口成功响应中至少会返回短链字符串,或返回包含短链字段的 JSON;实现会兼容这两类形态。
- “posts 和 notes 侧栏”包含当前文件里的桌面侧栏入口,也包含对应移动底栏分享按钮,因为原分享逻辑在两端各有一份。