Skip to content

Commit c11f329

Browse files
authored
Merge pull request #98 from rebase-network/dev
Dev
2 parents 90b21b0 + 721243c commit c11f329

18 files changed

Lines changed: 1106 additions & 271 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
1+
# Runtime
12
NODE_ENV=development
23
APP_VERSION=dev
34
PORT=8788
5+
API_HOST_PORT=8788
46

7+
# Database
58
DATABASE_URL=postgresql://rebase:rebase@127.0.0.1:55433/rebase
69

10+
# Auth
711
BETTER_AUTH_SECRET=replace-with-a-long-random-string
812
BETTER_AUTH_URL=http://127.0.0.1:8788
913
CORS_ALLOWED_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:4321,http://localhost:4321
1014

15+
# Admin bootstrap
1116
DEV_ADMIN_EMAIL=admin@rebase.local
1217
DEV_ADMIN_PASSWORD=RebaseAdmin123456!
1318
DEV_ADMIN_NAME=Rebase Super Admin
1419

20+
# Public site and frontend runtime
1521
VITE_API_BASE_URL=http://127.0.0.1:8788
1622
VITE_PUBLIC_SITE_BASE_URL=https://rebase.network
1723
API_BASE_URL=http://127.0.0.1:8788
1824
SITE_URL=https://rebase.network
1925

26+
# Cloudflare R2
2027
R2_ACCOUNT_ID=
2128
R2_ACCESS_KEY_ID=
2229
R2_SECRET_ACCESS_KEY=
2330
R2_BUCKET=rebase-media
2431
R2_PUBLIC_BASE_URL=
2532
R2_DEV_USE_WRANGLER=false
33+
34+
# WeChat Official Account
35+
WECHAT_OFFICIAL_APP_ID=
36+
WECHAT_OFFICIAL_APP_SECRET=
37+
WECHAT_DEFAULT_THUMB_MEDIA_ID=

.github/workflows/ci.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ jobs:
1919

2020
- name: Setup pnpm
2121
uses: pnpm/action-setup@v4
22-
with:
23-
version: 10.6.5
2422

2523
- name: Setup Node.js
2624
uses: actions/setup-node@v4

.github/workflows/smoke.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ jobs:
1212

1313
- name: Setup pnpm
1414
uses: pnpm/action-setup@v4
15-
with:
16-
version: 10.6.5
1715

1816
- name: Setup Node.js
1917
uses: actions/setup-node@v4

apps/admin/src/lib/format.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ export const formatAuditSummary = (summary: string) => {
263263
[/^Archived article (.+)$/i, (m) => `已归档文章 ${m[1]}`],
264264
[/^Created GeekDaily episode (.+)$/i, (m) => `已创建极客日报第 ${m[1]} 期`],
265265
[/^Updated GeekDaily episode (.+)$/i, (m) => `已更新极客日报第 ${m[1]} 期`],
266+
[/^Created GeekDaily WeChat draft (.+)$/i, (m) => `已创建极客日报第 ${m[1]} 期微信公众号草稿`],
266267
[/^Published GeekDaily episode (.+)$/i, (m) => `已发布极客日报第 ${m[1]} 期`],
267268
[/^Archived GeekDaily episode (.+)$/i, (m) => `已归档极客日报第 ${m[1]} 期`],
268269
[/^Created asset (.+)$/i, (m) => `已创建媒体记录 ${m[1]}`],

apps/admin/src/lib/geekdaily-wechat.ts

Lines changed: 5 additions & 264 deletions
Large diffs are not rendered by default.

apps/api/src/lib/env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export interface AppEnv {
1111
r2Bucket: string;
1212
r2PublicBaseUrl: string;
1313
r2DevUseWrangler: boolean;
14+
wechatOfficialAppId: string;
15+
wechatOfficialAppSecret: string;
16+
wechatDefaultThumbMediaId: string;
1417
}
1518

1619
let envCache: AppEnv | null = null;
@@ -49,6 +52,9 @@ export const getEnv = (): AppEnv => {
4952
r2Bucket: process.env.R2_BUCKET ?? 'rebase-media',
5053
r2PublicBaseUrl: process.env.R2_PUBLIC_BASE_URL ?? '',
5154
r2DevUseWrangler: parseBoolean(process.env.R2_DEV_USE_WRANGLER, false),
55+
wechatOfficialAppId: process.env.WECHAT_OFFICIAL_APP_ID ?? '',
56+
wechatOfficialAppSecret: process.env.WECHAT_OFFICIAL_APP_SECRET ?? '',
57+
wechatDefaultThumbMediaId: process.env.WECHAT_DEFAULT_THUMB_MEDIA_ID ?? '',
5258
};
5359

5460
return envCache;

apps/api/src/lib/geekdaily.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
22

33
import { geekdailyEpisodeItems, geekdailyEpisodes } from '@rebase/db';
44
import {
5+
buildGeekDailyWechatHtml,
56
buildGeekDailyBodyMarkdown,
67
buildGeekDailySummary,
78
extractGeekDailyBodyNote,
9+
getGeekDailyEpisodePath,
810
getGeekDailyEpisodeSlug,
11+
getGeekDailyWechatGenerationIssue,
12+
type AdminGeekDailyWechatDraftRecord,
913
type AdminGeekDailyListItem,
1014
type ContentStatus,
1115
type GeekDailyEpisodeInput,
@@ -14,10 +18,13 @@ import {
1418

1519
import { createAuditEntry, type AuditActor } from './audit.js';
1620
import { getDb } from './db.js';
17-
import { badRequest, notFound } from './errors.js';
21+
import { getEnv } from './env.js';
22+
import { badRequest, notFound, serviceUnavailable } from './errors.js';
1823
import { buildPaginatedMeta, resolvePagination, type PaginationInput } from './pagination.js';
1924
import { combineFilters, toContainsPattern } from './query-filters.js';
25+
import { getPublicSiteConfig } from './site.js';
2026
import { toIsoString } from './utils.js';
27+
import { createWechatOfficialDraft } from './wechat-official.js';
2128

2229
const mapEpisodeItem = (row: typeof geekdailyEpisodeItems.$inferSelect) => ({
2330
title: row.title,
@@ -220,6 +227,18 @@ const resolveGeekDailyPublishedAt = (status: ContentStatus, currentPublishedAt?:
220227
return currentPublishedAt ? coerceDate(currentPublishedAt) : new Date();
221228
};
222229

230+
const withBaseUrl = (baseUrl: string, pathname: string) => new URL(pathname, baseUrl).toString();
231+
232+
const truncateByCodePoints = (value: string, maxLength: number) => Array.from(value).slice(0, maxLength).join('');
233+
234+
const buildWechatDraftTitle = (record: ReturnType<typeof mapEpisodeDetail>) =>
235+
truncateByCodePoints((record.title.trim() || `极客日报#${record.episodeNumber}`).trim(), 32);
236+
237+
const buildWechatDraftAuthor = (editors: string[]) => truncateByCodePoints((editors.join('、') || 'Rebase').trim(), 16);
238+
239+
const buildWechatDraftDigest = (record: ReturnType<typeof mapEpisodeDetail>) =>
240+
truncateByCodePoints((record.summary.trim() || buildGeekDailySummary({ items: record.items })).trim(), 128);
241+
223242
const mapEpisodeDetail = (row: any, items: any[]) => ({
224243
id: row.id,
225244
slug: getGeekDailyEpisodeSlug(row.episodeNumber),
@@ -518,6 +537,79 @@ export const archiveAdminGeekDailyEpisode = async (id: string, actor: AuditActor
518537
return getAdminGeekDailyEpisode(id);
519538
};
520539

540+
export const createAdminGeekDailyWechatDraft = async (
541+
id: string,
542+
actor: AuditActor,
543+
): Promise<AdminGeekDailyWechatDraftRecord> => {
544+
const current = await getAdminGeekDailyEpisode(id);
545+
if (!current) {
546+
throw notFound('GeekDaily episode not found');
547+
}
548+
549+
if (current.status !== 'published') {
550+
throw badRequest('only published GeekDaily episodes can create a WeChat draft');
551+
}
552+
553+
const editorName = current.editors.length > 0 ? current.editors.join('、') : 'Rebase';
554+
const wechatInput = {
555+
episodeNumber: current.episodeNumber,
556+
editorName,
557+
bodyMarkdown: extractGeekDailyBodyNote(current.bodyMarkdown),
558+
items: current.items,
559+
};
560+
const issue = getGeekDailyWechatGenerationIssue(wechatInput);
561+
if (issue) {
562+
throw badRequest('wechat draft generation failed validation', { issue });
563+
}
564+
565+
const content = buildGeekDailyWechatHtml(wechatInput);
566+
if (!content) {
567+
throw badRequest('wechat draft content is empty');
568+
}
569+
570+
const site = await getPublicSiteConfig();
571+
const title = buildWechatDraftTitle(current);
572+
const author = buildWechatDraftAuthor(current.editors);
573+
const digest = buildWechatDraftDigest(current);
574+
575+
const thumbMediaId = getEnv().wechatDefaultThumbMediaId.trim();
576+
if (!thumbMediaId) {
577+
throw serviceUnavailable('wechat default thumb media id is not configured', {
578+
missing: ['WECHAT_DEFAULT_THUMB_MEDIA_ID'],
579+
});
580+
}
581+
582+
const contentSourceUrl = withBaseUrl(site.primaryDomain, getGeekDailyEpisodePath(current.episodeNumber));
583+
const { mediaId } = await createWechatOfficialDraft({
584+
title,
585+
author,
586+
digest,
587+
content,
588+
contentSourceUrl,
589+
thumbMediaId,
590+
});
591+
592+
await createAuditEntry({
593+
...actor,
594+
action: 'geekdaily.wechat_draft.create',
595+
targetType: 'geekdaily_episode',
596+
targetId: id,
597+
summary: `Created GeekDaily WeChat draft ${current.episodeNumber}`,
598+
});
599+
600+
return {
601+
episodeId: id,
602+
episodeNumber: current.episodeNumber,
603+
title,
604+
author,
605+
digest,
606+
mediaId,
607+
thumbMediaId,
608+
contentSourceUrl,
609+
itemCount: current.items.length,
610+
};
611+
};
612+
521613
export const listPublicGeekDailyEpisodes = async (limit = -1) => {
522614
return withPublicGeekDailyCache(`episodes:${limit}`, async () => {
523615
const db = getDb();
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { getEnv } from './env.js';
2+
import { serviceUnavailable } from './errors.js';
3+
4+
interface WechatApiPayload {
5+
errcode?: number;
6+
errmsg?: string;
7+
}
8+
9+
interface WechatAccessTokenPayload extends WechatApiPayload {
10+
access_token?: string;
11+
expires_in?: number;
12+
}
13+
14+
interface WechatDraftAddPayload extends WechatApiPayload {
15+
media_id?: string;
16+
}
17+
18+
export interface WechatNewsDraftInput {
19+
title: string;
20+
author: string;
21+
digest: string;
22+
content: string;
23+
contentSourceUrl: string;
24+
thumbMediaId: string;
25+
}
26+
27+
let accessTokenCache: { appId: string; accessToken: string; expiresAt: number } | null = null;
28+
29+
const requestTimeoutMs = 15_000;
30+
const accessTokenRefreshBufferMs = 5 * 60 * 1000;
31+
32+
const createWechatApiError = (stage: string, payload: unknown) =>
33+
serviceUnavailable(`wechat official account ${stage} failed`, {
34+
stage,
35+
payload,
36+
});
37+
38+
const parseJson = async <T>(response: Response): Promise<T> => {
39+
const text = await response.text();
40+
41+
try {
42+
return JSON.parse(text) as T;
43+
} catch {
44+
throw createWechatApiError('response parsing', {
45+
status: response.status,
46+
body: text,
47+
});
48+
}
49+
};
50+
51+
const getWechatCredentials = () => {
52+
const env = getEnv();
53+
const appId = env.wechatOfficialAppId.trim();
54+
const appSecret = env.wechatOfficialAppSecret.trim();
55+
const missing = [
56+
!appId && 'WECHAT_OFFICIAL_APP_ID',
57+
!appSecret && 'WECHAT_OFFICIAL_APP_SECRET',
58+
].filter(Boolean);
59+
60+
if (missing.length > 0) {
61+
throw serviceUnavailable('wechat official account is not configured', { missing });
62+
}
63+
64+
return { appId, appSecret };
65+
};
66+
67+
const fetchWechatAccessToken = async () => {
68+
const { appId, appSecret } = getWechatCredentials();
69+
const cached = accessTokenCache;
70+
71+
if (cached && cached.appId === appId && cached.expiresAt > Date.now()) {
72+
return cached.accessToken;
73+
}
74+
75+
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
76+
url.searchParams.set('grant_type', 'client_credential');
77+
url.searchParams.set('appid', appId);
78+
url.searchParams.set('secret', appSecret);
79+
80+
let response: Response;
81+
try {
82+
response = await fetch(url, {
83+
signal: AbortSignal.timeout(requestTimeoutMs),
84+
});
85+
} catch (error) {
86+
throw createWechatApiError('access token request', {
87+
message: error instanceof Error ? error.message : String(error),
88+
});
89+
}
90+
91+
const payload = await parseJson<WechatAccessTokenPayload>(response);
92+
if (!response.ok || payload.errcode || !payload.access_token || !payload.expires_in) {
93+
throw createWechatApiError('access token response', payload);
94+
}
95+
96+
accessTokenCache = {
97+
appId,
98+
accessToken: payload.access_token,
99+
expiresAt: Date.now() + payload.expires_in * 1000 - accessTokenRefreshBufferMs,
100+
};
101+
102+
return payload.access_token;
103+
};
104+
105+
export const createWechatOfficialDraft = async (input: WechatNewsDraftInput) => {
106+
const accessToken = await fetchWechatAccessToken();
107+
const url = new URL('https://api.weixin.qq.com/cgi-bin/draft/add');
108+
url.searchParams.set('access_token', accessToken);
109+
110+
const body = {
111+
articles: [
112+
{
113+
article_type: 'news',
114+
title: input.title,
115+
author: input.author,
116+
digest: input.digest,
117+
content: input.content,
118+
content_source_url: input.contentSourceUrl,
119+
thumb_media_id: input.thumbMediaId,
120+
},
121+
],
122+
};
123+
124+
let response: Response;
125+
try {
126+
response = await fetch(url, {
127+
method: 'POST',
128+
headers: {
129+
'content-type': 'application/json',
130+
},
131+
body: JSON.stringify(body),
132+
signal: AbortSignal.timeout(requestTimeoutMs),
133+
});
134+
} catch (error) {
135+
throw createWechatApiError('draft request', {
136+
message: error instanceof Error ? error.message : String(error),
137+
});
138+
}
139+
140+
const payload = await parseJson<WechatDraftAddPayload>(response);
141+
if (!response.ok || payload.errcode || !payload.media_id) {
142+
throw createWechatApiError('draft response', payload);
143+
}
144+
145+
return {
146+
mediaId: payload.media_id,
147+
};
148+
};

apps/api/src/routes/admin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { createAdminArticle, getAdminArticle, listAdminArticles, publishAdminArt
2626
import { createAdminAsset, deleteAdminAsset, getAdminAsset, getAdminAssetUploadConfig, listAdminAssets, updateAdminAsset, uploadAdminAsset } from '../lib/assets.js';
2727
import { createAdminContributor, createAdminContributorRole, getAdminContributor, listAdminContributorRoles, listAdminContributors, updateAdminContributor, updateAdminContributorRole } from '../lib/contributors.js';
2828
import { createAdminEvent, getAdminEvent, listAdminEvents, publishAdminEvent, updateAdminEvent, archiveAdminEvent } from '../lib/events.js';
29-
import { createAdminGeekDailyEpisode, getAdminGeekDailyEpisode, listAdminGeekDailyEpisodes, publishAdminGeekDailyEpisode, updateAdminGeekDailyEpisode, archiveAdminGeekDailyEpisode } from '../lib/geekdaily.js';
29+
import { createAdminGeekDailyEpisode, createAdminGeekDailyWechatDraft, getAdminGeekDailyEpisode, listAdminGeekDailyEpisodes, publishAdminGeekDailyEpisode, updateAdminGeekDailyEpisode, archiveAdminGeekDailyEpisode } from '../lib/geekdaily.js';
3030
import { badRequest } from '../lib/errors.js';
3131
import { handleApiError, jsonError, ok } from '../lib/http.js';
3232
import { createAdminJob, getAdminJob, listAdminJobs, publishAdminJob, updateAdminJob, archiveAdminJob } from '../lib/jobs.js';
@@ -217,6 +217,9 @@ adminRoutes.patch('/geekdaily/:id', requireActiveStaff('geekdaily.write'), async
217217
const payload = expectValid(c, validateGeekDailyEpisodeInput(await c.req.json().catch(() => null)));
218218
return c.json(ok(await updateAdminGeekDailyEpisode(c.req.param('id'), payload, getAuditActor(c))));
219219
});
220+
adminRoutes.post('/geekdaily/:id/wechat-draft', requireActiveStaff('geekdaily.publish'), async (c) =>
221+
c.json(ok(await createAdminGeekDailyWechatDraft(c.req.param('id'), getAuditActor(c)))),
222+
);
220223
adminRoutes.post('/geekdaily/:id/publish', requireActiveStaff('geekdaily.publish'), async (c) => c.json(ok(await publishAdminGeekDailyEpisode(c.req.param('id'), getAuditActor(c)))));
221224
adminRoutes.post('/geekdaily/:id/archive', requireActiveStaff('geekdaily.publish'), async (c) => c.json(ok(await archiveAdminGeekDailyEpisode(c.req.param('id'), getAuditActor(c)))));
222225

infra/production/docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ services:
4444
R2_BUCKET: ${R2_BUCKET:-rebase-media}
4545
R2_PUBLIC_BASE_URL: ${R2_PUBLIC_BASE_URL:-}
4646
R2_DEV_USE_WRANGLER: ${R2_DEV_USE_WRANGLER:-false}
47+
WECHAT_OFFICIAL_APP_ID: ${WECHAT_OFFICIAL_APP_ID:-}
48+
WECHAT_OFFICIAL_APP_SECRET: ${WECHAT_OFFICIAL_APP_SECRET:-}
49+
WECHAT_DEFAULT_THUMB_MEDIA_ID: ${WECHAT_DEFAULT_THUMB_MEDIA_ID:-}
4750
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
4851
ports:
4952
- "127.0.0.1:${API_HOST_PORT:-8788}:8788"

0 commit comments

Comments
 (0)