run.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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. // ---- 幂等:**所有 case 各跑第二遍**(抽样会漏,差异是逐 case 的)----
  156. if opts.Idempotency {
  157. for i, c := range cases {
  158. _ = i
  159. first := collectRows(report.Cases, c.ID)
  160. again := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
  161. second := collectRows(again, c.ID)
  162. report.Idempotency = append(report.Idempotency, IdempotencyCheck{
  163. CaseID: c.ID,
  164. First: first.attr,
  165. Second: second.attr,
  166. Same: first.attr == second.attr,
  167. RowsEqual: first.json == second.json,
  168. })
  169. }
  170. }
  171. // ---- 汇总 ----
  172. for _, cr := range report.Cases {
  173. report.Summary[cr.Attribution]++
  174. }
  175. if err := WriteJSON(report, opts.OutJSON); err != nil {
  176. return nil, err
  177. }
  178. if err := WriteMarkdown(report, opts.OutMD); err != nil {
  179. return nil, err
  180. }
  181. return report, nil
  182. }
  183. type rowSet struct {
  184. attr Attribution
  185. json string
  186. }
  187. func collectRows(results []CaseResult, caseID string) rowSet {
  188. for _, r := range results {
  189. if r.CaseID != caseID {
  190. continue
  191. }
  192. b, _ := json.Marshal(r.Rows)
  193. return rowSet{attr: r.Attribution, json: string(b)}
  194. }
  195. return rowSet{}
  196. }
  197. // runOne 跑一条 case(BothSupplement 时内部跑两次)。
  198. func runOne(ctx context.Context, oracle *OracleRunner, refs *refs, facts map[string]caseFacts,
  199. c Case, frozenAt time.Time, opts Options) []CaseResult {
  200. variants := []bool{c.IsSupplement}
  201. if c.BothSupplement {
  202. variants = []bool{false, true}
  203. }
  204. var out []CaseResult
  205. for _, sup := range variants {
  206. cc := c
  207. cc.IsSupplement = sup
  208. res := CaseResult{
  209. CaseID: c.ID,
  210. Category: c.Category,
  211. Kind: c.Kind,
  212. RecordID: c.RecordID,
  213. IsSupplement: sup,
  214. Note: c.Note,
  215. }
  216. if c.BothSupplement {
  217. res.CaseID = fmt.Sprintf("%s/sup=%v", c.ID, sup)
  218. }
  219. obs, err := oracle.Run(ctx, cc, frozenAt)
  220. if err != nil && obs == nil {
  221. res.Attribution = AttrEnvFailure
  222. res.Note = joinNote(res.Note, "oracle 进程失败:"+err.Error())
  223. out = append(out, res)
  224. continue
  225. }
  226. res.Oracle = obs
  227. tgt, terr := ComputeTarget(obs)
  228. res.Target = tgt
  229. if terr != nil {
  230. res.Attribution = AttrEnvFailure
  231. res.Note = joinNote(res.Note, "target 计算出错:"+terr.Error())
  232. out = append(out, res)
  233. continue
  234. }
  235. f := facts[c.RecordID]
  236. rows, attr, s1 := Attribute(AttrInput{
  237. Case: cc,
  238. Oracle: obs,
  239. Target: tgt,
  240. PredictedDetailID: f.rule.id,
  241. PredictedTier: tierLabel(f.tier),
  242. })
  243. // A24:结算必抛异常,异常本身不是失败;把它记进备注便于复核
  244. if obs.Exception != nil {
  245. res.Note = joinNote(res.Note, fmt.Sprintf("oracle 抛出 %s(A24 预期,异常前写入值已读回)", obs.Exception.Message))
  246. }
  247. res.Rows = rows
  248. res.Step1 = s1
  249. res.Attribution = CaseAttribution(rows, attr)
  250. out = append(out, res)
  251. }
  252. return out
  253. }
  254. func joinNote(a, b string) string {
  255. if a == "" {
  256. return b
  257. }
  258. return a + ";" + b
  259. }
  260. // SortedCaseIDs 便于测试与调试。
  261. func SortedCaseIDs(cases []Case) []string {
  262. ids := make([]string, 0, len(cases))
  263. for _, c := range cases {
  264. ids = append(ids, c.ID)
  265. }
  266. sort.Strings(ids)
  267. return ids
  268. }