Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7d6cec70 | |||
| a67d5a137a |
@@ -32,3 +32,11 @@ bash scripts/check-release-ready.sh
|
|||||||
| Memind 后端 | 8081 / 8082 | 业务逻辑与 Portal;UI 不在此仓库 |
|
| Memind 后端 | 8081 / 8082 | 业务逻辑与 Portal;UI 不在此仓库 |
|
||||||
|
|
||||||
新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx` 与 `App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。
|
新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx` 与 `App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。
|
||||||
|
|
||||||
|
## Analytics / SEO·GEO 看板
|
||||||
|
|
||||||
|
- 页面:`src/admin/pages/AnalyticsConfigPage.tsx`、`SeoGeoAnalyticsPage.tsx`
|
||||||
|
- API:`server/umami-analytics.mjs` → 105 Umami `memind-pages`
|
||||||
|
- 部署与环境变量:[docs/analytics-deploy.md](docs/analytics-deploy.md)
|
||||||
|
- 跨仓库发布顺序:Memind `docs/analytics-release-runbook.md`(105 Umami → 103 Portal → gadm)
|
||||||
|
- Rybbit 已退役,勿恢复相关配置
|
||||||
|
|||||||
@@ -261,6 +261,24 @@ PY'
|
|||||||
| `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` |
|
| `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` |
|
||||||
| `scripts/.releaseignore-prod` | 生产发布包排除规则 |
|
| `scripts/.releaseignore-prod` | 生产发布包排除规则 |
|
||||||
|
|
||||||
|
## Analytics / SEO·GEO(Umami)
|
||||||
|
|
||||||
|
管理后台 **Analytics 配置** 与 **SEO / GEO 流量** 看板依赖 105 Umami。
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [docs/analytics-deploy.md](analytics-deploy.md) | gadm 环境变量、验收、故障排查 |
|
||||||
|
| Memind [docs/analytics-release-runbook.md](../Memind/docs/analytics-release-runbook.md) | 跨仓库发布顺序(105 → 103 Portal → gadm) |
|
||||||
|
|
||||||
|
生产 `.env` 至少配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
UMAMI_ADMIN_PASSWORD=<Umami admin 密码>
|
||||||
|
MEMIND_UMAMI_SSO_SECRET=<与 105 Umami .env 一致>
|
||||||
|
```
|
||||||
|
|
||||||
|
可选:`UMAMI_URL`、`UMAMI_WEBSITE_ID`、`UMAMI_SSO_USERNAME`。
|
||||||
|
|
||||||
## 规则文档
|
## 规则文档
|
||||||
|
|
||||||
- `ENGINEERING_WORKFLOW_RULES.md`
|
- `ENGINEERING_WORKFLOW_RULES.md`
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Analytics / SEO·GEO 部署说明
|
||||||
|
|
||||||
|
管理后台 **Analytics 配置** 与 **SEO / GEO 流量** 看板的生产配置与发布。
|
||||||
|
|
||||||
|
平台边界与字段口径见 Memind [docs/analytics-platform.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-platform.md)。
|
||||||
|
跨仓库发布顺序见 Memind [docs/analytics-release-runbook.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-release-runbook.md)。
|
||||||
|
|
||||||
|
## 功能入口(gadm 5174)
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/analytics` | Analytics 配置 | Umami Website ID、域名、SSO 进入 Umami |
|
||||||
|
| `/analytics/seo-geo` | SEO / GEO 流量 | 按页展示搜索引擎 / 生成式引擎来源访问 |
|
||||||
|
|
||||||
|
**Rybbit 已退役** — 相关 SSO 接口返回 410;勿再配置 `MEMIND_RYBBIT_*`。
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
```text
|
||||||
|
SeoGeoAnalyticsPage
|
||||||
|
-> GET /admin-api/analytics/seo-geo-pages
|
||||||
|
-> server/umami-analytics.mjs
|
||||||
|
-> 105 Umami API /api/websites/:id/memind-pages?discoveryChannel=seo|geo
|
||||||
|
(memind-analytics overlay)
|
||||||
|
|
||||||
|
Analytics 配置 / Umami SSO
|
||||||
|
-> GET /admin-api/analytics/sso
|
||||||
|
-> Memind 共享 SSO 逻辑 + MEMIND_UMAMI_SSO_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
adm **不**直连浏览器到 Umami 写库;读统计通过服务端用 Umami 登录态拉 API。
|
||||||
|
|
||||||
|
## 生产环境变量(103 `.env`)
|
||||||
|
|
||||||
|
在 `/Users/john/Project/memind_adm/.env` 增加或确认:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# SEO/GEO 看板(必填)
|
||||||
|
UMAMI_ADMIN_PASSWORD=<Umami admin 密码>
|
||||||
|
|
||||||
|
# 可选:覆盖默认
|
||||||
|
UMAMI_URL=https://fxi.tkmind.cn
|
||||||
|
UMAMI_WEBSITE_ID=<Memind Generated Pages 的 Website ID>
|
||||||
|
UMAMI_SSO_USERNAME=admin
|
||||||
|
|
||||||
|
# Umami SSO(Analytics 配置页)
|
||||||
|
MEMIND_UMAMI_SSO_SECRET=<与 105 /opt/memind-analytics/.env 一致>
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `UMAMI_ADMIN_PASSWORD` 未配置时,SEO/GEO 看板 API 会报错「未配置 UMAMI_ADMIN_PASSWORD」
|
||||||
|
- `websiteId` 也可在 gadm **Analytics 配置** 页面保存(写入 MindSpace admin 配置),服务端优先读库内配置
|
||||||
|
- 103 必须能访问 `UMAMI_URL`(内网或公网,依网络拓扑)
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
仅改 adm 前端或 `server/umami-analytics.mjs` 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/john/Project/memind_adm
|
||||||
|
bash scripts/release-prod.sh --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
若改了 Umami `memind-pages` API 或 overlay,需 **先** 发 memind-analytics(105),再发 adm。
|
||||||
|
|
||||||
|
若改了 Memind discovery 埋点,需 **先** 发 Memind Portal(103),再有 SEO/GEO 分列数据。
|
||||||
|
|
||||||
|
完整顺序见 Memind `docs/analytics-release-runbook.md`。
|
||||||
|
|
||||||
|
## 发布后验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Admin API 健康
|
||||||
|
ssh john@10.10.0.2 'curl -sf http://127.0.0.1:8085/health'
|
||||||
|
|
||||||
|
# SEO/GEO API(需替换 cookie 或在内网浏览器登录后测)
|
||||||
|
# 期望 200 + pages 数组,而非 500 / 未配置 UMAMI_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器:
|
||||||
|
|
||||||
|
1. `https://gadm.tkmind.cn/analytics` → 「打开 Umami 分析后台」跳转成功
|
||||||
|
2. `https://gadm.tkmind.cn/analytics/seo-geo` → 表格加载;SEO / GEO Tab 切换正常
|
||||||
|
3. 列口径与 Umami「所有生成页面明细 / public page」一致(页面、PV、UV、点击/表单/参与率等)
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # 5174
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 `.env` 可指向本机或 105 Umami:
|
||||||
|
|
||||||
|
```env
|
||||||
|
UMAMI_URL=http://127.0.0.1:3100
|
||||||
|
UMAMI_ADMIN_PASSWORD=umami
|
||||||
|
UMAMI_WEBSITE_ID=<local website id>
|
||||||
|
MEMIND_UMAMI_SSO_SECRET=<local secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Memind 本地 analytics 变量见 [local-analytics.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/local-analytics.md)。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 现象 | 检查 |
|
||||||
|
|------|------|
|
||||||
|
| SEO/GEO 看板 500 | 103 `adm-api.log`;`UMAMI_ADMIN_PASSWORD`;105 Umami 是否 active |
|
||||||
|
| Umami SSO 失败 | `MEMIND_UMAMI_SSO_SECRET` 与 105 `.env` 是否一致 |
|
||||||
|
| 看板无 SEO/GEO 行 | Memind discovery 是否已发布;Umami 是否有带 `discovery_channel` 的事件 |
|
||||||
|
| Rybbit 链接/报错 | 清理旧书签;代码已移除 Rybbit SSO |
|
||||||
|
|
||||||
|
## 相关代码
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `src/admin/pages/SeoGeoAnalyticsPage.tsx` | SEO/GEO 看板 UI |
|
||||||
|
| `src/admin/pages/AnalyticsConfigPage.tsx` | Analytics 配置 |
|
||||||
|
| `server/umami-analytics.mjs` | Umami memind-pages 代理 |
|
||||||
|
| `server/app.mjs` | `/admin-api/analytics/*` 路由 |
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
||||||
|
|
||||||
let cachedToken = '';
|
let cachedToken = '';
|
||||||
@@ -14,13 +16,40 @@ function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) {
|
|||||||
);
|
);
|
||||||
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
|
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
|
||||||
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
|
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
|
||||||
|
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
|
||||||
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
|
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
|
||||||
return { baseUrl, username, password, websiteId };
|
return { baseUrl, username, password, sharedSecret, websiteId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginUmami({ baseUrl, username, password }) {
|
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
|
||||||
|
const encoded = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
username,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
}),
|
||||||
|
).toString('base64url');
|
||||||
|
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||||
|
const ticket = `${encoded}.${signature}`;
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`,
|
||||||
|
{ signal: AbortSignal.timeout(10_000) },
|
||||||
|
);
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
const token = String(body?.token ?? '').trim();
|
||||||
|
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUmami({ baseUrl, username, password, sharedSecret }) {
|
||||||
|
if (sharedSecret) {
|
||||||
|
return loginUmamiViaSso({ baseUrl, username, sharedSecret });
|
||||||
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
throw new Error('未配置 UMAMI_ADMIN_PASSWORD,无法拉取 SEO/GEO 页面统计');
|
throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计');
|
||||||
}
|
}
|
||||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ test('fetchMemindDiscoveryPages rejects missing website id', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchMemindDiscoveryPages rejects missing admin password', async () => {
|
test('fetchMemindDiscoveryPages rejects missing auth credentials', async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => fetchMemindDiscoveryPages(
|
() => fetchMemindDiscoveryPages(
|
||||||
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
|
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
|
||||||
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
|
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
|
||||||
),
|
),
|
||||||
/未配置 UMAMI_ADMIN_PASSWORD/,
|
/未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function AnalyticsConfigPage() {
|
|||||||
<label className="admin-form-row"><span>Umami 地址</span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
|
<label className="admin-form-row"><span>Umami 地址</span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
|
||||||
<label className="admin-form-row"><span>统计域名</span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
|
<label className="admin-form-row"><span>统计域名</span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
|
||||||
<label className="admin-form-row"><span>匿名化密钥 {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
|
<label className="admin-form-row"><span>匿名化密钥 {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
|
||||||
<p className="muted">SEO/GEO 看板还需在 memind_adm 环境变量配置 <code>UMAMI_ADMIN_PASSWORD</code>(与 Umami admin 账号匹配)。</p>
|
<p className="muted">SEO/GEO 看板通过 <code>MEMIND_UMAMI_SSO_SECRET</code>(推荐,与 105 Umami 一致)或 <code>UMAMI_ADMIN_PASSWORD</code> 访问 Umami API;生产 Umami 若开启 SSO-only 则只需前者。</p>
|
||||||
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>重新加载</button></div>
|
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>重新加载</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user