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
+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()