Compare commits

...

5 Commits

Author SHA1 Message Date
john a15cc66f31 docs: register fix/wechat-schedule-delivery-false-success branch disposition
Memind CI / Test, build, and release guards (push) Successful in 3m42s
Record merge into main and 103 fast release artifact for the WeChat
deferred delivery reminder fix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 21:45:54 +08:00
john d820247aa1 fix(schedule): retry reminders when WeChat delivery is deferred
Memind CI / Test, build, and release guards (push) Successful in 3m32s
Treat deferred/skipped customer-service sends as failures so reminder worker
does not mark reminders sent, and add a 103 read-only health check script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 21:30:53 +08:00
tkmind eecfb79042 Merge pull request 'feat(seo): 工作区 public/*.html 默认纳入 SEO 收录' (#51) from feature/workspace-public-implicit-seo into main
Memind CI / Test, build, and release guards (push) Successful in 3m19s
2026-09-16 12:56:20 +00:00
john 745e9b691d feat(seo): 工作区 public/*.html 默认纳入 SEO 收录
Memind CI / Test, build, and release guards (pull_request) Successful in 3m35s
未点公开发布但已落盘到 public/ 的页面,在无在线非 public 发布阻挡时
合成 implicit public snapshot,注入 canonical/JSON-LD 并扫描进 sitemap。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 20:49:20 +08:00
tkmind 422ff8647c Merge pull request 'docs: register feature/baidu-seo-push branch disposition' (#50) from docs/baidu-seo-push-disposition into main
Memind CI / Test, build, and release guards (push) Successful in 3m20s
2026-09-16 11:26:59 +00:00
16 changed files with 814 additions and 43 deletions
+28
View File
@@ -1,5 +1,33 @@
# 历史分支处置登记
## `fix/wechat-schedule-delivery-false-success`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`d820247a`
`main` 对应提交:`d820247a`
103 发布 artifact`memind-portal-runtime-20260916-213838-d820247a`
### 交付内容
- 修复 schedule reminder worker 将微信 deferred/skipped 客服消息误标为 success 的问题;失败走 `markReminderFailed` 重试
- scheduled task worker`notifyChannel=wechat` 时 deferred 失败;`both` 时保留 web 通知
- 新增只读巡检脚本 `scripts/check-schedule-reminder-health-103.mjs``npm run check:schedule-reminder-health-103`
### 验证摘要
- schedule-reminder-worker / scheduled-task-worker / notification-dispatcher 单测通过
- `verify-schedule-reminder-create` 15/15、`verify-schedule-reminder-routing` 8/8
- `db.test.mjs` + `capabilities.test.mjs` + `wechat-mp.test.mjs` 141 项通过
- 103 快速发布 + 8081 健康检查 `ok`;发版后 SQL 巡检 overdue=0、delivery success 24h=5
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `d820247a` 或 artifact `memind-portal-runtime-20260916-213838-d820247a`
## `feature/baidu-seo-push`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
+2 -2
View File
@@ -4,8 +4,8 @@
## 保护内容
1. **收录策略硬规则** `access_mode=public``status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`
2. **私有页强制 noindex**密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
1. **收录策略硬规则**`h5_publish_records` `access_mode=public``status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录**此外**,工作区 `public/*.html` 直链在无在线非 public 发布记录阻挡时,默认视为可收录(implicit public。不再要求 `user_confirmed_at`
2. **私有页强制 noindex**在线 `password` / `private_link` / `login_required` / `owner_only` 发布、已过期、embed 模式等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
3. **总开关默认开启**`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产。
4. **配置来源**memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
+32
View File
@@ -1,5 +1,36 @@
const INDEXABLE_ACCESS_MODE = 'public';
export function isWorkspacePublicHtmlRelativePath(value) {
const normalized = String(value ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!/^public\/[^/]+\.html$/i.test(normalized)) return false;
if (/\/_archived[^/]*\.html$/i.test(normalized)) return false;
return true;
}
export function buildImplicitWorkspacePublicPublication({
publicUrl = '',
pageId = null,
updatedAt = null,
title = null,
summary = null,
} = {}) {
const url = String(publicUrl ?? '').trim();
if (!url) return null;
return normalizePublicationSnapshot({
accessMode: INDEXABLE_ACCESS_MODE,
status: 'online',
publicUrl: url,
pageId,
userConfirmedAt: null,
expiresAt: null,
updatedAt: updatedAt ?? Date.now(),
title,
summary,
});
}
export function normalizePublicationSnapshot(input = {}) {
if (!input || typeof input !== 'object') return null;
const accessMode = String(
@@ -83,4 +114,5 @@ export function resolveIndexPolicy({
export const indexPolicyInternals = {
INDEXABLE_ACCESS_MODE,
isWorkspacePublicHtmlRelativePath,
};
+24
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildImplicitWorkspacePublicPublication,
isPublicationIndexable,
isWorkspacePublicHtmlRelativePath,
resolveIndexPolicy,
} from './mindspace-index-policy.mjs';
@@ -73,3 +75,25 @@ test('resolveIndexPolicy skips embed delivery', () => {
});
assert.equal(policy.mode, 'off');
});
test('isWorkspacePublicHtmlRelativePath accepts public html and rejects archived', () => {
assert.equal(isWorkspacePublicHtmlRelativePath('public/demo.html'), true);
assert.equal(isWorkspacePublicHtmlRelativePath('public/_archived-demo.html'), false);
assert.equal(isWorkspacePublicHtmlRelativePath('private/demo.html'), false);
});
test('buildImplicitWorkspacePublicPublication marks workspace public html indexable', () => {
const snapshot = buildImplicitWorkspacePublicPublication({
publicUrl: '/MindSpace/user/public/demo.html',
});
assert.equal(isPublicationIndexable(snapshot), true);
const policy = resolveIndexPolicy({
seoGeoConfig: {
enabled: true,
seo: { enabled: true, canonical: true },
geo: { enabled: true, jsonLd: true },
},
publication: snapshot,
});
assert.equal(policy.mode, 'indexable');
});
+175 -32
View File
@@ -1,5 +1,30 @@
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
import fs from 'node:fs';
import path from 'node:path';
import {
buildImplicitWorkspacePublicPublication,
isPublicationIndexable,
isWorkspacePublicHtmlRelativePath,
normalizePublicationSnapshot,
} from './mindspace-index-policy.mjs';
import { buildMindSpacePublicRoutePath } from './mindspace-runtime-config.mjs';
import { PLATFORM_STATIC_DISCOVERY_ENTRIES } from './platform-seo-html.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
function mapPublicationRow(row) {
if (!row) return null;
return normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
});
}
function escapeXml(value) {
return String(value ?? '')
@@ -18,9 +43,88 @@ function toAbsoluteUrl(origin, value) {
return raw.split('#')[0];
}
async function loadOnlineNonPublicWorkspacePaths(pool) {
if (!pool) return new Set();
const [rows] = await pool.query(
`SELECT LOWER(pr.user_id) AS user_id, p.workspace_relative_path
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.access_mode <> 'public'
AND p.workspace_relative_path LIKE 'public/%.html'`,
);
return new Set(
rows.map((row) => `${String(row.user_id ?? '').toLowerCase()}\0${row.workspace_relative_path}`),
);
}
async function loadWorkspacePageMetaByPath(pool) {
if (!pool) return new Map();
const [rows] = await pool.query(
`SELECT id, LOWER(user_id) AS user_id, workspace_relative_path, title, summary, updated_at
FROM h5_page_records
WHERE status <> 'deleted'
AND workspace_relative_path LIKE 'public/%.html'`,
);
const map = new Map();
for (const row of rows) {
map.set(`${String(row.user_id ?? '').toLowerCase()}\0${row.workspace_relative_path}`, row);
}
return map;
}
export async function listWorkspacePublicHtmlIndexEntries(
pool,
{ h5Root = null, now = Date.now() } = {},
) {
if (!h5Root) return [];
const root = path.join(path.resolve(String(h5Root)), PUBLISH_ROOT_DIR);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return [];
const blocked = await loadOnlineNonPublicWorkspacePaths(pool);
const pageMeta = await loadWorkspacePageMetaByPath(pool);
const entries = [];
for (const userId of fs.readdirSync(root)) {
if (!userId || userId.startsWith('.')) continue;
const publicDir = path.join(root, userId, 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
for (const name of fs.readdirSync(publicDir)) {
if (!name.toLowerCase().endsWith('.html')) continue;
const relativePath = `public/${name}`;
if (!isWorkspacePublicHtmlRelativePath(relativePath)) continue;
const blockKey = `${userId.toLowerCase()}\0${relativePath}`;
if (blocked.has(blockKey)) continue;
const meta = pageMeta.get(blockKey);
const filePath = path.join(publicDir, name);
let updatedAt = now;
try {
updatedAt = fs.statSync(filePath).mtimeMs;
} catch {
updatedAt = now;
}
const snapshot = buildImplicitWorkspacePublicPublication({
publicUrl: buildMindSpacePublicRoutePath(userId, relativePath.split('/')),
pageId: meta?.id ?? null,
updatedAt: meta?.updated_at ?? updatedAt,
title: meta?.title ?? null,
summary: meta?.summary ?? null,
});
if (snapshot && isPublicationIndexable(snapshot, { now })) {
entries.push(snapshot);
}
}
}
return entries;
}
export async function listIndexablePublications(
pool,
{ limit = 5000, offset = 0 } = {},
{ limit = 5000, offset = 0, h5Root = null } = {},
) {
if (!pool) return [];
const safeLimit = Math.min(Math.max(Number(limit) || 5000, 1), 10000);
@@ -38,22 +142,14 @@ export async function listIndexablePublications(
LIMIT ? OFFSET ?`,
[Date.now(), safeLimit, safeOffset],
);
return rows
.map((row) =>
normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
}),
)
const fromDb = rows
.map((row) => mapPublicationRow(row))
.filter((entry) => isPublicationIndexable(entry));
if (!h5Root || safeOffset > 0) {
return dedupeDiscoveryEntriesByPage(fromDb);
}
const fromWorkspace = await listWorkspacePublicHtmlIndexEntries(pool, { h5Root });
return dedupeDiscoveryEntriesByPage([...fromDb, ...fromWorkspace]);
}
export async function resolvePublicationIndexSnapshot(
@@ -90,19 +186,61 @@ export async function resolvePublicationIndexSnapshot(
}
sql += ' ORDER BY pr.published_at DESC LIMIT 1';
const [rows] = await pool.query(sql, params);
const row = rows[0];
if (!row) return null;
return normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
return mapPublicationRow(rows[0]);
}
export async function resolveWorkspacePublicationIndexSnapshot(
pool,
{
publicationId = null,
pageId = null,
userId = null,
workspaceRelativePath = '',
publicUrl = '',
} = {},
) {
const online = await resolvePublicationIndexSnapshot(pool, {
publicationId,
pageId,
userId,
});
if (online) {
return online;
}
const relativePath = String(workspaceRelativePath ?? '').trim();
const owner = String(userId ?? '').trim();
if (!isWorkspacePublicHtmlRelativePath(relativePath) || !owner) {
return null;
}
if (pool) {
const [rows] = await pool.query(
`SELECT pr.id, pr.page_id, pr.public_url, pr.access_mode, pr.status,
pr.user_confirmed_at, pr.expires_at, pr.updated_at, pr.published_at,
p.title, p.summary
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.user_id = ?
AND p.workspace_relative_path = ?
AND pr.access_mode <> 'public'
ORDER BY pr.published_at DESC
LIMIT 1`,
[owner, relativePath],
);
const blocking = mapPublicationRow(rows[0]);
if (blocking) {
return blocking;
}
}
const resolvedPublicUrl =
String(publicUrl ?? '').trim() ||
buildMindSpacePublicRoutePath(owner, relativePath.split('/'));
return buildImplicitWorkspacePublicPublication({
publicUrl: resolvedPublicUrl,
pageId: pageId ?? null,
});
}
@@ -210,11 +348,16 @@ export function renderLlmsTxt(entries, { origin = '' } = {}) {
return `${header.concat(body).join('\n')}\n`;
}
export function createMindspaceSeoDiscoveryService(pool) {
export function createMindspaceSeoDiscoveryService(pool, { h5Root = null } = {}) {
return {
listIndexablePublications: (options) => listIndexablePublications(pool, options),
listIndexablePublications: (options) =>
listIndexablePublications(pool, { h5Root, ...options }),
resolvePublicationIndexSnapshot: (options) =>
resolvePublicationIndexSnapshot(pool, options),
resolveWorkspacePublicationIndexSnapshot: (options) =>
resolveWorkspacePublicationIndexSnapshot(pool, options),
listWorkspacePublicHtmlIndexEntries: (options) =>
listWorkspacePublicHtmlIndexEntries(pool, { h5Root, ...options }),
dedupeDiscoveryEntriesByPage,
mergeDiscoveryEntries,
renderSitemapXml,
+61
View File
@@ -1,11 +1,16 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
dedupeDiscoveryEntriesByPage,
listWorkspacePublicHtmlIndexEntries,
mergeDiscoveryEntries,
renderLlmsTxt,
renderRobotsTxt,
renderSitemapXml,
resolveWorkspacePublicationIndexSnapshot,
} from './mindspace-seo-discovery-service.mjs';
test('renderSitemapXml emits only provided urls', () => {
@@ -78,3 +83,59 @@ test('renderLlmsTxt lists markdown links', () => {
);
assert.match(body, /\[仙居玩水\]\(https:\/\/m\.tkmind\.cn\/u\/john\/pages\/demo\)/);
});
test('listWorkspacePublicHtmlIndexEntries includes unpublished public html on disk', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-seo-disk-'));
const userId = 'user-implicit-seo';
const publicDir = path.join(root, 'MindSpace', userId, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(
path.join(publicDir, 'weather-broadcast-20260916.html'),
'<!doctype html><html><head><title>Weather</title></head><body></body></html>',
'utf8',
);
const entries = await listWorkspacePublicHtmlIndexEntries(null, { h5Root: root });
assert.equal(entries.length, 1);
assert.match(entries[0].publicUrl ?? '', /weather-broadcast-20260916\.html/);
});
test('resolveWorkspacePublicationIndexSnapshot falls back to implicit public html', async () => {
const snapshot = await resolveWorkspacePublicationIndexSnapshot(null, {
userId: 'user-1',
workspaceRelativePath: 'public/demo.html',
publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/demo.html',
});
assert.equal(snapshot?.accessMode, 'public');
assert.equal(snapshot?.status, 'online');
});
test('resolveWorkspacePublicationIndexSnapshot respects online non-public publication', async () => {
const pool = {
async query(sql, params) {
if (/access_mode <> 'public'/i.test(sql)) {
assert.equal(params[1], 'public/demo.html');
return [[{
id: 'pub-private',
page_id: 'page-1',
public_url: '/u/user/pages/demo',
access_mode: 'password',
status: 'online',
user_confirmed_at: null,
expires_at: null,
updated_at: Date.now(),
published_at: Date.now(),
title: 'Demo',
summary: '私有',
}]];
}
return [[], []];
},
};
const snapshot = await resolveWorkspacePublicationIndexSnapshot(pool, {
userId: 'user-1',
workspaceRelativePath: 'public/demo.html',
publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/demo.html',
});
assert.equal(snapshot?.accessMode, 'password');
});
+16 -1
View File
@@ -1,10 +1,25 @@
function resolveWechatDispatchSent(result) {
export const WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT =
'WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT';
export function resolveWechatDispatchSent(result) {
if (result && typeof result === 'object') {
return result.sent !== false && !result.deferred && !result.skipped;
}
return result !== false;
}
export async function deliverWechatScheduleNotification(
sendScheduleNotification,
payload,
) {
const result = await sendScheduleNotification(payload);
if (!resolveWechatDispatchSent(result)) {
const err = new Error('微信提醒发送未完成(deferred、skipped 或未绑定)');
err.code = WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT;
throw err;
}
}
export function createNotificationDispatcher({ sendWechatTextToUser, logger = console } = {}) {
const sendWechat = async (userId, text, options = {}) => {
if (typeof sendWechatTextToUser !== 'function') return false;
+15 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNotificationDispatcher } from './notification-dispatcher.mjs';
import {
createNotificationDispatcher,
deliverWechatScheduleNotification,
WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT,
} from './notification-dispatcher.mjs';
test('notification dispatcher forwards recharge success text unchanged', async () => {
const sent = [];
@@ -144,3 +148,13 @@ test('notification dispatcher returns false when wechat sender is unavailable',
},
]);
});
test('deliverWechatScheduleNotification throws when sender defers', async () => {
await assert.rejects(
() => deliverWechatScheduleNotification(
async () => ({ sent: false, deferred: true, errcode: 45015 }),
{ userId: 'user-5', text: '提醒' },
),
(err) => err.code === WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT,
);
});
+1
View File
@@ -139,6 +139,7 @@
"verify:tang-itl-readiness-103": "node scripts/verify-tang-itl-readiness-103.mjs",
"simulate:tang-wechat-itl": "node scripts/simulate-tang-wechat-itl-flow.mjs",
"check:itl-rollout-config": "node scripts/check-itl-rollout-config.mjs",
"check:schedule-reminder-health-103": "node scripts/check-schedule-reminder-health-103.mjs",
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
+14 -3
View File
@@ -1,3 +1,5 @@
import { deliverWechatScheduleNotification } from './notification-dispatcher.mjs';
export function startScheduleReminderWorker({
scheduleService,
sendWechatTextToUser,
@@ -45,7 +47,10 @@ export function startScheduleReminderWorker({
},
});
if (reminder.channel === 'wechat') {
await sendScheduleNotification({ userId: reminder.userId, text });
await deliverWechatScheduleNotification(sendScheduleNotification, {
userId: reminder.userId,
text,
});
}
await scheduleService.logDelivery({
reminderId: reminder.id,
@@ -88,7 +93,10 @@ export function startScheduleReminderWorker({
timezone: subscription.timezone,
},
});
await sendScheduleNotification({ userId: subscription.userId, text });
await deliverWechatScheduleNotification(sendScheduleNotification, {
userId: subscription.userId,
text,
});
await scheduleService.logDelivery({
subscriptionId: subscription.id,
userId: subscription.userId,
@@ -135,7 +143,10 @@ export function startScheduleReminderWorker({
balanceCents,
},
});
await sendScheduleNotification({ userId: subscription.userId, text });
await deliverWechatScheduleNotification(sendScheduleNotification, {
userId: subscription.userId,
text,
});
await scheduleService.logDelivery({
subscriptionId: subscription.id,
userId: subscription.userId,
+61
View File
@@ -264,3 +264,64 @@ test('schedule reminder worker skips wechat for in_app reminders', async () => {
assert.deepEqual(sent, []);
});
test('schedule reminder worker retries when wechat delivery is deferred', async () => {
const calls = [];
const reminder = {
id: 'rem-deferred',
userId: 'user-1',
itemId: 'item-1',
remindAt: Date.now() - 1000,
channel: 'wechat',
attempts: 1,
};
const worker = startScheduleReminderWorker({
intervalMs: 60_000,
scheduleService: {
async listDueReminders() {
return [reminder];
},
async lockReminder() {
return reminder;
},
async buildReminderText() {
return '【待办提醒】开会';
},
async createUserNotification() {},
async logDelivery(input) {
calls.push(`log:${input.status}`);
},
async markReminderSent() {
calls.push('sent');
},
async markReminderFailed() {
calls.push('failed');
},
async markReminderCancelled() {},
async listDueDigestSubscriptions() {
return [];
},
async lockDigestSubscription() {
return null;
},
async listDueBalanceAlerts() {
return [];
},
async lockBalanceAlert() {
return null;
},
},
notificationDispatcher: {
async sendScheduleNotification() {
return false;
},
},
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, ['log:failed', 'failed']);
});
+15 -1
View File
@@ -1,3 +1,7 @@
import {
resolveWechatDispatchSent,
WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT,
} from './notification-dispatcher.mjs';
import {
buildScheduledTaskVerifiedHtmlUrls,
deliveryTextPromisesPublicHtml,
@@ -72,7 +76,7 @@ export function startScheduledTaskWorker({
userId: task.userId,
});
} else {
const sent = await sendScheduleNotification({
const sendResult = await sendScheduleNotification({
userId: task.userId,
text,
verifiedHtmlUrls,
@@ -80,6 +84,7 @@ export function startScheduledTaskWorker({
logger.warn?.('Scheduled task wechat notification failed:', err);
return false;
});
const sent = resolveWechatDispatchSent(sendResult);
if (sent) {
wechatDelivery = {
sentAt: Date.now(),
@@ -87,6 +92,15 @@ export function startScheduledTaskWorker({
source: 'scheduled_task_worker',
textOnly: verifiedHtmlUrls.length === 0,
};
} else if (notifyChannel === 'wechat') {
const err = new Error('定时任务微信发送未完成(deferred、skipped 或未绑定)');
err.code = WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT;
throw err;
} else {
logger.warn?.('[ScheduledTask] wechat delivery incomplete; web notification kept', {
taskId: task.id,
userId: task.userId,
});
}
}
}
+111
View File
@@ -289,3 +289,114 @@ test('scheduled task worker marks failure when page link is not deliverable yet'
assert.deepEqual(calls, ['failed:SCHEDULED_TASK_NON_DELIVERY', 'notify:scheduled_task_failed']);
});
test('scheduled task worker fails wechat-only task when delivery is deferred', async () => {
const calls = [];
const task = {
id: 'task-wechat-deferred',
userId: 'user-6',
title: '仅微信通知',
recurrence: 'once',
notifyChannel: 'wechat',
attempts: 1,
};
const worker = startScheduledTaskWorker({
intervalMs: 60_000,
userAuth: { id: 'user-auth' },
tkmindProxy: { id: 'proxy' },
scheduledTaskService: {
async listDueTasks() {
return [task];
},
async lockTask() {
return task;
},
async markTaskRunning(input) {
return input;
},
async markTaskSucceeded() {
calls.push('success');
},
async markTaskFailed(input, err) {
calls.push(`failed:${err.code}`);
return { ...input, status: 'failed', lastError: err.message };
},
},
notificationDispatcher: {
async sendScheduleNotification() {
return false;
},
},
executeTask: async () => ({
sessionId: 'session-6',
requestId: 'req-6',
deliveryText: '任务摘要已完成,详细内容已写入 MindSpace 工作区。',
readyPaths: [],
}),
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, ['failed:WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT']);
});
test('scheduled task worker keeps both-channel task when wechat is deferred but web succeeds', async () => {
const calls = [];
const task = {
id: 'task-both-deferred',
userId: 'user-7',
title: '双通道通知',
recurrence: 'once',
notifyChannel: 'both',
attempts: 1,
};
const worker = startScheduledTaskWorker({
intervalMs: 60_000,
userAuth: { id: 'user-auth' },
tkmindProxy: { id: 'proxy' },
scheduledTaskService: {
async listDueTasks() {
return [task];
},
async lockTask() {
return task;
},
async markTaskRunning(input) {
return input;
},
async markTaskSucceeded(input, payload) {
calls.push(`success:${payload.result.wechatDelivery ? 'wechat' : 'web-only'}`);
return input;
},
async markTaskFailed() {
calls.push('failed');
},
},
scheduleService: {
async createUserNotification() {
calls.push('notify:web');
},
},
notificationDispatcher: {
async sendScheduleNotification() {
return false;
},
},
executeTask: async () => ({
sessionId: 'session-7',
requestId: 'req-7',
deliveryText: '任务摘要已完成,详细内容已写入 MindSpace 工作区。',
readyPaths: [],
}),
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, ['notify:web', 'success:web-only']);
});
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* 103 服务号定时提醒健康巡检(只读)
*
* 用法:
* node scripts/check-schedule-reminder-health-103.mjs
* DATABASE_URL=... node scripts/check-schedule-reminder-health-103.mjs
*
* 在 103 上可配合:
* cd /Users/john/Project/Memind && node scripts/check-schedule-reminder-health-103.mjs
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { isScheduledTaskWorkerEnabled } from '../scheduled-task-worker-config.mjs';
import { isWechatNewsMorningDraftWorkerEnabled } from '../wechat-news-morning-draft-worker-config.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const env = process.env;
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const hourMs = 60 * 60 * 1000;
let passed = 0;
let failed = 0;
let warned = 0;
function pass(label, detail = '') {
passed += 1;
console.log(`${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
failed += 1;
console.error(`${label}${detail ? `: ${detail}` : ''}`);
}
function warn(label, detail = '') {
warned += 1;
console.warn(`${label}${detail ? `: ${detail}` : ''}`);
}
function envFlag(name) {
return String(env[name] ?? '').trim();
}
function envEnabled(name) {
return envFlag(name) === '1';
}
async function scalar(pool, sql, params = []) {
const [rows] = await pool.query(sql, params);
return Number(rows?.[0]?.c ?? rows?.[0]?.count ?? 0);
}
async function checkEnvironment() {
console.log('\n=== 环境变量 ===\n');
if (envEnabled('H5_WECHAT_MP_ENABLED')) pass('H5_WECHAT_MP_ENABLED=1');
else fail('H5_WECHAT_MP_ENABLED=1');
if (envEnabled('H5_SCHEDULE_ENABLED')) pass('H5_SCHEDULE_ENABLED=1');
else fail('H5_SCHEDULE_ENABLED=1');
if (envEnabled('H5_REMINDER_WORKER_ENABLED')) pass('H5_REMINDER_WORKER_ENABLED=1');
else fail('H5_REMINDER_WORKER_ENABLED=1');
if (isScheduledTaskWorkerEnabled(env)) {
pass('Scheduled task worker enabled');
} else {
warn('Scheduled task worker disabled', 'H5_SCHEDULED_TASK_WORKER_ENABLED=0 且 H5_REMINDER_WORKER_ENABLED≠1');
}
if (isWechatNewsMorningDraftWorkerEnabled(env)) {
pass('News morning draft worker enabled');
} else {
warn('News morning draft worker disabled');
}
if (envFlag('H5_DEFAULT_TIMEZONE')) {
pass('H5_DEFAULT_TIMEZONE', envFlag('H5_DEFAULT_TIMEZONE'));
} else {
warn('H5_DEFAULT_TIMEZONE 未设置', '默认 Asia/Shanghai');
}
const passiveCandidate =
envFlag('MEMIND_PORTAL_RUNTIME_ROLE') === 'candidate'
&& envFlag('MEMIND_CANARY_PASSIVE_RUNTIME') !== '0';
if (passiveCandidate) {
fail('Passive canary runtime', 'worker 会被禁用,不应承载定时提醒');
} else {
pass('非 passive canary runtime');
}
}
async function checkDatabase(pool) {
console.log('\n=== 数据库指标(只读)===\n');
const overduePending = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_reminders
WHERE status = 'pending' AND remind_at < ?`,
[now - hourMs],
);
if (overduePending === 0) pass('无 overdue pending 提醒');
else fail('overdue pending 提醒', String(overduePending));
const stuckLocked = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_reminders
WHERE status = 'locked' AND locked_until IS NOT NULL AND locked_until < ?`,
[now],
);
if (stuckLocked === 0) pass('无 stuck locked 提醒');
else fail('stuck locked 提醒', String(stuckLocked));
const failedReminders24h = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_reminders
WHERE status = 'failed' AND updated_at >= ?`,
[now - dayMs],
);
if (failedReminders24h === 0) pass('24h 内无 failed 提醒');
else warn('24h 内 failed 提醒', String(failedReminders24h));
const deliveryFailed24h = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_delivery_logs
WHERE status = 'failed' AND created_at >= ?`,
[now - dayMs],
);
if (deliveryFailed24h === 0) pass('24h 内无 failed delivery log');
else warn('24h 内 failed delivery log', String(deliveryFailed24h));
const deliverySuccess24h = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_delivery_logs
WHERE status = 'success' AND created_at >= ?`,
[now - dayMs],
);
pass('24h delivery success 计数', String(deliverySuccess24h));
const deferredQueue = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_wechat_mp_deferred_delivery`,
);
if (deferredQueue === 0) pass('deferred 队列为空');
else warn('deferred 队列积压', String(deferredQueue));
const activeDigests = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_digest_subscriptions WHERE status = 'active'`,
);
pass('active 待办摘要订阅', String(activeDigests));
const activeScheduledTasks = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_scheduled_tasks WHERE status = 'active'`,
);
pass('active 定时自动任务', String(activeScheduledTasks));
const failedTasks24h = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_scheduled_tasks
WHERE status = 'failed' AND updated_at >= ?`,
[now - dayMs],
);
if (failedTasks24h === 0) pass('24h 内无 failed 定时任务');
else warn('24h 内 failed 定时任务', String(failedTasks24h));
const morningReminders = await scalar(
pool,
`SELECT COUNT(*) AS c FROM h5_schedule_items
WHERE status = 'active'
AND deleted_at IS NULL
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'`,
);
pass('active 早安提醒订阅', String(morningReminders));
const [recentFailedDeliveries] = await pool.query(
`SELECT d.created_at, d.error_message, u.username, d.channel
FROM h5_schedule_delivery_logs d
JOIN h5_users u ON u.id = d.user_id
WHERE d.status = 'failed' AND d.created_at >= ?
ORDER BY d.created_at DESC
LIMIT 5`,
[now - dayMs],
);
if (recentFailedDeliveries.length > 0) {
console.log('\n--- 最近失败投递(最多 5 条)---');
for (const row of recentFailedDeliveries) {
console.log(
` ${new Date(Number(row.created_at)).toISOString()} ${row.username} ${row.channel} ${row.error_message ?? ''}`,
);
}
}
const [overdueSamples] = await pool.query(
`SELECT r.id, r.remind_at, r.attempts, r.last_error, u.username, i.title
FROM h5_schedule_reminders r
JOIN h5_schedule_items i ON i.id = r.item_id
JOIN h5_users u ON u.id = r.user_id
WHERE r.status = 'pending' AND r.remind_at < ?
ORDER BY r.remind_at ASC
LIMIT 5`,
[now - hourMs],
);
if (overdueSamples.length > 0) {
console.log('\n--- overdue pending 样本(最多 5 条)---');
for (const row of overdueSamples) {
console.log(
` ${row.username} "${row.title}" attempts=${row.attempts} remind_at=${new Date(Number(row.remind_at)).toISOString()} ${row.last_error ?? ''}`,
);
}
}
}
async function main() {
console.log('=== 103 服务号定时提醒健康巡检 ===');
console.log(`时间: ${new Date(now).toISOString()}`);
await checkEnvironment();
if (!env.DATABASE_URL) {
fail('DATABASE_URL', '未配置,跳过数据库检查');
summarize();
process.exit(1);
}
const pool = mysql.createPool({ uri: env.DATABASE_URL, connectionLimit: 2 });
try {
await checkDatabase(pool);
} finally {
await pool.end();
}
summarize();
process.exit(failed > 0 ? 1 : 0);
}
function summarize() {
console.log('\n=== 汇总 ===');
console.log(`通过: ${passed}`);
console.log(`警告: ${warned}`);
console.log(`失败: ${failed}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+3 -1
View File
@@ -1703,7 +1703,9 @@ let mindspaceSeoDiscoveryService = null;
function getMindspaceSeoDiscoveryService() {
if (!authPool) return null;
if (!mindspaceSeoDiscoveryService) {
mindspaceSeoDiscoveryService = createMindspaceSeoDiscoveryService(authPool);
mindspaceSeoDiscoveryService = createMindspaceSeoDiscoveryService(authPool, {
h5Root: H5_ROOT,
});
}
return mindspaceSeoDiscoveryService;
}
@@ -44,7 +44,7 @@ import {
loadMindSpaceConfigCached,
} from '../mindspace-config.mjs';
import {
resolvePublicationIndexSnapshot,
resolveWorkspacePublicationIndexSnapshot,
} from '../mindspace-seo-discovery-service.mjs';
function deliveryNotFoundMessage(reason) {
@@ -71,7 +71,7 @@ export function createPortalWorkspacePublicationDelivery({
resolvePageDataContext =
resolveMindSpacePageDataContext,
getMindSpaceConfig = loadMindSpaceConfigCached,
resolvePublicationSnapshot = resolvePublicationIndexSnapshot,
resolvePublicationSnapshot = resolveWorkspacePublicationIndexSnapshot,
} = {}) {
if (
typeof resolveRequestOrigin !==
@@ -279,6 +279,8 @@ export function createPortalWorkspacePublicationDelivery({
null,
pageId: pageDataContext?.pageId ?? null,
userId: delivery.ownerId ?? null,
workspaceRelativePath: delivery.relativePath ?? null,
publicUrl: context.pageUrl ?? null,
}).catch(() => null);
}
const decorated =