update_state.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. #!/usr/bin/env python3
  2. """
  3. 安全的 state.json 更新脚本
  4. 功能:
  5. 1. 提供结构化的 state.json 更新接口
  6. 2. 自动验证 JSON 格式和数据完整性
  7. 3. 自动备份(带时间戳)
  8. 4. 支持部分更新(不影响其他字段)
  9. 5. 原子性操作(要么全部成功,要么全部回滚)
  10. 使用方式:
  11. # 更新主角状态
  12. python update_state.py --protagonist-power "金丹" 3 "雷劫"
  13. # 更新人际关系
  14. python update_state.py --relationship "李雪" affection 95
  15. # 记录伏笔
  16. python update_state.py --add-foreshadowing "神秘玉佩的秘密" "未回收"
  17. # 回收伏笔
  18. python update_state.py --resolve-foreshadowing "天雷果的下落" 45
  19. # 更新进度
  20. python update_state.py --progress 45 198765
  21. # 标记卷已规划
  22. python update_state.py --volume-planned 1 --chapters-range 1-100
  23. # 组合更新(原子性)
  24. python update_state.py \
  25. --protagonist-power "金丹" 3 "雷劫" \
  26. --progress 45 198765 \
  27. --relationship "李雪" affection 95 \
  28. --add-foreshadowing "神秘玉佩" "未回收"
  29. 安全特性:
  30. - 自动备份原文件(.backup_TIMESTAMP.json)
  31. - JSON 格式验证
  32. - Schema 完整性检查
  33. - 原子性操作(失败自动回滚)
  34. - Dry-run 模式(--dry-run)
  35. """
  36. import json
  37. import os
  38. import sys
  39. import argparse
  40. import shutil
  41. from pathlib import Path
  42. from runtime_compat import enable_windows_utf8_stdio
  43. from datetime import datetime
  44. from typing import Dict, Any, Optional
  45. # ============================================================================
  46. # 安全修复:导入安全工具函数(P1 MEDIUM)
  47. # ============================================================================
  48. from security_utils import create_secure_directory, atomic_write_json, restore_from_backup
  49. from project_locator import resolve_state_file
  50. from data_modules.state_validator import (
  51. normalize_foreshadowing_status,
  52. normalize_state_runtime_sections,
  53. )
  54. # Windows 编码兼容性修复
  55. if sys.platform == "win32":
  56. enable_windows_utf8_stdio()
  57. class StateUpdater:
  58. """state.json 安全更新器"""
  59. def __init__(self, state_file: str, dry_run: bool = False):
  60. self.state_file = state_file
  61. self.dry_run = dry_run
  62. self.backup_file = None
  63. self.state = None
  64. def _validate_schema(self, state: Dict) -> bool:
  65. """验证 state.json 的基本结构(v5.0 引入,v5.4 沿用)"""
  66. required_keys = [
  67. "project_info",
  68. "progress",
  69. "protagonist_state",
  70. "relationships",
  71. "world_settings",
  72. "plot_threads",
  73. "review_checkpoints"
  74. ]
  75. for key in required_keys:
  76. if key not in state:
  77. print(f"❌ 缺少必需字段: {key}")
  78. return False
  79. # 验证嵌套结构(支持两种格式:嵌套和平铺)
  80. ps = state["protagonist_state"]
  81. # power 字段:支持 power.realm 或直接 realm
  82. has_nested_power = "power" in ps and isinstance(ps.get("power"), dict)
  83. has_flat_power = "realm" in ps
  84. if not (has_nested_power or has_flat_power):
  85. print(f"❌ 缺少 protagonist_state.power 或 protagonist_state.realm 字段")
  86. return False
  87. # location 字段:支持 location.current 或直接 location
  88. has_nested_location = isinstance(ps.get("location"), dict) and "current" in ps.get("location", {})
  89. has_flat_location = isinstance(ps.get("location"), str)
  90. if not (has_nested_location or has_flat_location):
  91. print(f"❌ 缺少 protagonist_state.location 字段")
  92. return False
  93. # 验证并补全 strand_tracker 结构(兼容旧 state.json)
  94. tracker = state.get("strand_tracker")
  95. if tracker is None or not isinstance(tracker, dict):
  96. if tracker is None:
  97. print("⚠️ strand_tracker 缺失,已自动补全默认结构")
  98. else:
  99. print("⚠️ strand_tracker 类型异常,已重置默认结构")
  100. state["strand_tracker"] = {
  101. "last_quest_chapter": 0,
  102. "last_fire_chapter": 0,
  103. "last_constellation_chapter": 0,
  104. "current_dominant": "quest",
  105. "chapters_since_switch": 0,
  106. "history": [],
  107. }
  108. else:
  109. tracker.setdefault("last_quest_chapter", 0)
  110. tracker.setdefault("last_fire_chapter", 0)
  111. tracker.setdefault("last_constellation_chapter", 0)
  112. tracker.setdefault("current_dominant", "quest")
  113. tracker.setdefault("chapters_since_switch", 0)
  114. tracker.setdefault("history", [])
  115. normalize_state_runtime_sections(state)
  116. return True
  117. def load(self) -> bool:
  118. """加载并验证 state.json"""
  119. if not os.path.exists(self.state_file):
  120. print(f"❌ 状态文件不存在: {self.state_file}")
  121. return False
  122. try:
  123. with open(self.state_file, 'r', encoding='utf-8') as f:
  124. self.state = json.load(f)
  125. if not self._validate_schema(self.state):
  126. print("❌ state.json 结构不完整,请检查")
  127. return False
  128. return True
  129. except json.JSONDecodeError as e:
  130. print(f"❌ JSON 格式错误: {e}")
  131. return False
  132. def backup(self) -> bool:
  133. """备份当前 state.json"""
  134. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  135. backup_dir = Path(self.state_file).parent / "backups"
  136. # ============================================================================
  137. # 安全修复:使用安全目录创建函数(P1 MEDIUM)
  138. # 原代码: backup_dir.mkdir(exist_ok=True)
  139. # 漏洞: 未设置权限,使用OS默认(可能为755,允许同组用户读取)
  140. # ============================================================================
  141. create_secure_directory(str(backup_dir))
  142. self.backup_file = backup_dir / f"state.backup_{timestamp}.json"
  143. try:
  144. shutil.copy2(self.state_file, self.backup_file)
  145. print(f"✅ 已备份: {self.backup_file}")
  146. return True
  147. except Exception as e:
  148. print(f"❌ 备份失败: {e}")
  149. return False
  150. def save(self) -> bool:
  151. """保存更新后的 state.json(原子化写入)"""
  152. if self.dry_run:
  153. print("\n⚠️ Dry-run 模式,不执行实际写入")
  154. print("\n📄 预览更新后的内容:")
  155. print(json.dumps(self.state, ensure_ascii=False, indent=2))
  156. return True
  157. try:
  158. # 使用集中式原子写入(带 filelock + 自动备份)
  159. atomic_write_json(self.state_file, self.state, use_lock=True, backup=True)
  160. print(f"✅ 已保存(原子化): {self.state_file}")
  161. return True
  162. except Exception as e:
  163. print(f"❌ 保存失败: {e}")
  164. # 尝试从备份恢复
  165. if restore_from_backup(self.state_file):
  166. print(f"✅ 已从备份恢复")
  167. return False
  168. def update_protagonist_power(self, realm: str, layer: int, bottleneck: str):
  169. """更新主角实力(支持嵌套和平铺两种格式)"""
  170. ps = self.state["protagonist_state"]
  171. # 检测当前格式
  172. if "power" in ps and isinstance(ps.get("power"), dict):
  173. # 嵌套格式
  174. ps["power"] = {
  175. "realm": realm,
  176. "layer": layer,
  177. "bottleneck": bottleneck if bottleneck != "null" else None
  178. }
  179. else:
  180. # 平铺格式
  181. ps["realm"] = realm
  182. ps["layer"] = layer
  183. ps["bottleneck"] = bottleneck if bottleneck != "null" else None
  184. print(f"📝 更新主角实力: {realm} {layer}层, 瓶颈: {bottleneck}")
  185. def update_protagonist_location(self, location: str, chapter: int):
  186. """更新主角位置(支持嵌套和平铺两种格式)"""
  187. ps = self.state["protagonist_state"]
  188. # 检测当前格式
  189. if isinstance(ps.get("location"), dict):
  190. # 嵌套格式
  191. ps["location"] = {
  192. "current": location,
  193. "last_chapter": chapter
  194. }
  195. else:
  196. # 平铺格式
  197. ps["location"] = location
  198. ps["location_since_chapter"] = chapter
  199. print(f"📝 更新主角位置: {location}(第{chapter}章)")
  200. def update_golden_finger(self, name: str, level: int, cooldown: int):
  201. """更新金手指状态"""
  202. ps = self.state.setdefault("protagonist_state", {})
  203. golden_finger = ps.get("golden_finger")
  204. if not isinstance(golden_finger, dict):
  205. golden_finger = {}
  206. ps["golden_finger"] = golden_finger
  207. golden_finger.setdefault("skills", [])
  208. golden_finger["name"] = name
  209. golden_finger["level"] = level
  210. golden_finger["cooldown"] = cooldown
  211. print(f"📝 更新金手指: {name} Lv.{level}, 冷却: {cooldown}天")
  212. def update_relationship(self, char_name: str, key: str, value: Any):
  213. """更新人际关系"""
  214. if char_name not in self.state["relationships"]:
  215. self.state["relationships"][char_name] = {}
  216. self.state["relationships"][char_name][key] = value
  217. print(f"📝 更新关系: {char_name}.{key} = {value}")
  218. def add_foreshadowing(self, content: str, status: str = "未回收"):
  219. """添加伏笔"""
  220. if "foreshadowing" not in self.state["plot_threads"]:
  221. self.state["plot_threads"]["foreshadowing"] = []
  222. # 检查是否已存在
  223. for item in self.state["plot_threads"]["foreshadowing"]:
  224. if item.get("content") == content:
  225. print(f"⚠️ 伏笔已存在: {content}")
  226. return
  227. # 归一化状态,避免 "待回收/进行中/active/pending" 等混用导致下游过滤漏掉
  228. status = normalize_foreshadowing_status(status)
  229. planted_chapter = int(self.state.get("progress", {}).get("current_chapter", 0) or 0)
  230. if planted_chapter <= 0:
  231. planted_chapter = 1
  232. print("? 未找到有效 progress.current_chapter,默认 planted_chapter=1")
  233. target_chapter = planted_chapter + 100
  234. self.state["plot_threads"]["foreshadowing"].append({
  235. "content": content,
  236. "status": status,
  237. "added_at": datetime.now().strftime("%Y-%m-%d"),
  238. "planted_chapter": planted_chapter,
  239. "target_chapter": target_chapter,
  240. "tier": "支线"
  241. })
  242. print(f"📝 添加伏笔: {content}({status})")
  243. def resolve_foreshadowing(self, content: str, chapter: int):
  244. """回收伏笔"""
  245. if "foreshadowing" not in self.state["plot_threads"]:
  246. print(f"❌ 未找到伏笔列表")
  247. return
  248. for item in self.state["plot_threads"]["foreshadowing"]:
  249. if item.get("content") == content:
  250. item["status"] = "已回收"
  251. item["resolved_chapter"] = chapter
  252. item["resolved_at"] = datetime.now().strftime("%Y-%m-%d")
  253. normalize_state_runtime_sections(self.state)
  254. print(f"📝 回收伏笔: {content}(第{chapter}章)")
  255. return
  256. print(f"⚠️ 未找到伏笔: {content}")
  257. def update_progress(self, current_chapter: int, total_words: int):
  258. """更新创作进度"""
  259. self.state["progress"]["current_chapter"] = current_chapter
  260. self.state["progress"]["total_words"] = total_words
  261. self.state["progress"]["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  262. print(f"📝 更新进度: 第{current_chapter}章, 总字数: {total_words}")
  263. def mark_volume_planned(self, volume: int, chapters_range: str):
  264. """标记卷已规划"""
  265. if "volumes_planned" not in self.state["progress"]:
  266. self.state["progress"]["volumes_planned"] = []
  267. # 检查是否已存在
  268. for item in self.state["progress"]["volumes_planned"]:
  269. if item.get("volume") == volume:
  270. print(f"⚠️ 第{volume}卷已规划,更新章节范围")
  271. item["chapters_range"] = chapters_range
  272. item["updated_at"] = datetime.now().strftime("%Y-%m-%d")
  273. return
  274. self.state["progress"]["volumes_planned"].append({
  275. "volume": volume,
  276. "chapters_range": chapters_range,
  277. "planned_at": datetime.now().strftime("%Y-%m-%d")
  278. })
  279. print(f"📝 标记第{volume}卷已规划: 第{chapters_range}章")
  280. def add_review_checkpoint(self, chapters_range: str, report_file: str):
  281. """添加审查记录"""
  282. if "review_checkpoints" not in self.state:
  283. self.state["review_checkpoints"] = []
  284. self.state["review_checkpoints"].append({
  285. "chapters": chapters_range,
  286. "report": report_file,
  287. "reviewed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  288. })
  289. print(f"📝 添加审查记录: 第{chapters_range}章 → {report_file}")
  290. def update_strand_tracker(self, strand: str, chapter: int):
  291. """更新主导情节线(Strand Weave系统)"""
  292. # 验证 strand 参数
  293. valid_strands = ["quest", "fire", "constellation"]
  294. if strand.lower() not in valid_strands:
  295. print(f"❌ 无效的情节线类型: {strand}(有效值: quest, fire, constellation)")
  296. return False
  297. strand = strand.lower()
  298. # 初始化 strand_tracker(如果不存在)
  299. if "strand_tracker" not in self.state:
  300. self.state["strand_tracker"] = {
  301. "last_quest_chapter": 0,
  302. "last_fire_chapter": 0,
  303. "last_constellation_chapter": 0,
  304. "current_dominant": None,
  305. "chapters_since_switch": 0,
  306. "history": []
  307. }
  308. tracker = self.state["strand_tracker"]
  309. # 更新对应 strand 的最后章节
  310. tracker[f"last_{strand}_chapter"] = chapter
  311. # 判断是否切换 strand
  312. if tracker.get("current_dominant") != strand:
  313. tracker["current_dominant"] = strand
  314. tracker["chapters_since_switch"] = 1
  315. else:
  316. tracker["chapters_since_switch"] += 1
  317. # 添加到历史记录
  318. tracker["history"].append({
  319. "chapter": chapter,
  320. "dominant": strand
  321. })
  322. # 只保留最近50章的历史(避免文件过大)
  323. if len(tracker["history"]) > 50:
  324. tracker["history"] = tracker["history"][-50:]
  325. print(f"✅ strand_tracker 已更新")
  326. print(f" - 第{chapter}章主导情节线: {strand}")
  327. print(f" - 该情节线已连续{tracker['chapters_since_switch']}章")
  328. return True
  329. def main():
  330. parser = argparse.ArgumentParser(
  331. description="安全更新 state.json",
  332. formatter_class=argparse.RawDescriptionHelpFormatter,
  333. epilog="""
  334. 示例:
  335. # 更新主角实力
  336. python update_state.py --protagonist-power "金丹" 3 "雷劫"
  337. # 更新人际关系
  338. python update_state.py --relationship "李雪" affection 95
  339. # 添加伏笔
  340. python update_state.py --add-foreshadowing "神秘玉佩的秘密" "未回收"
  341. # 回收伏笔
  342. python update_state.py --resolve-foreshadowing "天雷果的下落" 45
  343. # 更新进度
  344. python update_state.py --progress 45 198765
  345. # 标记卷已规划
  346. python update_state.py --volume-planned 1 --chapters-range "1-100"
  347. # 组合更新(原子性)
  348. python update_state.py \
  349. --protagonist-power "金丹" 3 "雷劫" \
  350. --progress 45 198765 \
  351. --relationship "李雪" affection 95
  352. """
  353. )
  354. parser.add_argument(
  355. '--project-root',
  356. default=None,
  357. help='项目根目录(包含 .webnovel/state.json)。不提供时自动搜索(支持 webnovel-project/ 与父目录)。'
  358. )
  359. parser.add_argument(
  360. '--state-file',
  361. default=None,
  362. help='state.json 文件路径(可选)。不提供时从项目根目录自动定位为 .webnovel/state.json。'
  363. )
  364. parser.add_argument(
  365. '--dry-run',
  366. action='store_true',
  367. help='预览模式,不执行实际写入'
  368. )
  369. # 主角状态更新
  370. parser.add_argument(
  371. '--protagonist-power',
  372. nargs=3,
  373. metavar=('REALM', 'LAYER', 'BOTTLENECK'),
  374. help='更新主角实力(境界 层数 瓶颈)'
  375. )
  376. parser.add_argument(
  377. '--protagonist-location',
  378. nargs=2,
  379. metavar=('LOCATION', 'CHAPTER'),
  380. help='更新主角位置(地点 章节号)'
  381. )
  382. parser.add_argument(
  383. '--golden-finger',
  384. nargs=3,
  385. metavar=('NAME', 'LEVEL', 'COOLDOWN'),
  386. help='更新金手指(名称 等级 冷却天数)'
  387. )
  388. # 人际关系更新
  389. parser.add_argument(
  390. '--relationship',
  391. nargs=3,
  392. action='append',
  393. metavar=('CHAR_NAME', 'KEY', 'VALUE'),
  394. help='更新人际关系(角色名 属性 值)'
  395. )
  396. # 伏笔管理
  397. parser.add_argument(
  398. '--add-foreshadowing',
  399. nargs=2,
  400. metavar=('CONTENT', 'STATUS'),
  401. help='添加伏笔(内容 状态)'
  402. )
  403. parser.add_argument(
  404. '--resolve-foreshadowing',
  405. nargs=2,
  406. metavar=('CONTENT', 'CHAPTER'),
  407. help='回收伏笔(内容 章节号)'
  408. )
  409. # 进度更新
  410. parser.add_argument(
  411. '--progress',
  412. nargs=2,
  413. type=int,
  414. metavar=('CHAPTER', 'WORDS'),
  415. help='更新进度(当前章节 总字数)'
  416. )
  417. # 卷规划
  418. parser.add_argument(
  419. '--volume-planned',
  420. type=int,
  421. metavar='VOLUME',
  422. help='标记卷已规划(卷号)'
  423. )
  424. parser.add_argument(
  425. '--chapters-range',
  426. metavar='RANGE',
  427. help='章节范围(如 "1-100")'
  428. )
  429. # 审查记录
  430. parser.add_argument(
  431. '--add-review',
  432. nargs=2,
  433. metavar=('CHAPTERS_RANGE', 'REPORT_FILE'),
  434. help='添加审查记录(章节范围 报告文件)'
  435. )
  436. # Strand Tracker 更新
  437. parser.add_argument(
  438. '--strand-dominant',
  439. nargs=2,
  440. metavar=('STRAND', 'CHAPTER'),
  441. help='更新主导情节线(quest/fire/constellation 章节号)'
  442. )
  443. args = parser.parse_args()
  444. # 如果没有任何更新参数,显示帮助并退出
  445. if not any([
  446. args.protagonist_power,
  447. args.protagonist_location,
  448. args.golden_finger,
  449. args.relationship,
  450. args.add_foreshadowing,
  451. args.resolve_foreshadowing,
  452. args.progress,
  453. args.volume_planned,
  454. args.add_review,
  455. args.strand_dominant
  456. ]):
  457. parser.print_help()
  458. sys.exit(1)
  459. # 解析 state.json 路径(支持从仓库根目录运行)
  460. state_file_path = resolve_state_file(args.state_file, explicit_project_root=args.project_root)
  461. # 创建更新器
  462. updater = StateUpdater(str(state_file_path), args.dry_run)
  463. # 加载状态文件
  464. if not updater.load():
  465. sys.exit(1)
  466. # 备份(除非是 dry-run)
  467. if not args.dry_run:
  468. if not updater.backup():
  469. sys.exit(1)
  470. print("\n📝 开始更新...")
  471. # 执行更新操作
  472. try:
  473. if args.protagonist_power:
  474. realm, layer, bottleneck = args.protagonist_power
  475. updater.update_protagonist_power(realm, int(layer), bottleneck)
  476. if args.protagonist_location:
  477. location, chapter = args.protagonist_location
  478. updater.update_protagonist_location(location, int(chapter))
  479. if args.golden_finger:
  480. name, level, cooldown = args.golden_finger
  481. updater.update_golden_finger(name, int(level), int(cooldown))
  482. if args.relationship:
  483. for char_name, key, value in args.relationship:
  484. # 尝试转换为数字
  485. try:
  486. value = int(value)
  487. except ValueError:
  488. pass
  489. updater.update_relationship(char_name, key, value)
  490. if args.add_foreshadowing:
  491. content, status = args.add_foreshadowing
  492. updater.add_foreshadowing(content, status)
  493. if args.resolve_foreshadowing:
  494. content, chapter = args.resolve_foreshadowing
  495. updater.resolve_foreshadowing(content, int(chapter))
  496. if args.progress:
  497. chapter, words = args.progress
  498. updater.update_progress(chapter, words)
  499. if args.volume_planned:
  500. if not args.chapters_range:
  501. print("❌ --volume-planned 需要 --chapters-range 参数")
  502. sys.exit(1)
  503. updater.mark_volume_planned(args.volume_planned, args.chapters_range)
  504. if args.add_review:
  505. chapters_range, report_file = args.add_review
  506. updater.add_review_checkpoint(chapters_range, report_file)
  507. # Strand Tracker 更新
  508. if args.strand_dominant:
  509. strand, chapter = args.strand_dominant
  510. updater.update_strand_tracker(strand, int(chapter))
  511. # 保存更新
  512. if not updater.save():
  513. sys.exit(1)
  514. print("\n✅ 更新完成!")
  515. if not args.dry_run:
  516. print(f"\n💡 提示:")
  517. print(f" - 原文件已备份: {updater.backup_file}")
  518. print(f" - 如需回滚,可复制备份文件到 {updater.state_file}")
  519. except Exception as e:
  520. print(f"\n❌ 更新失败: {e}")
  521. if updater.backup_file and os.path.exists(updater.backup_file):
  522. print(f"🔄 正在回滚...")
  523. shutil.copy2(updater.backup_file, updater.state_file)
  524. print(f"✅ 已回滚到备份版本")
  525. sys.exit(1)
  526. if __name__ == "__main__":
  527. main()