update_state.py 22 KB

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