feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,6 +42,13 @@ export const PORTAL_STATIC_ACCESS_RULES = Object.freeze([
|
||||
legacyDirectBypass: true,
|
||||
paths: ['/config/blocked-words'],
|
||||
}),
|
||||
freezeRule({
|
||||
id: 'seo-discovery',
|
||||
accessClass: PORTAL_ACCESS_CLASS.PUBLIC,
|
||||
legacyDirectBypass: true,
|
||||
methods: ['GET'],
|
||||
paths: ['/robots.txt', '/sitemap.xml', '/llms.txt'],
|
||||
}),
|
||||
freezeRule({
|
||||
id: 'internal-agent',
|
||||
accessClass: PORTAL_ACCESS_CLASS.INTERNAL,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function attachPortalAgentRuntimeRoutes(
|
||||
getAgentRunGateway = () => null,
|
||||
getGoalRunService = () => null,
|
||||
getChatIntentRouter = () => null,
|
||||
getTemplateCatalog = () => null,
|
||||
getSessionAccess = () => null,
|
||||
getMindSpaceAssetAgent = () => null,
|
||||
getCodeRunPolicyService = () => null,
|
||||
@@ -134,6 +135,7 @@ export function attachPortalAgentRuntimeRoutes(
|
||||
codeRunPolicyService: getCodeRunPolicyService(),
|
||||
goalRunService: getGoalRunService(),
|
||||
chatIntentRouter: getChatIntentRouter(),
|
||||
templateCatalogService: getTemplateCatalog(),
|
||||
}),
|
||||
],
|
||||
req,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
ensurePlanCatalogSchema,
|
||||
} from '../billing-subscription.mjs';
|
||||
import { resolveNormalizedRouterDecisionMode } from '../chat-intent-router.mjs';
|
||||
import {
|
||||
createPageTemplateCatalogService,
|
||||
ensureTemplateCatalogSchema,
|
||||
} from '../page-template-catalog.mjs';
|
||||
import {
|
||||
createSessionAccess,
|
||||
isSessionBrokerEnabled,
|
||||
@@ -37,7 +41,9 @@ export async function bootstrapPortalAuthServices({
|
||||
env = process.env,
|
||||
logger = console,
|
||||
ensurePlanCatalogSchemaFn = ensurePlanCatalogSchema,
|
||||
ensureTemplateCatalogSchemaFn = ensureTemplateCatalogSchema,
|
||||
createPlanCatalogServiceFn = createPlanCatalogService,
|
||||
createPageTemplateCatalogServiceFn = createPageTemplateCatalogService,
|
||||
createSubscriptionServiceFn = createSubscriptionService,
|
||||
createUserAuthFn = createUserAuth,
|
||||
createUserDataSpaceServiceFn = createUserDataSpaceService,
|
||||
@@ -69,6 +75,7 @@ export async function bootstrapPortalAuthServices({
|
||||
}
|
||||
|
||||
await ensurePlanCatalogSchemaFn(pool);
|
||||
await ensureTemplateCatalogSchemaFn(pool);
|
||||
const planCatalogService =
|
||||
createPlanCatalogServiceFn(pool);
|
||||
const subscriptionService =
|
||||
@@ -143,16 +150,23 @@ export async function bootstrapPortalAuthServices({
|
||||
const wechatPayClient = createWechatPayClientFn(
|
||||
loadWechatPayConfigFn(),
|
||||
);
|
||||
const templateCatalogService = createPageTemplateCatalogServiceFn(pool, {
|
||||
userAuth,
|
||||
h5Root,
|
||||
wechatPay: wechatPayClient,
|
||||
});
|
||||
await templateCatalogService.ensureReady();
|
||||
const rechargeService = createRechargeServiceFn(pool, {
|
||||
userAuth,
|
||||
wechatPay: wechatPayClient,
|
||||
templateCatalogService,
|
||||
});
|
||||
const wechatOAuthService =
|
||||
createWechatOAuthServiceFn(
|
||||
pool,
|
||||
loadWechatOAuthConfigFn(),
|
||||
{ userAuth },
|
||||
);
|
||||
const rechargeService = createRechargeServiceFn(pool, {
|
||||
userAuth,
|
||||
wechatPay: wechatPayClient,
|
||||
});
|
||||
if (wechatPayClient.enabled) {
|
||||
logger.log(
|
||||
`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`,
|
||||
@@ -173,5 +187,6 @@ export async function bootstrapPortalAuthServices({
|
||||
wechatPayClient,
|
||||
wechatOAuthService,
|
||||
rechargeService,
|
||||
templateCatalogService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs';
|
||||
import { ensureMindSpaceConfig, loadMindSpaceConfig, loadMindSpaceConfigCached } from '../mindspace-config.mjs';
|
||||
import { resolveMindSpaceRybbitConfig } from '../mindspace-rybbit.mjs';
|
||||
import { createMindSearchConfigService } from '../mindsearch-config.mjs';
|
||||
import { createMindSpaceService } from '../mindspace.mjs';
|
||||
@@ -47,6 +47,7 @@ export async function bootstrapPortalDomainServices({
|
||||
createMindSearchConfigService,
|
||||
ensureMindSpaceConfigFn = ensureMindSpaceConfig,
|
||||
loadMindSpaceConfigFn = loadMindSpaceConfig,
|
||||
loadMindSpaceConfigCachedFn = loadMindSpaceConfigCached,
|
||||
createScheduleServiceFn = createScheduleService,
|
||||
createScheduledTaskServiceFn = createScheduledTaskService,
|
||||
createPageDataServiceFn = createPageDataService,
|
||||
@@ -249,8 +250,14 @@ export async function bootstrapPortalDomainServices({
|
||||
plazaRedis,
|
||||
algorithmConfig: plazaAlgorithmConfig,
|
||||
recommendService: plazaRecommend,
|
||||
onPostPublished: (postId) =>
|
||||
plazaSeo?.notifyPostPublished(postId),
|
||||
onPostPublished: async (postId) => {
|
||||
if (!plazaSeo) return { pushed: false, reason: 'plaza_unavailable' };
|
||||
const config = await loadMindSpaceConfigCachedFn(pool, { env }).catch(() => null);
|
||||
if (!config?.seoGeo?.enabled || !config?.seoGeo.seo?.baiduPush) {
|
||||
return { pushed: false, reason: 'disabled' };
|
||||
}
|
||||
return plazaSeo.notifyPostPublished(postId);
|
||||
},
|
||||
loadFeaturedPosts: async (viewerId) => {
|
||||
if (!plazaOps) {
|
||||
return {
|
||||
|
||||
@@ -115,6 +115,20 @@ function createBootstrapSetup(overrides = {}) {
|
||||
websiteId: 'website-1',
|
||||
idSecret: 'secret-1',
|
||||
},
|
||||
seoGeo: {
|
||||
enabled: true,
|
||||
seo: { enabled: true, baiduPush: true },
|
||||
geo: { enabled: false },
|
||||
},
|
||||
};
|
||||
},
|
||||
async loadMindSpaceConfigCachedFn() {
|
||||
calls.push({ kind: 'load-mindspace-config-cached' });
|
||||
return {
|
||||
seoGeo: {
|
||||
enabled: true,
|
||||
seo: { enabled: true, baiduPush: true },
|
||||
},
|
||||
};
|
||||
},
|
||||
createScheduleServiceFn(receivedPool, options) {
|
||||
@@ -396,7 +410,7 @@ test('domain bootstrap preserves directory and Plaza cross-service callbacks', a
|
||||
),
|
||||
{ viewerId: 'viewer-1' },
|
||||
);
|
||||
setup.captured.plazaPostOptions.onPostPublished('post-1');
|
||||
await setup.captured.plazaPostOptions.onPostPublished('post-1');
|
||||
await setup.captured.plazaPostOptions.loadViewerReactions(
|
||||
'viewer-1',
|
||||
['post-1'],
|
||||
|
||||
@@ -45,6 +45,13 @@ import {
|
||||
removeQueryParam,
|
||||
resolveRequestOrigin,
|
||||
} from './portal-publication-shell.mjs';
|
||||
import {
|
||||
decorateMindSpaceSeoGeoHtml,
|
||||
applySeoGeoResponseHeaders,
|
||||
} from '../mindspace-seo-geo-delivery.mjs';
|
||||
import {
|
||||
loadMindSpaceConfigCached,
|
||||
} from '../mindspace-config.mjs';
|
||||
|
||||
async function decoratePublicationHtmlAnalytics(
|
||||
html,
|
||||
@@ -138,6 +145,7 @@ export function createPortalPublishedPageDelivery({
|
||||
getAuthPool = () => null,
|
||||
getUserAuth = () => null,
|
||||
getMindSpacePages = () => null,
|
||||
getMindSpaceConfig = loadMindSpaceConfigCached,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
if (
|
||||
@@ -194,6 +202,24 @@ export function createPortalPublishedPageDelivery({
|
||||
pageUrl.lastIndexOf('/') + 1,
|
||||
)
|
||||
: '';
|
||||
const mindSpaceConfig = getAuthPool()
|
||||
? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } }))
|
||||
: { seoGeo: { enabled: false } };
|
||||
const seoGeoConfig = mindSpaceConfig?.seoGeo ?? null;
|
||||
const publicationSnapshot = result?.publication ?? null;
|
||||
const applySeoGeo = (htmlInput, { innerHtml = null, meta = {} } = {}) =>
|
||||
decorateMindSpaceSeoGeoHtml(htmlInput, {
|
||||
seoGeoConfig,
|
||||
publication: publicationSnapshot,
|
||||
embed,
|
||||
context: {
|
||||
origin,
|
||||
pageUrl,
|
||||
pageDirUrl,
|
||||
meta,
|
||||
},
|
||||
innerHtml,
|
||||
});
|
||||
const wechatShare =
|
||||
!embed &&
|
||||
isWechatUserAgent(
|
||||
@@ -241,6 +267,17 @@ export function createPortalPublishedPageDelivery({
|
||||
// Never block delivery on share metadata.
|
||||
}
|
||||
}
|
||||
let robotsHeader = null;
|
||||
if (!embed) {
|
||||
try {
|
||||
const shareMeta = extractShareMetaFromPageHtml(html);
|
||||
const seoGeoResult = applySeoGeo(html, { meta: shareMeta });
|
||||
html = seoGeoResult.html;
|
||||
robotsHeader = seoGeoResult.robotsHeader;
|
||||
} catch {
|
||||
// Never block delivery on SEO/GEO metadata.
|
||||
}
|
||||
}
|
||||
const isFullHtml =
|
||||
/^\s*<!doctype html/i.test(html) ||
|
||||
/^\s*<html[\s>]/i.test(html);
|
||||
@@ -356,6 +393,20 @@ export function createPortalPublishedPageDelivery({
|
||||
getMindSpacePages,
|
||||
logger,
|
||||
});
|
||||
if (!embed) {
|
||||
try {
|
||||
const shareMeta = extractShareMetaFromPageHtml(html);
|
||||
const shellSeoGeo = applySeoGeo(shellHtml, {
|
||||
innerHtml: html,
|
||||
meta: shareMeta,
|
||||
});
|
||||
shellHtml = shellSeoGeo.html;
|
||||
robotsHeader = shellSeoGeo.robotsHeader ?? robotsHeader;
|
||||
} catch {
|
||||
// Keep the share shell usable.
|
||||
}
|
||||
}
|
||||
applySeoGeoResponseHeaders(res, robotsHeader);
|
||||
res.set(
|
||||
'Content-Type',
|
||||
'text/html; charset=utf-8',
|
||||
@@ -375,6 +426,7 @@ export function createPortalPublishedPageDelivery({
|
||||
);
|
||||
return res.send(shellHtml);
|
||||
}
|
||||
applySeoGeoResponseHeaders(res, robotsHeader);
|
||||
res.set(
|
||||
'Content-Type',
|
||||
'text/html; charset=utf-8',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
function assertRouter(api) {
|
||||
if (!api || typeof api.get !== 'function') {
|
||||
throw new Error('attachPortalSeoDiscoveryRoutes requires an Express-compatible router');
|
||||
}
|
||||
}
|
||||
|
||||
export function attachPortalSeoDiscoveryRoutes(
|
||||
app,
|
||||
{
|
||||
getAuthPool = () => null,
|
||||
getMindSpaceConfig = async () => ({ seoGeo: { enabled: false } }),
|
||||
getSeoDiscoveryService = () => null,
|
||||
resolveRequestOrigin = () => '',
|
||||
} = {},
|
||||
) {
|
||||
assertRouter(app);
|
||||
|
||||
app.get('/robots.txt', async (req, res) => {
|
||||
const config = await getMindSpaceConfig();
|
||||
const seoGeo = config?.seoGeo ?? {};
|
||||
if (!seoGeo.enabled || !seoGeo.seo?.robotsTxt) {
|
||||
return res.type('text/plain; charset=utf-8').send('User-agent: *\nDisallow: /\n');
|
||||
}
|
||||
const service = getSeoDiscoveryService();
|
||||
if (!service) {
|
||||
return res.status(503).type('text/plain; charset=utf-8').send('SEO discovery unavailable\n');
|
||||
}
|
||||
const origin = resolveRequestOrigin(req);
|
||||
return res
|
||||
.type('text/plain; charset=utf-8')
|
||||
.set('Cache-Control', 'public, max-age=300')
|
||||
.send(
|
||||
service.renderRobotsTxt({
|
||||
origin,
|
||||
sitemapEnabled: Boolean(seoGeo.seo?.sitemap),
|
||||
llmsTxtEnabled: Boolean(seoGeo.geo?.llmsTxt),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/sitemap.xml', async (req, res) => {
|
||||
const config = await getMindSpaceConfig();
|
||||
const seoGeo = config?.seoGeo ?? {};
|
||||
if (!seoGeo.enabled || !seoGeo.seo?.enabled || !seoGeo.seo?.sitemap) {
|
||||
return res.status(404).type('text/plain; charset=utf-8').send('Not Found\n');
|
||||
}
|
||||
const pool = getAuthPool();
|
||||
const service = getSeoDiscoveryService();
|
||||
if (!pool || !service) {
|
||||
return res.status(503).type('text/plain; charset=utf-8').send('SEO discovery unavailable\n');
|
||||
}
|
||||
const origin = resolveRequestOrigin(req);
|
||||
const entries = await service.listIndexablePublications(pool, {
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
return res
|
||||
.type('application/xml; charset=utf-8')
|
||||
.set('Cache-Control', 'public, max-age=300')
|
||||
.send(service.renderSitemapXml(entries, { origin }));
|
||||
});
|
||||
|
||||
app.get('/llms.txt', async (req, res) => {
|
||||
const config = await getMindSpaceConfig();
|
||||
const seoGeo = config?.seoGeo ?? {};
|
||||
if (!seoGeo.enabled || !seoGeo.geo?.enabled || !seoGeo.geo?.llmsTxt) {
|
||||
return res.status(404).type('text/plain; charset=utf-8').send('Not Found\n');
|
||||
}
|
||||
const pool = getAuthPool();
|
||||
const service = getSeoDiscoveryService();
|
||||
if (!pool || !service) {
|
||||
return res.status(503).type('text/plain; charset=utf-8').send('GEO discovery unavailable\n');
|
||||
}
|
||||
const origin = resolveRequestOrigin(req);
|
||||
const entries = await service.listIndexablePublications(pool, {
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
return res
|
||||
.type('text/plain; charset=utf-8')
|
||||
.set('Cache-Control', 'public, max-age=300')
|
||||
.send(service.renderLlmsTxt(entries, { origin }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import express from 'express';
|
||||
import { attachPortalSeoDiscoveryRoutes } from './portal-seo-discovery-routes.mjs';
|
||||
|
||||
function createApp(deps) {
|
||||
const app = express();
|
||||
attachPortalSeoDiscoveryRoutes(app, deps);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getText(app, path) {
|
||||
const server = app.listen(0);
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
||||
const body = await response.text();
|
||||
return { status: response.status, body };
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
test('GET /robots.txt returns closed policy when disabled', async () => {
|
||||
const app = createApp({
|
||||
getMindSpaceConfig: async () => ({ seoGeo: { enabled: false } }),
|
||||
});
|
||||
const result = await getText(app, '/robots.txt');
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /Disallow: \//);
|
||||
});
|
||||
|
||||
test('GET /sitemap.xml returns 404 when sitemap disabled', async () => {
|
||||
const app = createApp({
|
||||
getMindSpaceConfig: async () => ({
|
||||
seoGeo: { enabled: true, seo: { enabled: true, sitemap: false } },
|
||||
}),
|
||||
});
|
||||
const result = await getText(app, '/sitemap.xml');
|
||||
assert.equal(result.status, 404);
|
||||
});
|
||||
|
||||
test('GET /sitemap.xml renders xml when enabled', async () => {
|
||||
const app = createApp({
|
||||
getAuthPool: () => ({}),
|
||||
getMindSpaceConfig: async () => ({
|
||||
seoGeo: { enabled: true, seo: { enabled: true, sitemap: true } },
|
||||
}),
|
||||
getSeoDiscoveryService: () => ({
|
||||
listIndexablePublications: async () => [
|
||||
{ publicUrl: '/u/john/pages/demo', updatedAt: Date.now() },
|
||||
],
|
||||
renderSitemapXml: (entries, ctx) =>
|
||||
`<urlset origin="${ctx.origin}">${entries.length}</urlset>`,
|
||||
}),
|
||||
resolveRequestOrigin: () => 'https://m.tkmind.cn',
|
||||
});
|
||||
const result = await getText(app, '/sitemap.xml');
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /origin="https:\/\/m\.tkmind\.cn">1<\/urlset>/);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
function assertRouter(api) {
|
||||
if (!api || typeof api.get !== 'function' || typeof api.post !== 'function') {
|
||||
throw new Error('attachPortalTemplateCatalogRoutes requires an Express-compatible router');
|
||||
}
|
||||
}
|
||||
|
||||
export function attachPortalTemplateCatalogRoutes(
|
||||
api,
|
||||
{
|
||||
getTemplateCatalog = () => null,
|
||||
ensureMindSpaceEnabled = () => false,
|
||||
sendData = (res, _req, data) => res.json({ data }),
|
||||
sendError = (res, _req, status, code, message) =>
|
||||
res.status(status).json({ error: { code, message } }),
|
||||
} = {},
|
||||
) {
|
||||
assertRouter(api);
|
||||
|
||||
api.get('/mindspace/v1/template-catalog', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const items = await catalog.listCatalogForUser(req.currentUser.id);
|
||||
return sendData(res, req, { items });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '读取模板商城失败';
|
||||
return sendError(res, req, 500, 'template_catalog_error', message);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/mindspace/v1/template-catalog/mine', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const items = await catalog.listMine(req.currentUser.id);
|
||||
return sendData(res, req, { items });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '读取已购模板失败';
|
||||
return sendError(res, req, 500, 'template_catalog_error', message);
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/mindspace/v1/template-catalog/:skillName/purchase', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const result = await catalog.purchaseWithBalance(req.currentUser.id, req.params.skillName);
|
||||
if (!result.ok) {
|
||||
if (result.code === 'INSUFFICIENT_BALANCE') {
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
code: result.code,
|
||||
message: result.message,
|
||||
},
|
||||
details: {
|
||||
balanceCents: result.balanceCents,
|
||||
minRechargeCents: result.minRechargeCents,
|
||||
suggestedTiers: result.suggestedTiers,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sendError(res, req, 400, 'template_purchase_failed', result.message);
|
||||
}
|
||||
return sendData(res, req, result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '购买模板失败';
|
||||
return sendError(res, req, 500, 'template_purchase_error', message);
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/mindspace/v1/template-catalog/:skillName/favorite', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const result = await catalog.toggleTemplateFavorite(req.currentUser.id, req.params.skillName);
|
||||
if (!result.ok) {
|
||||
return sendError(res, req, 400, 'template_favorite_failed', result.message);
|
||||
}
|
||||
return sendData(res, req, result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '收藏操作失败';
|
||||
return sendError(res, req, 500, 'template_favorite_error', message);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/mindspace/v1/template-catalog/:skillName/preview', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const result = await catalog.getTemplatePreviewHtml(req.params.skillName);
|
||||
if (!result.ok) {
|
||||
return sendError(res, req, 404, 'template_preview_not_found', result.message);
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
return res.send(result.html);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '读取模板预览失败';
|
||||
return sendError(res, req, 500, 'template_preview_error', message);
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/mindspace/v1/template-catalog/:skillName/checkout', async (req, res) => {
|
||||
const catalog = getTemplateCatalog();
|
||||
if (!catalog || !ensureMindSpaceEnabled(res, req)) return;
|
||||
try {
|
||||
const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene)
|
||||
? req.body.payScene
|
||||
: 'native';
|
||||
const result = await catalog.createWechatCheckout({
|
||||
userId: req.currentUser.id,
|
||||
skillName: req.params.skillName,
|
||||
payScene,
|
||||
clientIp: req.ip,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return sendError(res, req, 400, 'template_checkout_failed', result.message);
|
||||
}
|
||||
return sendData(res, req, result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '创建模板支付订单失败';
|
||||
return sendError(res, req, 500, 'template_checkout_error', message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import test from 'node:test';
|
||||
import { attachPortalTemplateCatalogRoutes } from './portal-template-catalog-routes.mjs';
|
||||
|
||||
function createApp({ catalog, enabled = true }) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.currentUser = { id: 'user-1' };
|
||||
next();
|
||||
});
|
||||
attachPortalTemplateCatalogRoutes(app, {
|
||||
getTemplateCatalog: () => catalog,
|
||||
ensureMindSpaceEnabled: (res) => {
|
||||
if (!enabled) {
|
||||
res.status(503).json({ error: { code: 'disabled', message: 'disabled' } });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function request(app, method, url, body) {
|
||||
const server = app.listen(0);
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const json = await response.json();
|
||||
return { status: response.status, json };
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
test('GET /mindspace/v1/template-catalog returns catalog items', async () => {
|
||||
const app = createApp({
|
||||
catalog: {
|
||||
async listCatalogForUser(userId) {
|
||||
assert.equal(userId, 'user-1');
|
||||
return [{ skillName: 'page-template-travel', owned: false, priceCents: 990 }];
|
||||
},
|
||||
},
|
||||
});
|
||||
const { status, json } = await request(app, 'GET', '/mindspace/v1/template-catalog');
|
||||
assert.equal(status, 200);
|
||||
assert.equal(json.data.items[0].skillName, 'page-template-travel');
|
||||
});
|
||||
|
||||
test('GET template preview returns html', async () => {
|
||||
const app = createApp({
|
||||
catalog: {
|
||||
async getTemplatePreviewHtml(skillName) {
|
||||
assert.equal(skillName, 'page-template-travel');
|
||||
return { ok: true, html: '<!DOCTYPE html><title>preview</title>' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const server = app.listen(0);
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/mindspace/v1/template-catalog/page-template-travel/preview`);
|
||||
const html = await response.text();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(html, /preview/);
|
||||
assert.match(response.headers.get('content-type') ?? '', /text\/html/);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test('POST purchase returns 402 on insufficient balance', async () => {
|
||||
const app = createApp({
|
||||
catalog: {
|
||||
async purchaseWithBalance(userId, skillName) {
|
||||
assert.equal(userId, 'user-1');
|
||||
assert.equal(skillName, 'page-template-travel');
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
message: '余额不足',
|
||||
balanceCents: 100,
|
||||
minRechargeCents: 500,
|
||||
suggestedTiers: [500],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const { status, json } = await request(
|
||||
app,
|
||||
'POST',
|
||||
'/mindspace/v1/template-catalog/page-template-travel/purchase',
|
||||
);
|
||||
assert.equal(status, 402);
|
||||
assert.equal(json.error.code, 'INSUFFICIENT_BALANCE');
|
||||
});
|
||||
@@ -40,6 +40,15 @@ import {
|
||||
isLongImageDownloadRequest,
|
||||
} from '../mindspace-long-image.mjs';
|
||||
import { isWechatUserAgent } from '../wechat-oauth.mjs';
|
||||
import {
|
||||
applySeoGeoResponseHeaders,
|
||||
} from '../mindspace-seo-geo-delivery.mjs';
|
||||
import {
|
||||
loadMindSpaceConfigCached,
|
||||
} from '../mindspace-config.mjs';
|
||||
import {
|
||||
resolvePublicationIndexSnapshot,
|
||||
} from '../mindspace-seo-discovery-service.mjs';
|
||||
|
||||
function deliveryNotFoundMessage(reason) {
|
||||
if (reason === 'missing_owner_dir' || reason === 'missing_owner') {
|
||||
@@ -65,6 +74,8 @@ export function createPortalWorkspacePublicationDelivery({
|
||||
logger = console,
|
||||
resolvePageDataContext =
|
||||
resolveMindSpacePageDataContext,
|
||||
getMindSpaceConfig = loadMindSpaceConfigCached,
|
||||
resolvePublicationSnapshot = resolvePublicationIndexSnapshot,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof resolveRequestOrigin !==
|
||||
@@ -273,6 +284,20 @@ export function createPortalWorkspacePublicationDelivery({
|
||||
'',
|
||||
config: rybbitConfig,
|
||||
});
|
||||
const mindSpaceConfig = getAuthPool()
|
||||
? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } }))
|
||||
: { seoGeo: { enabled: false } };
|
||||
let publicationSnapshot = null;
|
||||
if (mindSpaceConfig?.seoGeo?.enabled && getAuthPool()) {
|
||||
publicationSnapshot = await resolvePublicationSnapshot(getAuthPool(), {
|
||||
publicationId:
|
||||
pageDataContext?.publicationId ??
|
||||
pageDataContext?.publication_id ??
|
||||
null,
|
||||
pageId: pageDataContext?.pageId ?? null,
|
||||
userId: delivery.ownerId ?? null,
|
||||
}).catch(() => null);
|
||||
}
|
||||
const decorated =
|
||||
decorateMindSpacePublishedHtml({
|
||||
html,
|
||||
@@ -288,11 +313,14 @@ export function createPortalWorkspacePublicationDelivery({
|
||||
injectPublicFileShareButton,
|
||||
publishedPageCsp,
|
||||
isWechatUserAgent,
|
||||
seoGeoConfig: mindSpaceConfig?.seoGeo ?? null,
|
||||
publicationSnapshot,
|
||||
});
|
||||
html = decorated.html;
|
||||
if (decorated.allowEmbedFrame) {
|
||||
allowPlazaEmbedFrame(res);
|
||||
}
|
||||
applySeoGeoResponseHeaders(res, decorated.robotsHeader);
|
||||
res.set(
|
||||
'Content-Security-Policy',
|
||||
decorated.csp,
|
||||
|
||||
Reference in New Issue
Block a user