Add private operations portal and weekly accounting reports

This commit is contained in:
wuyang
2026-11-03 16:45:50 +08:00
parent 10c4487463
commit 20aea13054
18 changed files with 958 additions and 0 deletions
+4
View File
@@ -20,3 +20,7 @@ app/keystore/
*.iml
.DS_Store
Thumbs.db
# Local Python caches
__pycache__/
*.pyc
+6
View File
@@ -0,0 +1,6 @@
*
!package.json
!server.mjs
!password.mjs
!public/
!public/**
+6
View File
@@ -0,0 +1,6 @@
.env
.env.*
data/
node_modules/
*.log
test-artifacts/
+9
View File
@@ -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"]
+112
View File
@@ -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、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
+8
View File
@@ -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"}
}
+19
View File
@@ -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'));
}
+138
View File
@@ -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; }
})();
+24
View File
@@ -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
+8
View File
@@ -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');
+170
View File
@@ -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; }
}
+112
View File
@@ -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);
});
+21
View File
@@ -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 号;当前播放数据采集未接入,有已发布稿件时相关指标保留待核验。平台结算数据须按实际到账或后台结果记账。
+14
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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()
+58
View File
@@ -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()