run.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. package dualrun
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "sort"
  7. "time"
  8. )
  9. // 阶段 1 的冻结点:与 P0 实测一致的整秒 epoch(2026-03-31 10:00:00 +08:00)。
  10. //
  11. // 口径:两侧同源、**Truncate 到整秒**。理由见 P0 §6 组 2b:`SET timestamp` 连微秒都能冻,
  12. // 不取整就会出现"legacy 给 .123456、target 给 .000"的伪差异。
  13. const DefaultFrozenEpoch int64 = 1774922400
  14. // Options 是 stage1 的运行参数(全部来自 CLI)。
  15. type Options struct {
  16. // CaseSet:real100 / synthetic / all。
  17. CaseSet string
  18. // DryRun:只列 case,不执行。
  19. DryRun bool
  20. // OutJSON / OutMD 输出路径。
  21. OutJSON string
  22. OutMD string
  23. // Idempotency:每条 case 连跑两次比对结论。
  24. Idempotency bool
  25. // RealCount 真实基线条数(默认 100)。
  26. RealCount int
  27. // PerCat 合成每类条数(默认 5)。
  28. PerCat int
  29. // FrozenEpoch 冻结点(默认 DefaultFrozenEpoch)。
  30. FrozenEpoch int64
  31. // Now 选样用的"当前时间"(教龄换算用;零值取 time.Now)。
  32. Now time.Time
  33. }
  34. func (o *Options) normalize() {
  35. if o.CaseSet == "" {
  36. o.CaseSet = "all"
  37. }
  38. if o.RealCount <= 0 {
  39. o.RealCount = 100
  40. }
  41. if o.PerCat <= 0 {
  42. o.PerCat = 5
  43. }
  44. if o.FrozenEpoch == 0 {
  45. o.FrozenEpoch = DefaultFrozenEpoch
  46. }
  47. if o.Now.IsZero() {
  48. o.Now = time.Now()
  49. }
  50. if o.OutJSON == "" {
  51. o.OutJSON = "dualrun-stage1.json"
  52. }
  53. if o.OutMD == "" {
  54. o.OutMD = "dualrun-stage1.md"
  55. }
  56. }
  57. // RunStage1 执行阶段 1。
  58. //
  59. // 前置:调用方已 LoadConfigFromEnv 并传入;本函数内部会 Guard()(违反即 panic)。
  60. //
  61. // 出参:Report(同时已写入 OutJSON / OutMD);error 仅表示"环境层面无法继续"。
  62. func RunStage1(ctx context.Context, cfg Config, opts Options) (*Report, error) {
  63. opts.normalize()
  64. // ---- 守卫 1:host / 库名 / 口令(panic 语义见 Config.Guard)----
  65. cfg.Guard()
  66. report := &Report{
  67. Stage: "AC-15 stage1 (payroll chain)",
  68. GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
  69. CaseSet: opts.CaseSet,
  70. DryRun: opts.DryRun,
  71. FrozenEpoch: opts.FrozenEpoch,
  72. FrozenAt: time.Unix(opts.FrozenEpoch, 0).In(time.Local).Format("2006-01-02 15:04:05"),
  73. Guard: map[string]string{
  74. "host": maskHost(cfg.Host),
  75. "database": cfg.Name,
  76. "user": cfg.User,
  77. "口令": "已用 AC15_DB_PASS 传入(不记录)",
  78. },
  79. Assertions: map[string]string{},
  80. CaseCounts: map[string]int{},
  81. Summary: map[Attribution]int{},
  82. }
  83. db, err := cfg.Open()
  84. if err != nil {
  85. return nil, fmt.Errorf("连接失败:%w", err)
  86. }
  87. defer db.Close()
  88. // ---- 守卫 2:连上后核对 SELECT DATABASE() ----
  89. if err := AssertDatabase(db, cfg.Name); err != nil {
  90. return nil, fmt.Errorf("数据源守卫失败(SELECT DATABASE()):%w", err)
  91. }
  92. // ---- 启动静态断言:31 个 routine 里 sysdate( 必须为 0 ----
  93. nSys, err := AssertNoSysdate(db, cfg.Name)
  94. if err != nil {
  95. return nil, err
  96. }
  97. report.Assertions["sysdate_count"] = fmt.Sprintf("%d(必须为 0)", nSys)
  98. report.Assertions["frozen_clock"] = fmt.Sprintf("SET SESSION timestamp=%d;time_zone=+08:00;Truncate(time.Second)", opts.FrozenEpoch)
  99. report.Assertions["连接模型"] = "每个 case 一个新 PHP 进程(= 新连接);Go 侧另开只读连接选样"
  100. refs, err := loadRefs(db, cfg.Name)
  101. if err != nil {
  102. return nil, err
  103. }
  104. report.Assertions["参照表"] = fmt.Sprintf("payments=%d classify=%d courses=%d teachers=%d rules=%d",
  105. len(refs.pays), len(refs.classifies), len(refs.courseClass), len(refs.teachers), len(refs.detailByID))
  106. records, err := loadRecords(db, cfg.Name)
  107. if err != nil {
  108. return nil, err
  109. }
  110. report.Assertions["record_scan"] = fmt.Sprintf("%d 条(teacherid>0 且有 actual_start_time)", len(records))
  111. // ---- 组装 case ----
  112. var cases []Case
  113. if opts.CaseSet == "real100" || opts.CaseSet == "all" {
  114. cases = append(cases, BuildRealCases(records, refs, opts.RealCount, opts.Now)...)
  115. }
  116. if opts.CaseSet == "synthetic" || opts.CaseSet == "all" {
  117. syn, notes := BuildSyntheticCases(records, refs, opts.PerCat, opts.Now)
  118. cases = append(cases, syn...)
  119. report.MutationNotes = append(report.MutationNotes, notes...)
  120. }
  121. if len(cases) == 0 {
  122. return nil, fmt.Errorf("用例集 %q 组装结果为空", opts.CaseSet)
  123. }
  124. for _, c := range cases {
  125. report.CaseCounts[string(c.Kind)]++
  126. }
  127. // ---- dry-run:只列清单 ----
  128. if opts.DryRun {
  129. report.DryRunCases = cases
  130. if err := WriteJSON(report, opts.OutJSON); err != nil {
  131. return nil, err
  132. }
  133. if err := WriteMarkdown(report, opts.OutMD); err != nil {
  134. return nil, err
  135. }
  136. return report, nil
  137. }
  138. // ---- 实际执行 ----
  139. oracle, err := NewOracleRunner(cfg)
  140. if err != nil {
  141. return nil, err
  142. }
  143. defer oracle.Close()
  144. frozenAt := time.Unix(opts.FrozenEpoch, 0)
  145. // 事实依据:factsByRecord 用于第 2 步"复刻判定 vs SP 实返"比对
  146. factsByRecord := map[string]caseFacts{}
  147. for _, rec := range records {
  148. factsByRecord[rec.id] = refs.buildFacts(rec, opts.Now)
  149. }
  150. for _, c := range cases {
  151. // 单 case 失败不中断:记录后继续
  152. res := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
  153. report.Cases = append(report.Cases, res...)
  154. }
  155. // ---- 幂等:抽样重跑(默认前 3 条 + 全部合成里的第一条)----
  156. if opts.Idempotency {
  157. for i, c := range cases {
  158. if i >= 3 && i%len(cases) != 0 {
  159. if i >= 3 && i != len(cases)-1 {
  160. continue
  161. }
  162. }
  163. first := collectRows(report.Cases, c.ID)
  164. again := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
  165. second := collectRows(again, c.ID)
  166. report.Idempotency = append(report.Idempotency, IdempotencyCheck{
  167. CaseID: c.ID,
  168. First: first.attr,
  169. Second: second.attr,
  170. Same: first.attr == second.attr,
  171. RowsEqual: first.json == second.json,
  172. })
  173. }
  174. }
  175. // ---- 汇总 ----
  176. for _, cr := range report.Cases {
  177. report.Summary[cr.Attribution]++
  178. }
  179. if err := WriteJSON(report, opts.OutJSON); err != nil {
  180. return nil, err
  181. }
  182. if err := WriteMarkdown(report, opts.OutMD); err != nil {
  183. return nil, err
  184. }
  185. return report, nil
  186. }
  187. type rowSet struct {
  188. attr Attribution
  189. json string
  190. }
  191. func collectRows(results []CaseResult, caseID string) rowSet {
  192. for _, r := range results {
  193. if r.CaseID != caseID {
  194. continue
  195. }
  196. b, _ := json.Marshal(r.Rows)
  197. return rowSet{attr: r.Attribution, json: string(b)}
  198. }
  199. return rowSet{}
  200. }
  201. // runOne 跑一条 case(BothSupplement 时内部跑两次)。
  202. func runOne(ctx context.Context, oracle *OracleRunner, refs *refs, facts map[string]caseFacts,
  203. c Case, frozenAt time.Time, opts Options) []CaseResult {
  204. variants := []bool{c.IsSupplement}
  205. if c.BothSupplement {
  206. variants = []bool{false, true}
  207. }
  208. var out []CaseResult
  209. for _, sup := range variants {
  210. cc := c
  211. cc.IsSupplement = sup
  212. res := CaseResult{
  213. CaseID: c.ID,
  214. Category: c.Category,
  215. Kind: c.Kind,
  216. RecordID: c.RecordID,
  217. IsSupplement: sup,
  218. Note: c.Note,
  219. }
  220. if c.BothSupplement {
  221. res.CaseID = fmt.Sprintf("%s/sup=%v", c.ID, sup)
  222. }
  223. obs, err := oracle.Run(ctx, cc, frozenAt)
  224. if err != nil && obs == nil {
  225. res.Attribution = AttrEnvFailure
  226. res.Note = joinNote(res.Note, "oracle 进程失败:"+err.Error())
  227. out = append(out, res)
  228. continue
  229. }
  230. res.Oracle = obs
  231. tgt, terr := ComputeTarget(obs)
  232. res.Target = tgt
  233. if terr != nil {
  234. res.Attribution = AttrEnvFailure
  235. res.Note = joinNote(res.Note, "target 计算出错:"+terr.Error())
  236. out = append(out, res)
  237. continue
  238. }
  239. f := facts[c.RecordID]
  240. rows, attr, s1 := Attribute(AttrInput{
  241. Case: cc,
  242. Oracle: obs,
  243. Target: tgt,
  244. PredictedDetailID: f.rule.id,
  245. PredictedTier: tierLabel(f.tier),
  246. })
  247. // A24:结算必抛异常,异常本身不是失败;把它记进备注便于复核
  248. if obs.Exception != nil {
  249. res.Note = joinNote(res.Note, fmt.Sprintf("oracle 抛出 %s(A24 预期,异常前写入值已读回)", obs.Exception.Message))
  250. }
  251. res.Rows = rows
  252. res.Step1 = s1
  253. res.Attribution = CaseAttribution(rows, attr)
  254. out = append(out, res)
  255. }
  256. return out
  257. }
  258. func joinNote(a, b string) string {
  259. if a == "" {
  260. return b
  261. }
  262. return a + ";" + b
  263. }
  264. // SortedCaseIDs 便于测试与调试。
  265. func SortedCaseIDs(cases []Case) []string {
  266. ids := make([]string, 0, len(cases))
  267. for _, c := range cases {
  268. ids = append(ids, c.ID)
  269. }
  270. sort.Strings(ids)
  271. return ids
  272. }