fix(友链): 修复申请「通过不了/删不掉」;feat(评论): 通知按文章作者分派
## 友链自助申请(通过不了 / 删除不掉)
根因:admin.js 全局事件委托把 data-id 一律 parseInt,
而友链申请 id 是字符串(lamus5uptzcdam)→ NaN → JSON 成 {"id":null}
→ 后端 String(null||'') 为空 → 400 id and action required,查看/通过/拒绝全失效。
引入于 a74b3c71(10-04 归档 rss-robot 时;后台只有友链申请用字符串 id)。
- admin.js:纯数字才转数字,其余原样(评论/用户/站点按钮请求完全不变)
- admin.js:待审卡片加「删除」按钮
- links.ts:新增 action:'delete'(不受「已处理过」限制,处理过的也能删)
- links.ts:approve 改为先写友链再改状态 + URL 去重(重试幂等)
- links.ts:已是目标状态时返回 {ok:true,already:true},不再 409
—— 否则网络抖动后重试会让用户以为「怎么点都通不过」
## 评论通知按文章作者分派
编辑角色(女朋友)写的文章,评论通知改发给她,不再打扰博主。
不需要「文章→作者」同步表:Hugo 模板把 author_id 注入 artalkConfig,
artalk.js 已有的 fetch 拦截器把它塞进评论提交体,Worker 直接读。
- artalk.html:artalkConfig 增 pageAuthorId(老文章 0)
- artalk.js:提交评论时带上 author_id(读 window.artalkConfig,防 PJAX 过期)
- comments.ts:createComment 解析 author_id 并传给通知
- mail.ts:收件人改为「这篇文章的主人」——作者非管理员且开了邮件则发给他,
否则回退博主;已被回复通知过 / 作者本人评论自己的文章都不重复发
- mail.ts:增一行收件人决策日志,便于线上排查
验证:Worker tsc 通过;Hugo 构建通过;artalk.js 语法通过;
友链本地跑通(通过 200 → 再通过 already:true → 友链仅 1 条 → 删除 200 → 再删 404)。
This commit is contained in:
1 parent
68416366eb
commit
1b2e3d46f8
6 files changed
+124
-25
No files matched your search
@@ -1123,7 +1123,8 @@ async function viewLinks() {
|
||||
'<span class="c">' +
|
||||
'<button class="btn tiny" data-act="l-ap-view" data-id="' + esc(a.id) + '">查看</button>' +
|
||||
'<button class="btn tiny" data-act="l-ap-approve" data-id="' + esc(a.id) + '">通过</button>' +
|
||||
'<button class="btn tiny danger" data-act="l-ap-reject" data-id="' + esc(a.id) + '">拒绝</button>' +
|
||||
'<button class="btn tiny" data-act="l-ap-reject" data-id="' + esc(a.id) + '">拒绝</button>' +
|
||||
'<button class="btn tiny danger" data-act="l-ap-del" data-id="' + esc(a.id) + '">删除</button>' +
|
||||
'</span>' +
|
||||
'</div>').join('');
|
||||
appHtml =
|
||||
@@ -1906,7 +1907,12 @@ document.addEventListener('click', async (e) => {
|
||||
const el = e.target.closest('[data-act]');
|
||||
if (!el) return;
|
||||
const act = el.dataset.act;
|
||||
const id = el.dataset.id ? parseInt(el.dataset.id, 10) : 0;
|
||||
// ★ 不能无脑 parseInt:友链申请的 id 是字符串(la + base36,如 lamus5uptzcdam),
|
||||
// parseInt 会得到 NaN,序列化成 JSON 变成 null —— 后端只看到空 id,一律 400
|
||||
// 「id and action required」,表现就是「查看/通过/拒绝点了没反应」。
|
||||
// 规矩:纯数字才转数字,其余原样保留。
|
||||
const rawId = el.dataset.id || '';
|
||||
const id = /^\d+$/.test(rawId) ? parseInt(rawId, 10) : rawId;
|
||||
|
||||
// --- 通用
|
||||
if (act === 'modal-close') { closeModal(); return; }
|
||||
@@ -2071,6 +2077,14 @@ document.addEventListener('click', async (e) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (act === 'l-ap-del') {
|
||||
const a = (state.linkApps || []).find((x) => x.id === id);
|
||||
confirmBox('删除申请记录', '确定删除「' + (a ? a.name : '') + '」这条申请吗?只清掉申请记录,已有的友链不受影响。', async () => {
|
||||
await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'delete' } });
|
||||
toast('✓ 已删除申请记录'); await viewLinks();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// --- 友链
|
||||
if (act === 'l-reload') { el.dataset.busy = '1'; try { await viewLinks(); } finally { delete el.dataset.busy; } return; }
|
||||
if (act === 'l-add') { addLink(); return; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Env, CommentRow, UserRow } from '../types';
|
||||
import { findUserById } from './db';
|
||||
import { formatDateCN } from './util';
|
||||
import { md5Lower } from './md5';
|
||||
import { renderMarkdown } from './md';
|
||||
@@ -404,6 +405,11 @@ export interface NotifyCtx {
|
||||
siteUrl: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
/**
|
||||
* 当前页文章作者的 user id(拿不到则是 0/undefined)。
|
||||
* 文章作者不是博主时(例如「编辑」角色写的文章),新评论通知改发给他。
|
||||
*/
|
||||
pageAuthorId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,12 +453,26 @@ export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 管理员(用 MAIL_ADMIN,不复用 ADMIN_EMAIL)
|
||||
// --- 这篇文章的主人(默认博主;文章作者另有其人时发给他)
|
||||
// 背景:编辑角色(女朋友)也有发布权限,她文章下的评论不该再来打扰博主。
|
||||
// pageAuthorId 由前端提交评论时带上(Hugo 模板 → artalkConfig → fetch 拦截器),
|
||||
// 这样不需要任何「文章 → 作者」的同步表。
|
||||
// 只在作者确实是「另一位写作者」时才改派:管理员 / 没开邮件 / 邮箱非法一律回退给博主,
|
||||
// 免得把通知发丢了。
|
||||
const adminTo = (env.MAIL_ADMIN || '').trim();
|
||||
let ownerTo = adminTo;
|
||||
let ownerNick = '博主';
|
||||
if (c.pageAuthorId) {
|
||||
const u = await findUserById(env, c.pageAuthorId);
|
||||
if (u && !u.is_admin && u.receive_email && u.email && u.email.includes('@')) {
|
||||
ownerTo = u.email;
|
||||
ownerNick = u.name || '作者';
|
||||
}
|
||||
}
|
||||
if (
|
||||
adminTo.includes('@') &&
|
||||
adminTo.toLowerCase() !== c.author.email.toLowerCase() &&
|
||||
(!c.parentAuthor || c.parentAuthor.email.toLowerCase() !== adminTo.toLowerCase())
|
||||
ownerTo.includes('@') &&
|
||||
ownerTo.toLowerCase() !== c.author.email.toLowerCase() &&
|
||||
(!c.parentAuthor || c.parentAuthor.email.toLowerCase() !== ownerTo.toLowerCase())
|
||||
) {
|
||||
const input: AdminMailInput = {
|
||||
siteName: c.siteName,
|
||||
@@ -462,10 +482,10 @@ export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
|
||||
comment: c.newComment,
|
||||
commenterNick: c.author.name,
|
||||
commenterEmail: c.author.email,
|
||||
adminNick: '博主',
|
||||
adminNick: ownerNick,
|
||||
};
|
||||
targets.push({
|
||||
to: adminTo,
|
||||
to: ownerTo,
|
||||
subject: adminMailSubject(input),
|
||||
html: adminMailHtml(input),
|
||||
fromName,
|
||||
@@ -474,6 +494,14 @@ export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
|
||||
|
||||
if (!targets.length) return;
|
||||
|
||||
// 排查用:一条日志看清「这封通知发给了谁、为什么」——文章作者另有其人时
|
||||
// 收件人会是作者本人而不是博主,出问题时先看这里。
|
||||
console.log(
|
||||
'[mail] 收件人 =', targets.map((t) => t.to).join(', '),
|
||||
'| 文章作者 id =', c.pageAuthorId || 0,
|
||||
'| 本地通知收信人 =', ownerTo || '(空)',
|
||||
);
|
||||
|
||||
for (const t of targets) {
|
||||
if (!(await mailQuotaOk(env))) {
|
||||
console.warn('[mail] 已达当日发信上限,跳过');
|
||||
|
||||
@@ -375,6 +375,10 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const rid = Number(body.rid || 0);
|
||||
const pageKey = trimTo(body.page_key || '', 255).trim();
|
||||
const pageTitle = trimTo(body.page_title || '', 255);
|
||||
// 当页文章的作者(用户 id)。由前端从 Hugo 模板注入的 artalkConfig 带上来,
|
||||
// 仅用于「这封通知邮件该发给谁」——文章作者另有其人时发给他,不再打扰博主。
|
||||
// 拿不到就是 0,通知逻辑自动回退到原行为(发博主)。
|
||||
const pageAuthorId = Number(body.author_id || 0) || 0;
|
||||
const siteName = trimTo(body.site_name || '', 120) || (await defaultSiteName(env));
|
||||
|
||||
if (!name || !email || !content || !pageKey) {
|
||||
@@ -526,6 +530,7 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
// 《"留言"》)。存量仅在本次没带标题时兜底。
|
||||
pageTitle: pageTitle || page.title,
|
||||
pageUrl: pageAccessibleUrl(pageKey, siteUrl),
|
||||
pageAuthorId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[mail] 通知流程失败:', e instanceof Error ? e.message : e);
|
||||
|
||||
@@ -210,38 +210,70 @@ export async function linkApplyReview(request: Request, env: Env): Promise<Respo
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const id = String(body.id || '');
|
||||
const action = body.action === 'approve' ? 'approve' : body.action === 'reject' ? 'reject' : '';
|
||||
const action =
|
||||
body.action === 'approve' ? 'approve'
|
||||
: body.action === 'reject' ? 'reject'
|
||||
: body.action === 'delete' ? 'delete'
|
||||
: '';
|
||||
if (!id || !action) return respond({ error: 'id and action required' }, 400);
|
||||
|
||||
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
|
||||
const app = st.apps.find((a) => a.id === id);
|
||||
if (!app) return respond({ error: '申请不存在' }, 404);
|
||||
if (app.status !== 'pending') return respond({ error: '该申请已处理过' }, 409);
|
||||
|
||||
app.status = action === 'approve' ? 'approved' : 'rejected';
|
||||
// 删除:清理记录用。**不受「已处理过」限制** —— 否则处理过的申请就永远删不掉了。
|
||||
if (action === 'delete') {
|
||||
st.apps = st.apps.filter((a) => a.id !== id);
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
return respond({ ok: true, deleted: id });
|
||||
}
|
||||
|
||||
if (app.status !== 'pending') {
|
||||
// 幂等:状态已经是目标状态就直接算成功。
|
||||
// 跨境链路会偶发握手失败(后台前端此时会弹错),用户自然会再点一次 ——
|
||||
// 若这里回 409「该申请已处理过」,他会以为「怎么点都通不过」,
|
||||
// 而实际上第一次早就通过了。重复的「通过」不会再发一遍邮件。
|
||||
if (
|
||||
(action === 'approve' && app.status === 'approved') ||
|
||||
(action === 'reject' && app.status === 'rejected')
|
||||
) {
|
||||
return respond({ ok: true, already: true });
|
||||
}
|
||||
return respond({ error: '该申请已处理过' }, 409);
|
||||
}
|
||||
|
||||
app.feedback = String(body.feedback || '').slice(0, 300);
|
||||
app.reviewedAt = new Date().toISOString();
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
|
||||
let mailOk = false;
|
||||
if (action === 'approve') {
|
||||
// 写入友链
|
||||
// ★ 顺序要紧:**先写友链、再改申请状态**。
|
||||
// 反过来的话(旧实现就是),一旦写 friend_links 失败,申请已经变成 approved,
|
||||
// 用户再点只会得到「该申请已处理过」—— 友链永远加不上、也重试不了。
|
||||
// 顺带做去重:该 URL 已在友链里就别再 push 一条重复的(重试因此是幂等的)。
|
||||
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
links.links.push({
|
||||
name: app.name,
|
||||
url: app.url,
|
||||
// 申请人没填图标 → 用站内 favicon 服务自动抓(页面/友圈直接用这个地址)
|
||||
image: app.image || `/api/favicon?url=${encodeURIComponent(app.url)}`,
|
||||
description: app.description || '',
|
||||
rss: app.feed,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
await kvPutJson(env, 'friend_links', links);
|
||||
const dup = links.links.some((l) => normUrl(l.url) === normUrl(app.url));
|
||||
if (!dup) {
|
||||
links.links.push({
|
||||
name: app.name,
|
||||
url: app.url,
|
||||
// 申请人没填图标 → 用站内 favicon 服务自动抓(页面/友圈直接用这个地址)
|
||||
image: app.image || `/api/favicon?url=${encodeURIComponent(app.url)}`,
|
||||
description: app.description || '',
|
||||
rss: app.feed,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
await kvPutJson(env, 'friend_links', links);
|
||||
}
|
||||
app.status = 'approved';
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
mailOk = await sendMailSafe(env, app.email,
|
||||
`✅ 你的友链申请已通过:${app.name}`,
|
||||
`<p>你的友链申请(<a href="${app.url}">${app.url}</a>)已审核通过,现已加进友链列表,感谢支持!</p>` +
|
||||
(app.feedback ? `<p>站长留言:${app.feedback}</p>` : ''));
|
||||
} else {
|
||||
app.status = 'rejected';
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
mailOk = await sendMailSafe(env, app.email,
|
||||
`关于你的友链申请:${app.name}`,
|
||||
`<p>很抱歉,你的友链申请(<a href="${app.url}">${app.url}</a>)本次未能通过。</p>` +
|
||||
|
||||
@@ -1728,7 +1728,23 @@ artalk.on('list-loaded', function() {
|
||||
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
||||
if (method !== 'POST' || !POST_RE.test(url)) return origFetch.apply(this, arguments);
|
||||
|
||||
var p = origFetch.apply(this, arguments);
|
||||
// ★ 把「当前页文章的作者 id」塞进评论提交体 —— 服务端凭它决定这封
|
||||
// 「有新评论」的通知邮件发给文章作者本人,还是发博主。
|
||||
// 读 window.artalkConfig 而不是闭包里的 config:PJAX 切页时 artalkConfig
|
||||
// 会被重新赋值,闭包里那份会过期(把上一页的作者带过去就发错人了)。
|
||||
var args = arguments;
|
||||
var pageAuthorId = Number((window.artalkConfig || {}).pageAuthorId || 0);
|
||||
if (pageAuthorId && init && typeof init.body === 'string') {
|
||||
try {
|
||||
var payload = JSON.parse(init.body);
|
||||
if (payload && typeof payload === 'object' && !payload.author_id) {
|
||||
payload.author_id = pageAuthorId;
|
||||
args = [input, Object.assign({}, init, { body: JSON.stringify(payload) })];
|
||||
}
|
||||
} catch (e) { /* body 不是 JSON(理论上不会)→ 原样放行,不影响提交 */ }
|
||||
}
|
||||
|
||||
var p = origFetch.apply(this, args);
|
||||
return p.then(function (res) {
|
||||
if (res && res.ok) {
|
||||
// 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是
|
||||
|
||||
@@ -264,6 +264,10 @@
|
||||
模板「这就是最终 JS 字面量」,不再二次转义;`</` 仍按惯例转义兜底。 */}}
|
||||
{{ $t := .Title | jsonify }}
|
||||
pageTitle: {{ replace $t "</" "<\\/" | safeJS }},
|
||||
// 文章作者的 user id:评论提交时由 artalk.js 的 fetch 拦截器塞进请求体,
|
||||
// 服务端据此把「有新评论」的邮件发给文章作者本人 —— 编辑角色(女朋友)
|
||||
// 写的文章就不再打扰博主了。老文章没有 author_id → 0,服务端回退到原行为。
|
||||
pageAuthorId: {{ .Params.author_id | default 0 | int }},
|
||||
emoticons: '{{ .Site.Params.artalk.emoticons | absURL }}',
|
||||
aiComment: null
|
||||
};
|
||||
@@ -281,4 +285,4 @@
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</script>
|
||||
Reference in new issue
Block a user