Add private operations portal and weekly accounting reports
This commit is contained in:
@@ -20,3 +20,7 @@ app/keystore/
|
||||
*.iml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local Python caches
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
*
|
||||
!package.json
|
||||
!server.mjs
|
||||
!password.mjs
|
||||
!public/
|
||||
!public/**
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
node_modules/
|
||||
*.log
|
||||
test-artifacts/
|
||||
@@ -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"]
|
||||
@@ -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$<base64url-salt>$<base64url-key>`。要求密码至少 16 字符;环境文件权限应为 `0600`,只由管理员访问。Compose 的 `env_file` 中将哈希值包在单引号内,避免 `$` 被插值。
|
||||
|
||||
使用自己的凭据配置环境后,运行 `npm start`。本地 HTTP 预览可以显式设置 `COOKIE_SECURE=false`,同时将 `PUBLIC_ORIGIN` 指向准确的 `http://localhost:<port>` 或 `http://127.0.0.1:<port>`。该开关拒绝非回环地址;生产环境必须保留默认安全 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、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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; }
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>PawPlay 运营台</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-view" class="login-shell" hidden>
|
||||
<section class="login-intro"><div class="wordmark">pawplay<span>运营台</span></div><p class="eyebrow">每一次进展,都留下记录</p><h1>把小小的游乐场,<br>慢慢做成一门事业。</h1><p>查看每周进展、投入与回报。<br>从第一条视频,到下一次产品更新。</p><div class="paw-decoration" aria-hidden="true">●<span>●</span>●<b>♥</b></div><p class="private-note">私有工作空间 · 登录后查看运营数据</p></section>
|
||||
<main class="login-card"><p class="eyebrow">欢迎回来</p><h2>登录运营台</h2><p class="muted">报告、预算与发布记录,在这里汇总。</p><form id="login-form"><label for="username">账号</label><input id="username" name="username" autocomplete="username" required maxlength="128"><label for="password">密码</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="1024"><button class="primary" type="submit">进入运营台 <span aria-hidden="true">↗</span></button><p id="login-error" role="alert" class="error"></p></form></main>
|
||||
</div>
|
||||
<div id="dashboard-view" hidden>
|
||||
<header class="topbar"><a class="wordmark" href="/">pawplay<span>运营台</span></a><div class="top-right"><span class="private-pill">私有档案</span><button id="logout" class="quiet">退出</button></div></header>
|
||||
<div class="workspace"><aside class="sidebar"><div class="sidebar-heading"><p class="eyebrow">每周运营</p><h2>进展档案</h2></div><nav id="report-nav" aria-label="历史周报"></nav><div class="sidebar-foot"><span class="status-dot"></span>北京时间归档<br><span class="muted">费用与收益独立核算</span></div></aside>
|
||||
<main class="main"><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
|
||||
</div>
|
||||
</div>
|
||||
<p id="loading" class="loading" role="status">正在打开运营台…</p>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 号;当前播放数据采集未接入,有已发布稿件时相关指标保留待核验。平台结算数据须按实际到账或后台结果记账。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+236
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user