From 57bd4ea7378248220b0bc3feec728b1cc7ec7507 Mon Sep 17 00:00:00 2001 From: zqlit Date: Mon, 5 Oct 2026 16:44:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=8B=E9=93=BE):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E3=80=8C=E9=80=9A=E8=BF=87=E4=B8=8D=E4=BA=86?= =?UTF-8?q?/=E5=88=A0=E4=B8=8D=E6=8E=89=E3=80=8D=EF=BC=9Bfeat(=E8=AF=84?= =?UTF-8?q?=E8=AE=BA):=20=E9=80=9A=E7=9F=A5=E6=8C=89=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E4=BD=9C=E8=80=85=E5=88=86=E6=B4=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 友链自助申请(通过不了 / 删除不掉) 根因:admin.js 全局事件委托把 data-id 一律 parseInt, 而友链申请 id 是字符串(lamus5uptzcdam)→ NaN → JSON 成 {"id":null} → 后端 String(null||'') 为空 → 400 id and action required,查看/通过/拒绝全失效。 引入于 299ab57e(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)。 --- blog-admin/public/admin/admin.js | 18 ++++++- blog-admin/src/lib/mail.ts | 40 ++++++++++++--- blog-admin/src/routes/comments.ts | 5 ++ blog-admin/src/routes/rss/links.ts | 62 ++++++++++++++++++------ themes/Ying/assets/js/modules/artalk.js | 18 ++++++- themes/Ying/layouts/partials/artalk.html | 6 ++- 6 files changed, 124 insertions(+), 25 deletions(-) diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index c8affca5..54cc4304 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -1123,7 +1123,8 @@ async function viewLinks() { '' + '' + '' + - '' + + '' + + '' + '' + '').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; } diff --git a/blog-admin/src/lib/mail.ts b/blog-admin/src/lib/mail.ts index d1800d5c..1522bf53 100644 --- a/blog-admin/src/lib/mail.ts +++ b/blog-admin/src/lib/mail.ts @@ -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 { } } - // --- 管理员(用 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 { 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 { 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] 已达当日发信上限,跳过'); diff --git a/blog-admin/src/routes/comments.ts b/blog-admin/src/routes/comments.ts index 7036bfae..e8742019 100644 --- a/blog-admin/src/routes/comments.ts +++ b/blog-admin/src/routes/comments.ts @@ -375,6 +375,10 @@ export async function createComment(ctx: Ctx): Promise { 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 { // 《"留言"》)。存量仅在本次没带标题时兜底。 pageTitle: pageTitle || page.title, pageUrl: pageAccessibleUrl(pageKey, siteUrl), + pageAuthorId, }); } catch (e) { console.error('[mail] 通知流程失败:', e instanceof Error ? e.message : e); diff --git a/blog-admin/src/routes/rss/links.ts b/blog-admin/src/routes/rss/links.ts index dfb463d9..3ad59532 100644 --- a/blog-admin/src/routes/rss/links.ts +++ b/blog-admin/src/routes/rss/links.ts @@ -210,38 +210,70 @@ export async function linkApplyReview(request: Request, env: Env): Promise(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}`, `

你的友链申请(${app.url})已审核通过,现已加进友链列表,感谢支持!

` + (app.feedback ? `

站长留言:${app.feedback}

` : '')); } else { + app.status = 'rejected'; + await kvPutJson(env, 'link_applications', st); mailOk = await sendMailSafe(env, app.email, `关于你的友链申请:${app.name}`, `

很抱歉,你的友链申请(${app.url})本次未能通过。

` + diff --git a/themes/Ying/assets/js/modules/artalk.js b/themes/Ying/assets/js/modules/artalk.js index 355a8c3f..7dd59fdf 100644 --- a/themes/Ying/assets/js/modules/artalk.js +++ b/themes/Ying/assets/js/modules/artalk.js @@ -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 看是不是落进了审核队列(服务端返回的就是 diff --git a/themes/Ying/layouts/partials/artalk.html b/themes/Ying/layouts/partials/artalk.html index 27d9c72e..5e0ed731 100644 --- a/themes/Ying/layouts/partials/artalk.html +++ b/themes/Ying/layouts/partials/artalk.html @@ -264,6 +264,10 @@ 模板「这就是最终 JS 字面量」,不再二次转义;` \ No newline at end of file +