From 20aea130547cd69045311f540cda707b517347d0 Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Tue, 3 Nov 2026 16:45:50 +0800 Subject: [PATCH] Add private operations portal and weekly accounting reports --- .gitignore | 4 + ops-portal/.dockerignore | 6 + ops-portal/.gitignore | 6 + ops-portal/Dockerfile | 9 + ops-portal/README.md | 112 +++++++++++++ ops-portal/package.json | 8 + ops-portal/password.mjs | 19 +++ ops-portal/public/app.js | 138 ++++++++++++++++ ops-portal/public/index.html | 24 +++ ops-portal/public/style.css | 2 + ops-portal/scripts/hash-password.mjs | 8 + ops-portal/server.mjs | 170 +++++++++++++++++++ ops-portal/test/server.test.mjs | 112 +++++++++++++ ops/README.md | 21 +++ ops/pawplay-weekly-report.service | 14 ++ ops/pawplay-weekly-report.timer | 11 ++ ops/report.py | 236 +++++++++++++++++++++++++++ ops/test_report.py | 58 +++++++ 18 files changed, 958 insertions(+) create mode 100644 ops-portal/.dockerignore create mode 100644 ops-portal/.gitignore create mode 100644 ops-portal/Dockerfile create mode 100644 ops-portal/README.md create mode 100644 ops-portal/package.json create mode 100644 ops-portal/password.mjs create mode 100644 ops-portal/public/app.js create mode 100644 ops-portal/public/index.html create mode 100644 ops-portal/public/style.css create mode 100644 ops-portal/scripts/hash-password.mjs create mode 100644 ops-portal/server.mjs create mode 100644 ops-portal/test/server.test.mjs create mode 100644 ops/README.md create mode 100644 ops/pawplay-weekly-report.service create mode 100644 ops/pawplay-weekly-report.timer create mode 100644 ops/report.py create mode 100644 ops/test_report.py diff --git a/.gitignore b/.gitignore index c3e96a7..ab92996 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ app/keystore/ *.iml .DS_Store Thumbs.db + +# Local Python caches +__pycache__/ +*.pyc diff --git a/ops-portal/.dockerignore b/ops-portal/.dockerignore new file mode 100644 index 0000000..ebba0ff --- /dev/null +++ b/ops-portal/.dockerignore @@ -0,0 +1,6 @@ +* +!package.json +!server.mjs +!password.mjs +!public/ +!public/** diff --git a/ops-portal/.gitignore b/ops-portal/.gitignore new file mode 100644 index 0000000..ea72eaa --- /dev/null +++ b/ops-portal/.gitignore @@ -0,0 +1,6 @@ +.env +.env.* +data/ +node_modules/ +*.log +test-artifacts/ diff --git a/ops-portal/Dockerfile b/ops-portal/Dockerfile new file mode 100644 index 0000000..e2319e9 --- /dev/null +++ b/ops-portal/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +ENV NODE_ENV=production PORT=8080 DATA_DIR=/data +WORKDIR /app +COPY --chown=node:node package.json server.mjs password.mjs ./ +COPY --chown=node:node public ./public +USER node +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "server.mjs"] diff --git a/ops-portal/README.md b/ops-portal/README.md new file mode 100644 index 0000000..df0428e --- /dev/null +++ b/ops-portal/README.md @@ -0,0 +1,112 @@ +# PawPlay 私有运营台 + +周报、预算和收支台账的只读网站。无前端构建、无第三方运行依赖,Node.js 22 及以上可直接运行。数据目录与代码、镜像分离;上传视频、采集数据和生成周报由独立任务完成。 + +## 配置 + +必需环境变量: + +| 名称 | 内容 | +| --- | --- | +| `USERNAME` | 管理员账号,无默认值 | +| `PASSWORD_HASH` | 下述脚本生成的 scrypt 哈希,无默认密码 | +| `SESSION_SECRET` | 随机生成、至少 32 字符的独立会话密钥 | +| `PUBLIC_ORIGIN` | 对外 HTTPS 源,例如 `https://pawplay-ops.example.com`,不带路径或结尾斜杠 | + +`DATA_DIR` 默认 `./data`,容器内默认 `/data`。`PORT` 默认 `8080`,监听 `0.0.0.0`。 + +密码通过标准输入传给 `node scripts/hash-password.mjs`,不要放进命令参数、Git 或日志。该脚本输出 `scrypt$16384$8$1$$`。要求密码至少 16 字符;环境文件权限应为 `0600`,只由管理员访问。Compose 的 `env_file` 中将哈希值包在单引号内,避免 `$` 被插值。 + +使用自己的凭据配置环境后,运行 `npm start`。本地 HTTP 预览可以显式设置 `COOKIE_SECURE=false`,同时将 `PUBLIC_ORIGIN` 指向准确的 `http://localhost:` 或 `http://127.0.0.1:`。该开关拒绝非回环地址;生产环境必须保留默认安全 Cookie。 + +认证会话有效期 4 小时。退出登录会立即撤销当前 Cookie;服务重启会撤销所有会话。更换密码时更新 `PASSWORD_HASH` 并重新启动此服务。网站没有在线重设密码入口,也不记录密码、Cookie 或登录请求体。 + +## 数据格式 + +所有金额使用数值或 `null`。`null` 表示未知,不应以 0 代替。不同币种、现金支出、固定套餐分摊和预估费用分开保存;套餐内 Token 用量不能重复计作现金支出。账户总用量不能直接归因到 PawPlay。 + +`config.json`: + +```json +{ + "title": "PawPlay 运营台", + "timezone": "Asia/Shanghai", + "currency": "USD", + "budget": { + "subscription_usd": 200, + "billing_period": "pending", + "project_allocation_fraction": null, + "extra_api_weekly_limit_usd": null + }, + "links": {"product": "", "repository": ""} +} +``` + +`billing_period` 支持 `monthly`、`weekly`、`pending`;项目分摊比例范围由生成任务校验,未确认时使用 `null`。 + +`ledger.json`: + +```json +{ + "entries": [ + { + "id": "cost-001", + "date": "2026-11-03", + "label": "本周套餐分摊", + "category": "subscription", + "amount": null, + "currency": "USD", + "basis": "allocated", + "source": "计费周期和项目占比待确认" + } + ] +} +``` + +`basis` 支持 `cash`、`allocated`、`estimate`、`settled`。支持额外元数据,例如 `project`、`status`、`usage`;网站不据此重新计算金额。 + +`reports/2026-W45.json`: + +```json +{ + "id": "2026-W45", + "title": "第 45 周运营报告", + "period_start": "2026-10-30T20:00:00+08:00", + "period_end": "2026-11-06T20:00:00+08:00", + "published_at": "2026-11-06T20:00:00+08:00", + "summary": "本周进展摘要。", + "highlights": ["完成的工作"], + "risks": ["需要关注的问题"], + "next_actions": ["下一周计划"], + "decisions": ["已确认的决策"], + "metrics": {"views": null, "downloads": {"value": null, "source": "尚未接入采集"}}, + "costs": {"cash_usd": null, "allocated_usd": null, "estimated_usd": null}, + "revenue": {"settled_cny": null, "estimated_cny": null} +} +``` + +文件名必须与 `id` 一致。编号只接受字母、数字、下划线和连字符,长度不超过 80 字符。页面按 `period_end` 倒序展示;`metrics` 支持嵌套对象,页面以可读字段呈现。周报 JSON 可在登录后下载。报告生成任务应使用临时文件写完后原子替换,避免读到半份 JSON。 + +`period_start` 和 `period_end` 可使用 ISO 日期时间,页面统一以北京时间显示。当前未结束周期的快照添加 `"kind": "snapshot"`,页面会注明“截至当前的部分周期”。 + +数据中不要放密码、Token、浏览器会话或签名密钥。文件下载受同样的认证保护。缺失文件按待核实展示;损坏 JSON 返回读取失败,不会静默按零处理。 + +## Docker 与反向代理 + +```sh +docker build -t pawplay-ops . +``` + +镜像内使用非 root 的 `node` 用户,内部端口 `8080`。生产 Compose 应将私有环境文件通过 `env_file` 注入,将报告目录挂载为 `/data:ro`,并确保该目录对容器的 UID 1000 可读。环境文件不挂载到 `/data`,不复制到镜像。该服务不需要写入报告目录;可设置只读根文件系统、`cap_drop: [ALL]` 和 `no-new-privileges`。 + +Nginx Proxy Manager 终止 HTTPS,转发到所选 NAS 端口。`PUBLIC_ORIGIN` 必须与用户访问的域名一致。反向代理无需读取报告内容或额外添加认证旁路。`GET /healthz` 可在 NAS 内网使用 HTTP 检查,只返回 `{"ok":true}`;它不代表报告采集已完成。 + +报告、台账以及配置的所有 API 均在服务端校验会话。报告不会作为静态资源提供。登录静态页面可匿名访问,不能从中读取项目运营数据。登录按连接来源限速;反向代理下来源可能合并,因此单人运营站点优先使用当前的保守限制。 + +## 检查 + +```sh +npm test +``` + +覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。 diff --git a/ops-portal/package.json b/ops-portal/package.json new file mode 100644 index 0000000..9ac0c24 --- /dev/null +++ b/ops-portal/package.json @@ -0,0 +1,8 @@ +{ + "name": "pawplay-ops", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": {"start": "node server.mjs", "test": "node --test", "hash-password": "node scripts/hash-password.mjs"}, + "engines": {"node": ">=22"} +} diff --git a/ops-portal/password.mjs b/ops-portal/password.mjs new file mode 100644 index 0000000..fbbb143 --- /dev/null +++ b/ops-portal/password.mjs @@ -0,0 +1,19 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +export function hashPassword(password) { + if (password.length < 16 || password.length > 1024) throw new Error('Password must contain 16–1024 characters'); + const salt = randomBytes(16).toString('base64url'); + const key = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }).toString('base64url'); + return `scrypt$16384$8$1$${salt}$${key}`; +} + +export function validatePasswordHash(value) { + if (!/^scrypt\$16384\$8\$1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{86}$/.test(value || '')) throw new Error('PASSWORD_HASH must be generated by scripts/hash-password.mjs'); +} + +export function verifyPassword(password, hash) { + if (typeof password !== 'string' || password.length > 1024) return false; + validatePasswordHash(hash); + const [, , , , salt, expected] = hash.split('$'); + return timingSafeEqual(scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }), Buffer.from(expected, 'base64url')); +} diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js new file mode 100644 index 0000000..71383c7 --- /dev/null +++ b/ops-portal/public/app.js @@ -0,0 +1,138 @@ +const $ = selector => document.querySelector(selector); +const el = (tag, text, className) => { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +}; +let dashboard; +const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数' }; +const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' }; +const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' }; +function valueText(value) { + if (value === null || value === undefined || value === '') return '待核实'; + if (typeof value === 'boolean') return value ? '是' : '否'; + if (Array.isArray(value)) return value.map(valueText).join(';') || '暂无记录'; + if (typeof value === 'object') return Object.entries(value).map(([k, v]) => `${labels[k] || k}:${valueText(v)}`).join(' · ') || '暂无记录'; + return String(value); +} +function money(value, currency = 'USD') { + if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实'; + return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value); +} +function dateTime(value) { + if (!value) return '待确认'; + if (!String(value).includes('T')) return String(value); + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return String(value); + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date); +} +async function api(url, options = {}) { + const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }); + const data = await response.json(); + if (response.status === 401 && url !== '/api/login') showLogin(); + if (!response.ok) throw new Error(data.error || '请求失败,请稍后再试'); + return data; +} +function showLogin() { + $('#loading').hidden = true; $('#dashboard-view').hidden = true; $('#login-view').hidden = false; + $('#password').value = ''; +} +function card(title, amount, note, accent = false) { + const node = el('article', undefined, `stat-card${accent ? ' accent' : ''}`); + node.append(el('p', title, 'stat-label'), el('strong', amount), el('p', note, 'stat-note')); + return node; +} +function listSection(title, items, style = '') { + const section = el('section', undefined, `report-section ${style}`); + section.append(el('h2', title)); + if (!Array.isArray(items) || !items.length) { section.append(el('p', '暂无记录', 'muted')); return section; } + const list = el('ul'); + for (const item of items) list.append(el('li', valueText(item))); + section.append(list); return section; +} +function budgetPanel() { + const budget = dashboard.config?.budget || {}; + const section = el('section', undefined, 'budget-note'); + const header = el('div', undefined, 'section-heading'); + header.append(el('h2', '预算口径'), el('span', '套餐内 Token 不重复计费', 'tag')); + const period = { monthly: '月', weekly: '周', pending: '待确认周期' }[budget.billing_period] || '待确认周期'; + const amount = typeof budget.subscription_usd === 'number' ? `${money(budget.subscription_usd)} / ${period}` : '套餐价格待确认'; + const fraction = typeof budget.project_allocation_fraction === 'number' ? `${Math.round(budget.project_allocation_fraction * 100)}%` : '待归因'; + section.append(header, el('p', `账户套餐:${amount} · 本项目分摊比例:${fraction}`), el('p', '套餐成本按确认的周期和项目占比分摊。套餐内 Token 用量单独记录,不再加算为现金支出;额外 API、托管和电费按实际凭据入账。账户总用量不等于本项目用量。', 'muted')); + return section; +} +function renderReport(report) { + const content = $('#report-content'); content.replaceChildren(); + const download = $('#download-report'); + if (!report) { + $('#report-title').textContent = '第一份报告,正在准备'; $('#report-period').textContent = '每周进展与收支将在这里归档。'; + download.hidden = true; content.append(budgetPanel()); return; + } + $('#report-title').textContent = report.title || report.id; + $('#report-period').textContent = `${dateTime(report.period_start)} — ${dateTime(report.period_end)} · 北京时间${report.partial_period || report.kind === 'snapshot' ? ' · 截至当前的部分周期' : ''}`; + download.href = `/api/reports/${encodeURIComponent(report.id)}/download`; download.hidden = false; + for (const link of $('#report-nav').children) link.classList.toggle('selected', link.dataset.id === report.id); + const intro = el('section', undefined, 'summary'); intro.append(el('span', '本周摘要', 'eyebrow'), el('p', valueText(report.summary))); content.append(intro); + const costs = report.costs || {}, revenue = report.revenue || {}; + const stats = el('div', undefined, 'stats'); + const cashNote = typeof costs.confirmed_recorded_cash_usd === 'number' ? `已确认记录 ${money(costs.confirmed_recorded_cash_usd)};未覆盖支出仍待核实` : '仅计实际发生的美元支出'; + stats.append(card('本周实际现金支出', money(costs.cash_usd), cashNote), card('本周套餐分摊', money(costs.allocated_usd), '固定费用归因,与现金支出分开'), card('已结算收入', money(revenue.settled_cny, 'CNY'), '以平台结算或到账为依据', true), card('预估收入', money(revenue.estimated_cny, 'CNY'), '尚未结算,不计作已实现收益')); + content.append(stats, budgetPanel()); + if (report.metrics && Object.keys(report.metrics).length) { + const section = el('section', undefined, 'report-section'); section.append(el('h2', '运营数据')); + const metrics = el('dl', undefined, 'metric-grid'); + for (const [name, value] of Object.entries(report.metrics)) { const row = el('div'); row.append(el('dt', labels[name] || name), el('dd', valueText(value))); metrics.append(row); } + section.append(metrics); content.append(section); + } + const columns = el('div', undefined, 'report-columns'); columns.append(listSection('本周完成', report.highlights), listSection('下一步', report.next_actions)); content.append(columns); + if (report.risks?.length) content.append(listSection('需要关注', report.risks, 'attention')); + if (report.decisions?.length) content.append(listSection('运营决策', report.decisions)); +} +function renderLedger() { + const host = $('#ledger'); host.replaceChildren(); + const entries = dashboard.ledger?.entries || []; + if (!entries.length) { host.append(el('p', '暂无已入账记录。未取得数据的项目保持待核实,不按零成本处理。', 'empty')); return; } + const wrapper = el('div', undefined, 'table-wrap'), table = el('table'), head = el('thead'), header = el('tr'); + for (const title of ['日期', '项目', '类型', '金额', '口径', '依据']) header.append(el('th', title)); + head.append(header); const body = el('tbody'); + for (const item of [...entries].sort((a, b) => String(b.date).localeCompare(String(a.date)))) { + const row = el('tr'); + for (const value of [item.date, item.label, categoryLabels[item.category] || item.category, money(item.amount, item.currency || 'USD'), basisLabels[item.basis] || item.basis, item.source || '待补充']) row.append(el('td', valueText(value))); + body.append(row); + } + table.append(head, body); wrapper.append(table); host.append(wrapper); +} +async function openReport(id) { + $('#global-error').textContent = ''; + try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); } + catch (error) { $('#global-error').textContent = error.message; } +} +async function loadDashboard() { + dashboard = await api('/api/dashboard'); + $('#loading').hidden = true; $('#login-view').hidden = true; $('#dashboard-view').hidden = false; + document.title = dashboard.config?.title || 'PawPlay 运营台'; + const nav = $('#report-nav'); nav.replaceChildren(); + for (const report of dashboard.reports) { + const link = el('a'); link.href = `/?report=${encodeURIComponent(report.id)}`; link.dataset.id = report.id; + link.append(el('strong', report.id), el('span', report.title || '每周运营报告')); + link.addEventListener('click', event => { event.preventDefault(); openReport(report.id); }); nav.append(link); + } + if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted')); + renderLedger(); + const selected = new URL(location.href).searchParams.get('report'); + if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest); +} +$('#login-form').addEventListener('submit', async event => { + event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = ''; + try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); } + catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; } +}); +$('#logout').addEventListener('click', async () => { + try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); } + catch (error) { $('#global-error').textContent = error.message; } +}); +(async () => { + try { const status = await api('/api/session'); if (status.authenticated) await loadDashboard(); else showLogin(); } + catch (error) { showLogin(); $('#login-error').textContent = error.message; } +})(); diff --git a/ops-portal/public/index.html b/ops-portal/public/index.html new file mode 100644 index 0000000..389ff24 --- /dev/null +++ b/ops-portal/public/index.html @@ -0,0 +1,24 @@ + + + + + + + PawPlay 运营台 + + + + + + +

正在打开运营台…

+ + diff --git a/ops-portal/public/style.css b/ops-portal/public/style.css new file mode 100644 index 0000000..b589b93 --- /dev/null +++ b/ops-portal/public/style.css @@ -0,0 +1,2 @@ +@charset "UTF-8"; +:root{--ink:#242a27;--muted:#727a73;--paper:#f8f8f2;--green:#244c3c;--line:#e3e7df;--lime:#eaf0cb;--orange:#d8824b;--card:#fff;--serif:Georgia,"Songti SC","Noto Serif CJK SC",serif}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.7}button,input{font:inherit}button,a,input{ -webkit-tap-highlight-color:transparent}button,a{touch-action:manipulation}a{color:inherit}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #a4bfa4;outline-offset:4px}[hidden]{display:none!important}.muted{color:var(--muted)}.eyebrow{font-size:11px;font-weight:650;letter-spacing:.1em;margin:0 0 12px;color:var(--green)}h1,h2,p{margin-top:0}h1{font-size:31px;line-height:1.45;letter-spacing:-.04em;font-weight:600}h2{font-size:18px;letter-spacing:-.02em;font-weight:600}.wordmark{text-decoration:none;font-size:25px;font-weight:750;letter-spacing:-1.4px;display:flex;align-items:center;gap:14px;color:var(--green)}.wordmark span{letter-spacing:.05em;font-size:12px;font-weight:500;border-left:1px solid #ccd4ca;padding-left:14px;color:var(--muted)}.login-shell{max-width:1150px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1.2fr 1fr;align-items:center;gap:80px;padding:60px 40px}.login-intro{position:relative}.login-intro .wordmark{margin-bottom:80px}.login-intro h1{font-family:var(--serif);font-size:39px;font-weight:500;margin-bottom:25px}.login-intro>p:not(.eyebrow){color:var(--muted)}.private-note{font-size:12px;margin-top:95px}.login-card{background:#fff;padding:46px 40px 32px;border:1px solid var(--line);border-radius:20px;box-shadow:0 18px 70px #253e2d06}.login-card h2{font-size:26px;margin-bottom:10px}.login-card form{margin-top:30px}.login-card label{display:block;margin-bottom:7px;font-size:13px}.login-card input{display:block;width:100%;border:1px solid #d8ded5;border-radius:8px;padding:12px 13px;background:#fdfdfb;margin-bottom:19px;color:var(--ink)}.primary{background:var(--green);color:#fff;border:0;border-radius:8px;width:100%;padding:13px 16px;margin-top:8px;text-align:left}.primary span{float:right}.error{color:#ae402f;font-size:13px;min-height:1.7em;margin:12px 0 0}.paw-decoration{position:absolute;right:14px;bottom:82px;font-size:16px;letter-spacing:8px;transform:rotate(-15deg);color:#b7c4a0}.paw-decoration span{position:relative;top:-9px}.paw-decoration b{display:block;font-size:46px;text-align:center;line-height:1;letter-spacing:0}.loading{text-align:center;padding-top:25vh;color:var(--muted)}.topbar{height:86px;display:flex;align-items:center;justify-content:space-between;padding:0 46px;background:#fff;border-bottom:1px solid var(--line)}.top-right{display:flex;gap:22px;align-items:center}.private-pill{font-size:11px;color:var(--green);border:1px solid #dbe2d6;border-radius:50px;padding:4px 12px}.quiet{background:transparent;border:0;color:var(--muted);font-size:12px;padding:8px}.workspace{max-width:1600px;margin:auto;display:grid;grid-template-columns:244px minmax(0,1fr);min-height:calc(100vh - 86px)}.sidebar{padding:38px 22px;border-right:1px solid var(--line)}.sidebar-heading{padding:0 16px 20px}.sidebar-heading h2{font-size:19px}.sidebar nav{display:grid;gap:8px}.sidebar nav>a{display:block;text-decoration:none;padding:13px 16px;border:1px solid transparent;border-radius:9px}.sidebar nav>a:hover{background:#eef1e8}.sidebar nav>a.selected{background:var(--green);color:white}.sidebar nav strong{font-size:12px;display:block;font-weight:600}.sidebar nav span{font-size:12px;display:block;opacity:.7;line-height:1.5;margin-top:4px}.sidebar-foot{padding:40px 16px;font-size:11px;color:var(--green);line-height:2.2}.status-dot{display:inline-block;background:#90a97c;width:6px;height:6px;border-radius:50%;margin-right:7px}.main{padding:41px 44px 24px;min-width:0}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:22px}.page-heading h1{margin-bottom:8px}.page-heading .muted{font-size:12px;margin:0}.download{flex-shrink:0;text-decoration:none;font-size:12px;padding:8px 13px;border:1px solid #d7ded2;border-radius:7px;color:var(--green)}.download:hover{background:white}.summary{background:var(--lime);border-radius:12px;padding:25px 28px;margin-bottom:22px}.summary .eyebrow{font-size:10px;display:block;opacity:.7;margin-bottom:7px}.summary p{margin:0;font-size:15px;line-height:1.9}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin-bottom:24px}.stat-card{background:#fff;border:1px solid var(--line);border-radius:11px;padding:20px 18px}.stat-card.accent{background:#edf2e9;border-color:#d8e2d1}.stat-label{font-size:11px;color:var(--muted);margin:0 0 15px}.stat-card strong{font-size:25px;font-weight:550;letter-spacing:-.035em;color:var(--green)}.stat-note{margin:12px 0 0;font-size:10px;color:var(--muted);line-height:1.6}.budget-note{border-bottom:1px solid var(--line);padding:5px 0 21px;margin-bottom:24px}.budget-note h2{font-size:14px}.budget-note p{font-size:12px;margin-bottom:8px}.budget-note p:last-child{font-size:11px;margin-bottom:0;max-width:870px}.section-heading{display:flex;gap:15px;align-items:center;justify-content:space-between;margin-bottom:13px}.section-heading h2{margin:0}.section-heading>.muted{font-size:11px}.tag{border-radius:4px;padding:3px 7px;background:#eef1e7;color:#6c7b62;font-size:10px}.report-columns{display:grid;grid-template-columns:1fr 1fr;gap:22px}.report-section{padding:22px 24px;background:#fff;border:1px solid var(--line);border-radius:11px;margin-bottom:20px;min-width:0}.report-section h2{font-size:15px;margin-bottom:15px}.report-section ul{padding-left:18px;margin:0}.report-section li{padding-left:2px;margin:9px 0;line-height:1.9;font-size:13px;overflow-wrap:anywhere}.report-section li::marker{color:#a1b48e}.report-section.attention{background:#fbf6ec;border-color:#eee2ca}.report-section.attention li::marker{color:var(--orange)}.metric-grid{margin:0;display:grid;grid-template-columns:1fr 1fr;gap:17px 26px}.metric-grid>div{border-bottom:1px solid #eef0eb;padding-bottom:13px;min-width:0}.metric-grid dt{font-size:11px;color:var(--muted);margin-bottom:6px}.metric-grid dd{margin:0;font-size:13px;overflow-wrap:anywhere}.ledger-section{margin-top:35px}.ledger-section h2{font-size:17px}.empty{background:white;border:1px solid var(--line);border-radius:10px;padding:24px;color:var(--muted);font-size:12px}.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:12px;min-width:620px;text-align:left}th{font-size:10px;font-weight:500;color:var(--muted);background:#fafbf7;white-space:nowrap}td,th{padding:13px 16px;border-bottom:1px solid #edf0e8}td:last-child{font-size:11px;max-width:280px;overflow-wrap:anywhere}tbody tr:last-child td{border-bottom:0}.main-footer{border-top:1px solid var(--line);margin-top:45px;padding-top:20px;font-size:11px;color:var(--green);display:flex;justify-content:space-between;gap:12px}.main-footer span{font-size:10px;color:var(--muted)}@media(min-width:1500px){.main{padding-left:60px;padding-right:60px}}@media(max-width:1150px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.main{padding:34px 28px}.workspace{grid-template-columns:205px minmax(0,1fr)}.sidebar{padding:35px 12px}.login-shell{gap:45px}.login-intro h1{font-size:33px}}@media(max-width:760px){.topbar{height:70px;padding:0 20px}.wordmark{font-size:23px}.wordmark span{font-size:10px;gap:10px}.top-right{gap:10px}.private-pill{display:none}.workspace{display:block;min-height:calc(100vh - 70px)}.sidebar{border-right:0;border-bottom:1px solid var(--line);padding:16px 20px;overflow:auto}.sidebar-heading,.sidebar-foot{display:none}.sidebar nav{display:flex;gap:8px;min-width:max-content}.sidebar nav>a{padding:8px 12px;border-color:var(--line)}.sidebar nav span{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:10px}.sidebar nav strong{font-size:10px}.main{padding:25px 20px 20px}.page-heading{align-items:flex-start;margin-bottom:18px}.page-heading h1{font-size:24px}.page-heading .eyebrow{font-size:9px}.download{font-size:10px;padding:6px 9px;margin-top:4px}.summary{padding:20px}.summary p{font-size:14px}.stats{gap:10px}.stat-card{padding:16px 13px}.stat-card strong{font-size:23px}.stat-label{font-size:10px}.stat-note{font-size:9px}.report-columns{display:block}.report-section{padding:19px}.section-heading{align-items:flex-start}.tag{font-size:8px;max-width:130px}.budget-note p{font-size:11px}.metric-grid{grid-template-columns:1fr}.main-footer{display:block}.main-footer span{display:block;margin-top:6px}.login-shell{display:block;padding:30px 22px;min-height:100vh;max-width:470px}.login-intro .wordmark{margin-bottom:32px}.login-intro h1{font-size:27px;margin-bottom:12px}.login-intro>.eyebrow{display:none}.login-intro>p:not(.eyebrow){font-size:12px}.private-note,.paw-decoration{display:none}.login-card{padding:26px 23px 17px;margin-top:27px;border-radius:13px}.login-card h2{font-size:23px}.login-card .muted{font-size:12px}.login-card form{margin-top:24px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} diff --git a/ops-portal/scripts/hash-password.mjs b/ops-portal/scripts/hash-password.mjs new file mode 100644 index 0000000..d6d5dd9 --- /dev/null +++ b/ops-portal/scripts/hash-password.mjs @@ -0,0 +1,8 @@ +import { hashPassword } from '../password.mjs'; + +let input = ''; +for await (const chunk of process.stdin) { + input += chunk; + if (input.length > 2048) throw new Error('Password input too long'); +} +process.stdout.write(hashPassword(input.replace(/\r?\n$/, '')) + '\n'); diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs new file mode 100644 index 0000000..a0b3f7a --- /dev/null +++ b/ops-portal/server.mjs @@ -0,0 +1,170 @@ +import http from 'node:http'; +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, readdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { validatePasswordHash, verifyPassword } from './password.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const reportId = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; +const sessionSeconds = 4 * 60 * 60; +const mime = { '/': 'text/html; charset=utf-8', '/login': 'text/html; charset=utf-8', '/app.js': 'text/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8' }; +const files = { '/': 'index.html', '/login': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }; + +export function createApp(options = process.env) { + const username = options.USERNAME; + const passwordHash = options.PASSWORD_HASH; + const secret = options.SESSION_SECRET; + const secure = options.COOKIE_SECURE !== 'false'; + const origin = options.PUBLIC_ORIGIN; + const dataDir = path.resolve(options.DATA_DIR || './data'); + if (!username || username.length > 128) throw new Error('USERNAME is required'); + validatePasswordHash(passwordHash); + if (!secret || secret.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters'); + let parsedOrigin; + try { parsedOrigin = new URL(origin); } catch { throw new Error('PUBLIC_ORIGIN is required'); } + if (parsedOrigin.origin !== origin || (secure && parsedOrigin.protocol !== 'https:')) throw new Error('PUBLIC_ORIGIN must be an HTTPS origin without a trailing slash'); + if (!secure && !['127.0.0.1', 'localhost', '[::1]'].includes(parsedOrigin.hostname)) throw new Error('Insecure cookies are allowed only for a loopback origin'); + const cookieName = secure ? '__Host-pawplay_session' : 'pawplay_session'; + const sessions = new Map(); + const attempts = new Map(); + const sign = value => createHmac('sha256', secret).update(value).digest('base64url'); + const equal = (a, b) => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest()); + const cookie = (value, age) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`; + + function session(req) { + const value = (req.headers.cookie || '').split(';').map(s => s.trim()).find(s => s.startsWith(cookieName + '='))?.slice(cookieName.length + 1); + if (!value || value.length > 500) return null; + const [payload, signature, extra] = value.split('.'); + if (extra || !signature || !equal(signature, sign(payload))) return null; + let record; + try { record = JSON.parse(Buffer.from(payload, 'base64url').toString()); } catch { return null; } + if (!record.id || !Number.isFinite(record.exp) || record.exp <= Date.now() || sessions.get(record.id) !== record.exp) { + if (record.id) sessions.delete(record.id); + return null; + } + return record; + } + + async function jsonFile(file, fallback) { + let target; + try { target = await realpath(path.join(dataDir, file)); } + catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } + const actualRoot = await realpath(dataDir); + if (!target.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR'); + return JSON.parse(await readFile(target, 'utf8')); + } + + async function reports() { + let names; + try { names = await readdir(path.join(dataDir, 'reports')); } + catch (error) { if (error.code === 'ENOENT') return []; throw error; } + const results = []; + for (const name of names.filter(n => n.endsWith('.json') && reportId.test(n.slice(0, -5)))) { + const value = await jsonFile(`reports/${name}`, null); + if (value && value.id === name.slice(0, -5)) results.push(value); + } + return results.sort((a, b) => String(b.period_end || b.id).localeCompare(String(a.period_end || a.id))); + } + + function json(res, status, body, extra = {}) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra }); + res.end(JSON.stringify(body)); + } + + async function readBody(req) { + let body = '', bytes = 0; + for await (const chunk of req) { + bytes += chunk.length; + if (bytes <= 8192) body += chunk; + } + if (bytes > 8192) throw Object.assign(new Error('Body too large'), { status: 413 }); + try { + const value = JSON.parse(body); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid JSON object'); + return value; + } catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); } + } + + const server = http.createServer(async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + try { + const rawPath = (req.url || '/').split('?')[0]; + let route; + try { route = decodeURIComponent(rawPath); } catch { return json(res, 400, { error: '请求路径无效' }); } + if (route.includes('\\') || route.includes('\0') || route.split('/').some(p => p === '.' || p === '..')) return json(res, 400, { error: '请求路径无效' }); + if (req.method === 'GET' && route === '/healthz') return json(res, 200, { ok: true }); + if (req.method === 'GET' && route === '/api/session') return json(res, 200, { authenticated: !!session(req) }); + if (req.method === 'POST') { + if (req.headers.origin !== origin || req.headers['sec-fetch-site'] === 'cross-site') return json(res, 403, { error: '请求来源无效' }); + if (route === '/api/login') { + const now = Date.now(), ip = req.socket.remoteAddress; + for (const [key, entry] of attempts) if (entry.expires <= now) attempts.delete(key); + const attempt = attempts.get(ip) || { count: 0, expires: now + 15 * 60 * 1000 }; + if (attempt.count >= 8) return json(res, 429, { error: '尝试过于频繁,请稍后再试' }, { 'Retry-After': '900' }); + if (!attempts.has(ip) && attempts.size >= 2048) attempts.delete(attempts.keys().next().value); + attempt.count++; attempts.set(ip, attempt); + if (!req.headers['content-type']?.startsWith('application/json')) return json(res, 415, { error: '需要 JSON 请求' }); + const body = await readBody(req); + const validPassword = verifyPassword(body.password, passwordHash); + if (typeof body.username !== 'string' || !equal(body.username, username) || !validPassword) return json(res, 401, { error: '账号或密码错误' }); + attempts.delete(ip); + for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id); + if (sessions.size >= 128) sessions.delete(sessions.keys().next().value); + const record = { id: randomBytes(24).toString('base64url'), exp: now + sessionSeconds * 1000 }; + sessions.set(record.id, record.exp); + const payload = Buffer.from(JSON.stringify(record)).toString('base64url'); + return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(payload + '.' + sign(payload), sessionSeconds) }); + } + if (route === '/api/logout') { + const current = session(req); + if (current) sessions.delete(current.id); + return json(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) }); + } + } + if (route.startsWith('/api/')) { + if (!session(req)) return json(res, 401, { error: '请先登录' }); + if (req.method === 'GET' && route === '/api/dashboard') { + const list = await reports(); + const config = await jsonFile('config.json', { title: 'PawPlay 运营台', timezone: 'Asia/Shanghai', budget: { billing_period: 'pending' } }); + const ledger = await jsonFile('ledger.json', { entries: [] }); + return json(res, 200, { config, ledger, reports: list.map(({ id, title, period_start, period_end, published_at }) => ({ id, title, period_start, period_end, published_at })), latest: list[0] || null }); + } + if (req.method === 'GET' && route.startsWith('/api/reports/')) { + const download = route.endsWith('/download'); + const id = route.slice('/api/reports/'.length, download ? -'/download'.length : undefined); + if (!reportId.test(id)) return json(res, 400, { error: '报告编号无效' }); + const report = await jsonFile(`reports/${id}.json`, null); + if (!report || report.id !== id) return json(res, 404, { error: '报告不存在' }); + return json(res, 200, report, download ? { 'Content-Disposition': `attachment; filename="${id}.json"` } : {}); + } + return json(res, 404, { error: '接口不存在' }); + } + if (req.method === 'GET' && Object.hasOwn(files, route)) { + const body = await readFile(path.join(root, 'public', files[route])); + res.writeHead(200, { 'Content-Type': mime[route] }); + return res.end(body); + } + return json(res, 404, { error: '页面不存在' }); + } catch (error) { + if (!res.headersSent) json(res, error.status || 500, { error: error.status ? error.message : '暂时无法读取报告,请稍后重试' }); + else res.end(); + } + }); + server.requestTimeout = 15000; + server.headersTimeout = 10000; + server.maxRequestsPerSocket = 200; + return server; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const server = createApp(); + server.listen(Number(process.env.PORT || 8080), '0.0.0.0', () => console.log('PawPlay operations portal listening')); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs new file mode 100644 index 0000000..3abbb44 --- /dev/null +++ b/ops-portal/test/server.test.mjs @@ -0,0 +1,112 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { createApp } from '../server.mjs'; +import { hashPassword, verifyPassword } from '../password.mjs'; + +const password = 'a-test-password-with-enough-entropy'; +const passwordHash = hashPassword(password); +const origin = 'http://localhost:9999'; +const config = { USERNAME: 'owner', PASSWORD_HASH: passwordHash, SESSION_SECRET: 'test-secret-with-more-than-thirty-two-characters', PUBLIC_ORIGIN: origin, COOKIE_SECURE: 'false' }; +async function fixture(t, options = {}) { + const directory = await mkdtemp(path.join(tmpdir(), 'pawplay-ops-test-')); + await mkdir(path.join(directory, 'reports')); + await writeFile(path.join(directory, 'config.json'), JSON.stringify({ title: 'PRIVATE-REPORT-TITLE', budget: { billing_period: 'pending' } })); + await writeFile(path.join(directory, 'ledger.json'), JSON.stringify({ entries: [{ amount: null, basis: 'cash' }] })); + await writeFile(path.join(directory, 'reports/2026-W45.json'), JSON.stringify({ id: '2026-W45', title: 'PRIVATE-WEEK', period_end: '2026-11-08', costs: { cash_usd: null } })); + const server = createApp({ ...config, DATA_DIR: directory, ...options }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const base = `http://127.0.0.1:${server.address().port}`; + t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(directory, { recursive: true, force: true }); }); + const request = (url, opts) => fetch(base + url, opts); + const login = async (pwd = password) => request('/api/login', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password: pwd }) }); + return { server, directory, base, request, login }; +} + +test('fails closed without configured credentials or HTTPS production origin', () => { + assert.throws(() => createApp({}), /USERNAME/); + assert.throws(() => createApp({ ...config, SESSION_SECRET: 'short' }), /SESSION_SECRET/); + assert.throws(() => createApp({ ...config, COOKIE_SECURE: 'true' }), /HTTPS/); + assert.throws(() => createApp({ ...config, PUBLIC_ORIGIN: 'http://example.org' }), /loopback/); + assert.throws(() => createApp({ ...config, PASSWORD_HASH: 'plaintext' }), /PASSWORD_HASH/); +}); + +test('password hashing uses random salts and checks a supplied password', () => { + const another = hashPassword(password); assert.notEqual(another, passwordHash); + assert.equal(verifyPassword(password, another), true); + assert.equal(verifyPassword('wrong', another), false); +}); + +test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => { + const { request } = await fixture(t); + for (const endpoint of ['/api/dashboard', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) { + const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store'); + assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/); + } + const health = await request('/healthz'); assert.deepEqual(await health.json(), { ok: true }); + assert.equal((await request('/')).status, 200); + assert.equal((await request('/reports/2026-W45.json')).status, 404); +}); + +test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => { + const { request, login } = await fixture(t); + const response = await login(); assert.equal(response.status, 200); + const setCookie = response.headers.get('set-cookie'); assert.match(setCookie, /HttpOnly/); assert.match(setCookie, /SameSite=Lax/); + const cookie = setCookie.split(';')[0]; + const r = await request('/api/dashboard', { headers: { Cookie: cookie } }); + assert.equal(r.status, 200); const d = await r.json(); assert.equal(d.latest.title, 'PRIVATE-WEEK'); assert.equal(d.ledger.entries[0].amount, null); + const download = await request('/api/reports/2026-W45/download', { headers: { Cookie: cookie } }); + assert.equal(download.status, 200); assert.match(download.headers.get('content-disposition'), /attachment; filename="2026-W45.json"/); + assert.equal((await download.json()).costs.cash_usd, null); + const logout = await request('/api/logout', { method: 'POST', headers: { Cookie: cookie, Origin: origin } }); assert.equal(logout.status, 200); + assert.equal((await request('/api/dashboard', { headers: { Cookie: cookie } })).status, 401); +}); + +test('secure deployment cookie uses __Host prefix and Secure', async t => { + const { request } = await fixture(t, { COOKIE_SECURE: 'true', PUBLIC_ORIGIN: 'https://pawplay-ops.example.org' }); + const r = await request('/api/login', { method: 'POST', headers: { Origin: 'https://pawplay-ops.example.org', 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password }) }); + assert.equal(r.status, 200); assert.match(r.headers.get('set-cookie'), /^__Host-pawplay_session=/); assert.match(r.headers.get('set-cookie'), /; Secure/); assert.doesNotMatch(r.headers.get('set-cookie'), /Domain=/); +}); + +test('tampered session cannot access API', async t => { + const { request, login } = await fixture(t); const r = await login(); + const cookie = r.headers.get('set-cookie').split(';')[0]; + const mutated = cookie.slice(0, -1) + (cookie.endsWith('A') ? 'B' : 'A'); + assert.equal((await request('/api/dashboard', { headers: { Cookie: mutated } })).status, 401); +}); + +test('cross-origin login/logout are refused and repeated bad passwords are throttled', async t => { + const { request, login } = await fixture(t); + for (const endpoint of ['/api/login', '/api/logout']) { + assert.equal((await request(endpoint, { method: 'POST', headers: { Origin: 'https://attacker.invalid' }, body: '{}' })).status, 403); + assert.equal((await request(endpoint, { method: 'POST', body: '{}' })).status, 403); + } + for (let i = 0; i < 8; i++) assert.equal((await login('wrong')).status, 401); + const denied = await login(); assert.equal(denied.status, 429); assert.equal(denied.headers.get('retry-after'), '900'); +}); + +test('raw and encoded path traversal and symlinks never expose files outside data directory', async t => { + const { request, login, server, directory } = await fixture(t); + const r = await login(); const cookie = r.headers.get('set-cookie').split(';')[0]; + for (const target of ['/api/reports/%2e%2e%2fconfig', '/api/reports/a%2fb', '/api/reports/%252e%252e', '/api/reports/%00']) { + const response = await request(target, { headers: { Cookie: cookie } }); assert.equal(response.status, 400); + } + const rawStatus = await new Promise(resolve => { + const request = http.request({ host: '127.0.0.1', port: server.address().port, path: '/api/reports/../../config.json', headers: { Cookie: cookie } }, response => { response.resume(); resolve(response.statusCode); }); request.end(); + }); assert.equal(rawStatus, 400); + const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-outside-')); t.after(() => rm(outside, { recursive: true, force: true })); + await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ id: 'escape', secret: 'OUTSIDE-SECRET' })); + await symlink(path.join(outside, 'secret.json'), path.join(directory, 'reports/escape.json')); + const response = await request('/api/reports/escape', { headers: { Cookie: cookie } }); assert.equal(response.status, 500); assert.doesNotMatch(await response.text(), /OUTSIDE|secret|\/tmp/); +}); + +test('invalid JSON values and oversized bodies are rejected', async t => { + const { request } = await fixture(t); + const headers = { Origin: origin, 'Content-Type': 'application/json' }; + assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400); + assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413); +}); diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..239dfef --- /dev/null +++ b/ops/README.md @@ -0,0 +1,21 @@ +# 运营周报 + +每周五北京时间 20:00 生成报告,覆盖上周五 20:00 到本周五 20:00。阶段快照只覆盖截至生成时的部分周期,历史周报不覆盖改写。 + +```bash +python3 ops/report.py --data-dir "$PAWPLAY_OPS_DATA" --snapshot +python3 ops/report.py --data-dir "$PAWPLAY_OPS_DATA" --notify +python3 -m unittest discover -s ops -p 'test_*.py' -v +``` + +数据放在仓库外的私有目录,不加入 Git 或镜像。运营门户只读挂载该目录;格式见 `ops-portal/README.md`。`collector.json` 为本机采集配置,不由网站 API 返回,可包含 `repository`、`thread_id`、`codex_sessions_dir`、`backup_dir`、`bark_script` 和 `portal_url`。只保存模型用量汇总,不把对话、密钥或浏览器会话作为报告内容。 + +`findings.json` 的 `checked_at` 标注证据核验时间;`highlights` 可用字符串或 `{date,text}` 事件。过期成果不会重复计为本周完成。任务和风险持续保留,直到其状态有证据更新。 + +套餐按 200 美元/月、跨月实际时间和项目分摊比例记固定成本。周额度重置不产生新周费,套餐内 token 不按 API 单价重复计费。缓存输入、推理输出作为用量单列,不重复相加;日志计数不等于周额度百分比。额外 API、实际现金支出、已结算收入、预估收入分别记账。未知值使用 `null`,不当作零成本或零收入。 + +收入条目使用 `direction: income`、`basis: settled`,支出使用 `direction: expense`;`date` 使用含时区的时间戳。不同货币不直接相减;未确认汇率和完整支出前不宣称净利润。当前免费自研 App 的原创介绍正常推进,创作激励适用性按当前账号及具体稿件后台核验,不能仅凭旧公告预设无收益。 + +安装两个 systemd user unit 前,配置受保护的 `~/.config/pawplay-ops/report.env`,其中 `PAWPLAY_REPO` 指向项目目录,`PAWPLAY_OPS_DATA` 指向私有数据目录。`Persistent=true` 会在错过计划时间后补跑最近一期;不会追补所有历史周次。配置 `backup_dir` 时,生成报告同时将报告和账本复制到独立备份目录。 + +周报是数据归档任务,不会自动投稿、购买服务、生成视频或修改应用。发布稿件后,在 `content.json` 登记 BV 号;当前播放数据采集未接入,有已发布稿件时相关指标保留待核验。平台结算数据须按实际到账或后台结果记账。 diff --git a/ops/pawplay-weekly-report.service b/ops/pawplay-weekly-report.service new file mode 100644 index 0000000..98bce9f --- /dev/null +++ b/ops/pawplay-weekly-report.service @@ -0,0 +1,14 @@ +[Unit] +Description=PawPlay weekly operations report and archive +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +EnvironmentFile=%h/.config/pawplay-ops/report.env +ExecStart=/usr/bin/python3 ${PAWPLAY_REPO}/ops/report.py --data-dir ${PAWPLAY_OPS_DATA} --notify +UMask=0077 +TimeoutStartSec=180 +NoNewPrivileges=true +PrivateTmp=true + diff --git a/ops/pawplay-weekly-report.timer b/ops/pawplay-weekly-report.timer new file mode 100644 index 0000000..4749558 --- /dev/null +++ b/ops/pawplay-weekly-report.timer @@ -0,0 +1,11 @@ +[Unit] +Description=PawPlay report every Friday at 20:00 Asia/Shanghai + +[Timer] +OnCalendar=Fri *-*-* 20:00:00 Asia/Shanghai +Persistent=true +AccuracySec=1min +Unit=pawplay-weekly-report.service + +[Install] +WantedBy=timers.target diff --git a/ops/report.py b/ops/report.py new file mode 100644 index 0000000..2521827 --- /dev/null +++ b/ops/report.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Create a source-backed PawPlay weekly report without an LLM request.""" +import argparse +import calendar +import datetime as dt +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from zoneinfo import ZoneInfo + +TZ = ZoneInfo('Asia/Shanghai') + + +def read_json(path, default=None): + try: + return json.loads(Path(path).read_text()) + except FileNotFoundError: + return default + + +def save_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False, encoding='utf-8') as f: + json.dump(value, f, ensure_ascii=False, indent=2) + f.write('\n') + staging = f.name + os.replace(staging, path) + + +def subscription_allocation(budget, start, end): + """Prorate calendar days; token counts are never a second subscription bill.""" + price = budget.get('subscription_usd') + share = budget.get('project_allocation_fraction') + period = budget.get('billing_period') + if price is None or share is None or period not in ('monthly', 'weekly'): + return None + if not isinstance(start, dt.datetime): + start = dt.datetime.combine(start, dt.time(), TZ) + if not isinstance(end, dt.datetime): + end = dt.datetime.combine(end, dt.time(), TZ) + total = 0.0 + cursor = start.astimezone(TZ) + end = end.astimezone(TZ) + while cursor < end: + day = cursor.date() + divisor = calendar.monthrange(day.year, day.month)[1] if period == 'monthly' else 7 + next_midnight = dt.datetime.combine(day + dt.timedelta(days=1), dt.time(), TZ) + step_end = min(next_midnight, end) + total += float(price) * float(share) / divisor * ((step_end - cursor).total_seconds() / 86400) + cursor = step_end + return round(total, 2) + + +def session_metadata(path): + with path.open() as f: + for _ in range(4): + line = f.readline() + if not line: + break + try: + value = json.loads(line) + except ValueError: + continue + if value.get('type') == 'session_meta': + return value.get('payload', {}) + return {} + + +def codex_usage(session_root, thread_id, start, end): + """Use cumulative counter deltas, scoped to the chat and its descendants.""" + if not session_root or not thread_id: + return {'available': False, 'reason': '项目会话来源未配置'} + candidates = [] + for path in Path(session_root).rglob('*.jsonl'): + meta = session_metadata(path) + candidates.append((path, meta)) + allowed = {thread_id} + changed = True + while changed: + changed = False + for _, meta in candidates: + source = meta.get('source', {}) + spawn = source.get('subagent', {}).get('thread_spawn', {}) if isinstance(source, dict) else {} + if spawn.get('parent_thread_id') in allowed and meta.get('id') not in allowed: + allowed.add(meta['id']) + changed = True + totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens')} + sessions = 0 + for path, meta in candidates: + if meta.get('id') not in allowed: + continue + sessions += 1 + previous = dict.fromkeys(totals, 0) + with path.open() as handle: + for line in handle: + try: + event = json.loads(line) + payload = event.get('payload', {}) + if event.get('type') != 'event_msg' or payload.get('type') != 'token_count': + continue + current = (payload.get('info') or {}).get('total_token_usage') + if not current: + continue + stamp = dt.datetime.fromisoformat(event['timestamp'].replace('Z', '+00:00')) + if start <= stamp < end: + for key in totals: + totals[key] += max(0, int(current.get(key, 0)) - previous[key]) + previous = {k: int(current.get(k, 0)) for k in totals} + except (ValueError, KeyError, TypeError): + continue + return {'available': sessions > 0, 'session_count': sessions, **totals, + 'uncached_input_tokens': max(0, totals['input_tokens'] - totals['cached_input_tokens']), + 'measurement_note': '会话日志计数含重复处理的上下文和缓存;不是唯一文字数、周额度百分比或API账单。推理token包含于输出计数,不重复相加。'} + + +def build_report(data_dir, snapshot=False, now=None): + now = now or dt.datetime.now(TZ) + now = now.astimezone(TZ) + cutoff = now.replace(hour=20, minute=0, second=0, microsecond=0) - dt.timedelta(days=(now.weekday() - 4) % 7) + if cutoff > now: + cutoff -= dt.timedelta(days=7) + begin_stamp = cutoff if snapshot else cutoff - dt.timedelta(days=7) + end_stamp = now if snapshot else cutoff + start, end = begin_stamp.date(), end_stamp.date() + config = read_json(data_dir / 'config.json', {}) + collector = read_json(data_dir / 'collector.json', {}) + ledger = read_json(data_dir / 'ledger.json', {'entries': []}) + content = read_json(data_dir / 'content.json', {'items': []}) + tasks = read_json(data_dir / 'tasks.json', {'items': []}) + findings = read_json(data_dir / 'findings.json', {}) + api = read_json(data_dir / 'deepseek-usage.json', {}) + entries = [] + for entry in ledger.get('entries', []): + if entry.get('project') not in config.get('projects', ['pawplay']): + continue + stamp = entry.get('date', '') + try: + value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00')) + if value.tzinfo is None: + value = value.replace(tzinfo=TZ) + if begin_stamp <= value < end_stamp: + entries.append(entry) + except ValueError: + pass + published = [i for i in content.get('items', []) if i.get('status') == 'published' and i.get('bvid')] + costs = [e for e in entries if e.get('direction') == 'expense' and e.get('currency') == 'USD' and e.get('basis') == 'cash' and e.get('amount') is not None] + incomes = [e for e in entries if e.get('direction') == 'income' and e.get('currency') == 'CNY' and e.get('basis') == 'settled' and e.get('amount') is not None] + allocated = subscription_allocation(config.get('budget', {}), begin_stamp, end_stamp) + usage = codex_usage(collector.get('codex_sessions_dir'), collector.get('thread_id'), begin_stamp, end_stamp) + def observed_this_period(stamp): + try: + value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00')) + if value.tzinfo is None: + value = value.replace(tzinfo=TZ) + return begin_stamp <= value < end_stamp + except (AttributeError, ValueError): + return False + highlights = [] + for item in findings.get('highlights', []): + stamp = item.get('date') if isinstance(item, dict) else findings.get('checked_at') + if observed_this_period(stamp): + highlights.append(item.get('text', '') if isinstance(item, dict) else item) + risks = list(findings.get('risks', [])) + if not observed_this_period(findings.get('checked_at')): + risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。') + current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), None) + identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '') + report = { + 'id': identifier, + 'title': '账号运营' + ('阶段快照' if snapshot else '周报') + ' ' + end.isoformat(), + 'period_start': begin_stamp.isoformat(), 'period_end': end_stamp.isoformat(), + 'published_at': now.isoformat(), 'partial_period': snapshot, 'kind': 'snapshot' if snapshot else 'weekly', + 'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。', + 'highlights': highlights, + 'risks': risks, + 'next_actions': [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8], + 'decisions': findings.get('decisions', []), + 'metrics': { + 'published_project_videos': len(published), + 'project_video_views': 0 if not published else None, + 'project_game_sessions': None, + 'codex': usage, + 'deepseek': api or {'status': '未采集'}, + 'measurement_notes': ['只统计运营账本登记稿件,不计入账户其他历史内容。', '当前会话包含PawPlay、运营基础设施和虚拟人设研究,token仅能按该共享会话汇总,未精确归因到单个方向。', '游戏离线运行且未接入统计,不能推导活跃、留存或转化率。', '已发布稿件存在时,播放数据需接入稿件快照采集,当前不填造数值。'] + }, + 'costs': { + 'cash_usd': round(sum(e['amount'] for e in costs), 2) if config.get('cash_tracking_complete') else None, + 'confirmed_recorded_cash_usd': round(sum(e['amount'] for e in costs), 2), + 'allocated_usd': allocated, 'estimated_usd': None, + 'electricity_kwh': None, 'electricity_cny': None, + 'notes': ['固定套餐按计费周期的日历天数分摊;项目占比待确认时不报项目分摊金额。', '不按token再次给套餐内使用计费。DeepSeek/API如另计费,需用用量及实际费率/账单入账。', '电费需功率积分和电价,NAS/VPS及硬件折旧需确认项目分摊口径;不填虚构零成本。'] + }, + 'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None, + 'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']}, + 'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'}, + } + repo = collector.get('repository') + if repo: + r = subprocess.run(['git', '-C', repo, 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True) + report['sources']['repository_commit'] = r.stdout.strip() if r.returncode == 0 else None + target = data_dir / 'reports' / (identifier + '.json') + # A finalized weekly report is immutable. Manual corrections require a new revision. + if target.exists(): + return target, False + save_json(target, report) + return target, True + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--data-dir', required=True, type=Path) + parser.add_argument('--snapshot', action='store_true') + parser.add_argument('--notify', action='store_true') + args = parser.parse_args() + target, created = build_report(args.data_dir, args.snapshot) + print(json.dumps({'created': created, 'report': target.name}, ensure_ascii=False)) + collector = read_json(args.data_dir / 'collector.json', {}) + if created and collector.get('backup_dir'): + backup = Path(collector['backup_dir']) / target.stem + backup.mkdir(parents=True, exist_ok=True, mode=0o700) + for name in ('config.json', 'ledger.json', 'content.json', 'tasks.json', 'findings.json', 'deepseek-usage.json'): + if (args.data_dir / name).exists(): + shutil.copy2(args.data_dir / name, backup / name) + shutil.copy2(target, backup / target.name) + if created and args.notify: + script = collector.get('bark_script') + if script: + subprocess.run(['python3', script, 'PawPlay 周报已归档,可登录运营网站查看预算、收益及下周工作。', '--title', 'PawPlay:每周运营报告', '--url', collector.get('portal_url', '')], check=True) + + +if __name__ == '__main__': + main() diff --git a/ops/test_report.py b/ops/test_report.py new file mode 100644 index 0000000..bcb15b7 --- /dev/null +++ b/ops/test_report.py @@ -0,0 +1,58 @@ +import datetime as dt +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('report', Path(__file__).with_name('report.py')) +report = importlib.util.module_from_spec(spec) +spec.loader.exec_module(report) + + +class AccountingTests(unittest.TestCase): + def test_monthly_allocation_across_month_boundary(self): + budget = {'subscription_usd': 200, 'billing_period': 'monthly', 'project_allocation_fraction': 0.5} + value = report.subscription_allocation(budget, dt.date(2026, 10, 29), dt.date(2026, 11, 5)) + self.assertEqual(value, round((200 / 31 * 3 + 200 / 30 * 4) * 0.5, 2)) + + def test_unknown_period_or_share_is_not_zero(self): + self.assertIsNone(report.subscription_allocation({'subscription_usd': 200}, dt.date(2026, 11, 2), dt.date(2026, 11, 9))) + + def test_friday_twenty_hour_cutoff(self): + with tempfile.TemporaryDirectory() as directory: + path, _ = report.build_report(Path(directory), now=dt.datetime(2026, 11, 6, 20, tzinfo=report.TZ)) + value = report.read_json(path) + self.assertEqual(value['period_start'], '2026-10-30T20:00:00+08:00') + self.assertEqual(value['period_end'], '2026-11-06T20:00:00+08:00') + + def test_cross_month_proration_includes_partial_days(self): + budget = {'subscription_usd': 200, 'billing_period': 'monthly', 'project_allocation_fraction': 1} + value = report.subscription_allocation(budget, dt.datetime(2026, 10, 30, 20, tzinfo=report.TZ), dt.datetime(2026, 11, 6, 20, tzinfo=report.TZ)) + self.assertEqual(value, round(200 / 31 * 28 / 24 + 200 / 30 * 140 / 24, 2)) + + def test_duplicate_usage_events_are_not_counted_twice(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'test.jsonl' + meta = {'type': 'session_meta', 'payload': {'id': 'pawplay'}} + event = {'timestamp': '2026-11-03T08:00:00Z', 'type': 'event_msg', 'payload': {'type': 'token_count', 'info': {'total_token_usage': {'input_tokens': 100, 'cached_input_tokens': 80, 'output_tokens': 10, 'reasoning_output_tokens': 4, 'total_tokens': 110}}}} + import json + path.write_text('\n'.join(json.dumps(e) for e in [meta, event, event])) + result = report.codex_usage(directory, 'pawplay', dt.datetime(2026, 11, 2, tzinfo=report.TZ), dt.datetime(2026, 11, 9, tzinfo=report.TZ)) + self.assertEqual(result['total_tokens'], 110) + self.assertEqual(result['uncached_input_tokens'], 20) + self.assertEqual(result['output_tokens'], 10) + + def test_final_reports_are_immutable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + now = dt.datetime(2026, 11, 9, 9, tzinfo=report.TZ) + first, created = report.build_report(root, now=now) + self.assertTrue(created) + original = first.read_bytes() + second, created = report.build_report(root, now=now + dt.timedelta(hours=1)) + self.assertFalse(created) + self.assertEqual(original, second.read_bytes()) + + +if __name__ == '__main__': + unittest.main()