Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ dist-ssr
*.sln
*.sw?
stats.html
.vite-dev*.log
.pnpm-store/

# 本地审计导出
audit/*
!audit/backend-pr/

.commit-msg.txt
_tmp_autopcr_patchgen/
50 changes: 50 additions & 0 deletions audit/backend-pr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 半月刊字段化 — 后端 PR 素材(提交流程说明)

## 背景
Discord 讨论定案:半月刊(half_schedule, `autopcr/module/modules/nologin.py`)的解析逻辑
挪进 `db`(database.py)返回结构化数据,供 module 渲染与网站 API 共用;网页端基于此做
「复刻活动/特别地下城/新斗技场/公会战开启」通知,不再依赖运行任务读警报。

## 重要约束(为什么这个目录里只有素材没有直接改动)
后端仓库 U:\autopcr(cc004/autopcr,dev 分支)正在被本机 Docker 容器运行,工作区不能直接改。
本目录保存已完成的设计与代码素材,PR 需要在后端仓库的独立分支上落地。

## 提 PR 步骤(在后端仓库做)
1. cd U:\autopcr
2. git checkout -b feat/schedule-entries origin/dev
3. 按本目录两个 .py 素材文件落改动:
- database_schedule_entries.py 的方法体 → 粘进 autopcr/db/database.py 的
database 类(放 is_clan_battle_time 之前即可;List/Counter 已在文件头有 import)
- nologin_half_schedule.py 的 do_task → 替换 autopcr/module/modules/nologin.py
half_schedule 类里现有的 do_task(schedule_sources 静态方法可保留,已无消费者,也可一并删)
4. 本地验证:
- python -c "from autopcr.db.dbstart import db_start; db_start(); from autopcr.db.database import db; e=db.schedule_entries(); print(len(e), e[0])"
(必须先 db_start():lazy_property 在 dbmgr 未初始化时直接抛 ValueError;db_start 会连带拉起登录依赖,属预期)
- 跑一次半月刊 module,输出应与改动前逐行一致(排序键 (start, end, 源清单顺序),组内顺序=源清单顺序)
5. httpserver.py 加只读端点(不挂 login_required 装饰器即为公开,与 /validate 同风格;
注意 /clan_forbid 其实带 login+admin,别学它):
@self.api.route('/schedule', methods=["GET"])
async def get_schedule():
return db.schedule_entries(), 200
(注意 schedule_entries 每次调用都全表扫 17 张红标表,量级小可接受;若在意可加 lazy_property 缓存,
但 enabled 依赖当前时间,缓存须带 TTL——首版建议不加)
6. git add autopcr/db/database.py autopcr/module/modules/nologin.py autopcr/http_server/httpserver.py
git commit;push 到自己 fork 或分支;向 cc004/autopcr 提 PR。

## 字段化数据形状
每条日程:{ key, category, start_time, end_time, description }
- key:稳定唯一("类别:来源主键",同主键多来源加 #序号),通知侧已读去重直接用它
- category:半月刊渲染的类别名(公会战/特别地下城/新斗技场/庆典/活动/扭蛋/...)
- start_time / end_time:YYYY/MM/DD(与半月刊渲染一致)
- description:与半月刊逐行渲染完全相同的文本(up 名单、掉落倍率等都在这里面);
扭蛋条目若 gacha_name 含 フェス/FES,前缀加 fes|(前端折叠为「up 首人 fes扭蛋」)

## 前端侧状态(已在 AutoPCR_Web 实现,等后端 API 合入即切换)
- GET /daily/api/schedule → ScheduleEntry[](vite proxy 指向真后端即自动生效,前端零改动)
- ScheduleNotifyWatcher(挂 _sidebar)+ 面板(DashBoard 工具栏):
- 展示口径:end_time > 今天 的条目;噪声过滤(玩家经验值加成、公会战排名公示整类)
- 类别:固定清单 13 类(活动/女神祭/庆典/扭蛋/免费十连/公会战/特别地下城/新斗技场/季卡驾车游/露娜塔/次元断层/深渊讨伐战/赛马);
驾车游并入季卡、斗技场/登录奖励前端不显示;女神祭由 description 含「女神祭」从活动拆出
- 扭蛋折叠:description 带 fes| 前缀(= gacha_name 含 フェス/FES,由后端 schedule_entries 织入)→「up 首人 fes扭蛋」;
普通长名单 →「up 前两名……等N人」
- 通知:勾选类别条目 start_time == 今天 且到设定时刻 → 浏览器通知(每 key 当天一次,localStorage 已读 90 天自清理)
69 changes: 69 additions & 0 deletions audit/backend-pr/database_schedule_entries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
def schedule_entries(self) -> List[dict]:
"""字段化全部日程(纯静态红标数据,不依赖账号/登录)。半月刊 module 与网站 API 共用此单一来源。
每条:{key, category, start_time, end_time, description}
- key: 稳定唯一标识("类别:行主键",hatsune+seven 同 event_id 时加 #序号),通知侧用它做已读去重
- category: 语义类别(公会战/特别地下城/庆典/活动/...),调用方按需过滤
- description: 与半月刊渲染文本一致的展示文案;扭蛋 fes 池带 fes| 前缀(渲染侧需剥离,见 half_schedule.do_task)
"""
from ..module.modules.nologin import (
ClanBattlePeriod, SecretDungeonSchedule, GachaDatum, CampaignSchedule,
CampaignFreegacha, HatsuneSchedule, TowerSchedule, TdfSchedule,
ColosseumScheduleData, DomeScheduleData, AbyssSchedule, CaravanSchedule,
CharaFortuneSchedule, LoginBonusDatum, SeasonpassFoundation,
)

# (来源表, 工厂);与半月刊原 schedule_sources 清单一致(顺序即渲染组内顺序)
sources = [
(self.clan_battle_period, lambda x: ClanBattlePeriod(x.start_time, x.end_time, "公会战")),
(self.clan_battle_period, lambda x: ClanBattlePeriod(x.result_start, x.result_end, "公会战排名公示")),
(self.secret_dungeon_schedule, lambda x: SecretDungeonSchedule(x.start_time, x.end_time, "特别地下城")),
(self.seasonpass_foundation, lambda x: SeasonpassFoundation(x.name, x.start_time, x.end_time, "季卡")),
(self.gacha_data, lambda x: GachaDatum(x.gacha_id, x.exchange_id, x.start_time, x.end_time, "扭蛋")),
(self.campaign_schedule, lambda x: CampaignSchedule(x.id, x.campaign_category, x.value, x.start_time, x.end_time, "庆典")),
(self.campaign_free_gacha, lambda x: CampaignFreegacha(x.campaign_id, x.start_time, x.end_time, "免费十连")),
(self.hatsune_schedule, lambda x: HatsuneSchedule(x.event_id, x.start_time, x.end_time, "活动")),
(self.seven_schedule, lambda x: HatsuneSchedule(x.event_id, x.start_time, x.end_time, "活动")),
(self.tower_schedule, lambda x: TowerSchedule(x.start_time, x.end_time, "露娜塔")),
(self.tdf_schedule, lambda x: TdfSchedule(x.start_time, x.end_time, "次元断层")),
(self.chara_fortune_schedule, lambda x: CharaFortuneSchedule(x.name, x.start_time, x.end_time, "赛马")),
(self.login_bonus_data, lambda x: LoginBonusDatum(x.name, x.start_time, x.end_time, "登录奖励")),
(self.colosseum_schedule_data, lambda x: ColosseumScheduleData(x.start_time, x.end_time, "斗技场")),
(self.caravan_schedule, lambda x: CaravanSchedule(x.season_id, x.start_time, x.end_time, "驾车游")),
(self.dome_schedule_data, lambda x: DomeScheduleData(x.start_time, x.end_time, "新斗技场")),
(self.abyss_schedule, lambda x: AbyssSchedule(x.talent_id, x.start_time, x.end_time, "深渊讨伐战")),
]

entries: List[dict] = []
counters: Counter = Counter()
order = 0
for table, factory in sources:
# key 生成必须在 enabled 过滤之前:行从 enabled 转 disabled 时,后续行 #n 不得漂移
for row_key, row in table.items():
schedule = factory(row)
# key = 语义类别 + 行主键(dict 键即行主键,不取 name 等文本列——防改名/含分隔符导致 key 漂移)
base_key = ':'.join([schedule.description, str(row_key)])
counters[base_key] += 1
stable_key = base_key if counters[base_key] == 1 else base_key + '#' + str(counters[base_key])
if not schedule.enabled:
continue
desc = schedule.get_description()
# 扭蛋条目:官方池名 gacha_name 含 フェス/FES 判定为 fes 池,织入标记供通知侧折叠(只留第一人 + fes扭蛋);
# 渲染侧(half_schedule.do_task)负责剥离前缀,保证半月刊输出 parity
if schedule.description == "扭蛋":
gacha_name = row.gacha_name or ''
if ('フェス' in gacha_name) or ('FES' in gacha_name.upper()): # 宽松子串匹配是有意的
desc = 'fes|' + desc
entries.append({
'key': stable_key,
'category': schedule.description,
'start_time': self.format_date(self.parse_time(schedule.start_time)),
'end_time': self.format_date(self.parse_time(schedule.end_time)),
'description': desc,
'_order': order,
})
order += 1
# 排序与半月刊旧实现一致:(start, end, 源清单顺序)——不得用 key 码点序,否则组内顺序漂移破坏 parity
entries.sort(key=lambda e: (e['start_time'], e['end_time'], e['_order']))
for e in entries:
e.pop('_order')
return entries
10 changes: 10 additions & 0 deletions audit/backend-pr/httpserver_schedule_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# autopcr/http_server/httpserver.py — 两处改动:
# ① 文件顶部 import 区(accountmgr import 之后)补(全文件当前没有 db 导入,不加必 NameError):
# from ..db.database import db
# ② configure_routes 内(put_clan_forbid PUT 块之后)加路由。
# 公开只读:不挂 login_required 即为公开(先例 /validate,httpserver.py:501);
# 别学 /clan_forbid——它挂了 login+admin,不是公开路由。
@self.api.route('/schedule', methods=["GET"])
async def get_schedule():
"""半月刊结构化日程(字段化,无账号依赖)。网页端通知与本 module 渲染共用数据源。"""
return db.schedule_entries(), 200
18 changes: 18 additions & 0 deletions audit/backend-pr/nologin_half_schedule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
async def do_task(self, _: pcrclient):
# 结构化数据单一来源:db.schedule_entries()(字段化供 API/通知复用),这里只做渲染
schedules = defaultdict(list)
for entry in db.schedule_entries():
schedules[(entry['start_time'], entry['end_time'])].append(entry['description'])
times = sorted(schedules.keys())
mirai = False
for time in times:
st = time[0]
ed = time[1]
if not mirai and db.parse_time(st) > datetime.now():
mirai = True
self._log("\n====未来日程====")
self._log(f"{st} - {ed}")
for msg in schedules[time]:
if msg.startswith('fes|'):
msg = msg[4:] # 剥离 fes 标记(API 契约字段,半月刊渲染不带前缀,保持 parity)
self._log(f" {msg}")
Binary file added audit/backend-pr/schedule-entries.patch
Binary file not shown.
7 changes: 7 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoPCR</title>
<style>
/* 恒定保留文档级滚动条槽位:弹层(日程通知面板等)开合时页面宽度不再抖动 */
html {
scrollbar-gutter: stable;
overflow-y: scroll;
}
</style>
</head>
<body>
<div id="root"></div>
Expand Down
84 changes: 84 additions & 0 deletions scripts/apply-backend-pr.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const fs = require('fs');
const root = 'U:/autopcr/web/autopcr-main';
const mat = fs.readFileSync('U:/autopcr/web/AutoPCR_Web/audit/backend-pr/database_schedule_entries.py', 'utf8');

// 1) database.py
const dbp = root + '/autopcr/db/database.py';
let db = fs.readFileSync(dbp, 'utf8');
if (db.includes('def schedule_entries')) {
console.log('database.py: schedule_entries already present');
} else {
const marker = ' def is_clan_battle_time(self, now: Union[None, datetime.datetime] = None) -> bool:';
const i = db.indexOf(marker);
if (i === -1) throw new Error('is_clan_battle_time marker not found');
db = db.slice(0, i) + mat.replace(/\n$/, '') + '\n\n' + db.slice(i);
fs.writeFileSync(dbp, db, 'utf8');
console.log('database.py: schedule_entries inserted');
}

// 2) httpserver.py
const hpp = root + '/autopcr/http_server/httpserver.py';
let hp = fs.readFileSync(hpp, 'utf8');
if (!hp.includes('from ..db.database import db')) {
const m2 = hp.match(/(from \.\.constants import[^\n]*\n)/);
if (!m2) throw new Error('constants import anchor not found');
hp = hp.replace(m2[1], m2[1] + 'from ..db.database import db\n');
console.log('httpserver.py: db import added');
}
if (!hp.includes("'/schedule'")) {
const pf = hp.indexOf('async def put_clan_forbid():');
if (pf === -1) throw new Error('put_clan_forbid not found');
const retMark = "return f'设置成功,禁止了{len(data)}个账号', 200";
const ri = hp.indexOf(retMark, pf);
if (ri === -1) throw new Error('put_clan_forbid return not found');
const insertAt = hp.indexOf('\n', ri) + 1;
const route = [
'',
" @self.api.route('/schedule', methods = [\"GET\"])",
' async def get_schedule():',
' """半月刊结构化日程(字段化,无账号依赖)。网页端通知与本 module 渲染共用数据源。"""',
' return db.schedule_entries(), 200',
].join('\n') + '\n';
hp = hp.slice(0, insertAt) + route + hp.slice(insertAt);
console.log('httpserver.py: /schedule route added');
}
fs.writeFileSync(hpp, hp, 'utf8');

// 3) nologin.py do_task
const nlp = root + '/autopcr/module/modules/nologin.py';
let nl = fs.readFileSync(nlp, 'utf8');
if (nl.includes('db.schedule_entries()')) {
console.log('nologin.py: do_task already consumes schedule_entries');
} else {
const start = nl.indexOf(' async def do_task(self, _: pcrclient):', nl.indexOf('class half_schedule'));
if (start === -1) throw new Error('half_schedule.do_task not found');
const tailMark = 'self._log(f" {msg}")';
const ti = nl.indexOf(tailMark, start);
if (ti === -1) throw new Error('do_task tail not found');
const end = nl.indexOf('\n', ti) + 1;
const newDo = [
' async def do_task(self, _: pcrclient):',
' # 结构化数据单一来源:db.schedule_entries()(字段化供 API/通知复用),这里只做渲染',
' schedules = defaultdict(list)',
' for entry in db.schedule_entries():',
" msg = entry['description']",
" if msg.startswith('fes|'):",
' msg = msg[4:] # 剥离 fes 标记(通知侧契约),半月刊渲染保持 parity',
" schedules[(entry['start_time'], entry['end_time'])].append(msg)",
' times = sorted(schedules.keys())',
' mirai = False',
' for time in times:',
' st = time[0]',
' ed = time[1]',
' if not mirai and db.parse_time(st) > datetime.now():',
' mirai = True',
' self._log("\\n====未来日程====")',
' self._log(f"{st} - {ed}")',
' for msg in schedules[time]:',
' self._log(f" {msg}")',
].join('\n') + '\n';
nl = nl.slice(0, start) + newDo + nl.slice(end);
fs.writeFileSync(nlp, nl, 'utf8');
console.log('nologin.py: do_task replaced');
}
console.log('ALL DONE');
35 changes: 35 additions & 0 deletions scripts/mock-schedule-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 半月刊 /schedule 联调 mock:数据逐条对照真实半月刊页面(2026/09/10 时点快照)。
* 用法:node scripts/mock-schedule-server.mjs → 监听 13201;配合 AUTOPCR_SERVER_HOST=http://localhost:13201 起 vite。
*/
import { createServer } from 'node:http';

const rows = [
{ key: '季卡:1', category: '季卡', start_time: '2026/07/01', end_time: '2026/09/15', description: '驾车游第9季' },
{ key: '扭蛋:2', category: '扭蛋', start_time: '2026/08/31', end_time: '2026/09/08', description: 'up 艾拉,紫罗兰,菲欧,格蕾丝,白菲,涅妃,银莲,els,琉璃,莱莱,机娘,坏女人,龙安,爱梅斯,咲哈哈,兰法,小小甜心,龙妈,星栞,风剑,白猫,火猫,晶,飞田,蝶妈,高达,似似花,611,克总' },
{ key: '活动:3', category: '活动', start_time: '2026/08/31', end_time: '2026/09/23', description: ', And I Will \n牢牢紧握在手中' },
{ key: '活动:4', category: '活动', start_time: '2026/08/31', end_time: '2026/10/30', description: '女神祭' },
{ key: '庆典:5', category: '庆典', start_time: '2026/09/01', end_time: '2026/09/23', description: '活动 玩家经验值*1.5' },
{ key: '庆典:6', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/15', description: '圣迹 掉落*2.0' },
{ key: '庆典:7', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/15', description: '神殿 掉落*2.0' },
{ key: '庆典:8', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/24', description: 'vh 掉落*2.0' },
{ key: '庆典:9', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/24', description: 'vh mana*2.0' },
{ key: '公会战排名公示:10', category: '公会战排名公示', start_time: '2026/09/07', end_time: '2026/09/28', description: '公会战排名公示' },
{ key: '新斗技场:11', category: '新斗技场', start_time: '2026/09/08', end_time: '2026/09/12', description: '新斗技场' },
{ key: '扭蛋:12', category: '扭蛋', start_time: '2026/09/08', end_time: '2026/09/15', description: 'up 诗夏,紫罗兰,菲欧,格蕾丝,白菲,涅妃,银莲,els,琉璃,莱莱,机娘,坏女人,龙安,爱梅斯,咲哈哈,兰法,小小甜心,龙妈,星栞,风剑,白猫,火猫,晶,飞田,蝶妈,高达,似似花,611,克总' },
{ key: '庆典:13', category: '庆典', start_time: '2026/09/15', end_time: '2026/09/20', description: 'normal 掉落*3.0' },
{ key: '庆典:14', category: '庆典', start_time: '2026/09/15', end_time: '2026/09/29', description: '地下城 mana*2.0' },
{ key: '活动:15', category: '活动', start_time: '2026/09/16', end_time: '2026/09/23', description: '幻惑的妖精 \n‐盛夏乐园中舞动的身影‐' },
{ key: '公会战:16', category: '公会战', start_time: '2026/09/26', end_time: '2026/09/30', description: '公会战' },
{ key: '公会战排名公示:17', category: '公会战排名公示', start_time: '2026/10/07', end_time: '2026/10/28', description: '公会战排名公示' },
];
createServer((req, res) => {
const url = req.url ?? '';
if (url.startsWith('/daily/api/schedule')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rows));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end('{}');
}).listen(13201, () => console.log('mock schedule server on http://localhost:13201'));
Loading
Loading