oracle_probe.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. <?php
  2. /**
  3. * AC-15 阶段 1 · oracle 侧探针(由 Go runner 通过 embed 释放到临时目录后执行)
  4. *
  5. * 职责:在 legacy 应用里真实调用一次结课结算,把"异常前已写入的库值"读回来,以 JSON 输出。
  6. * 纪律:
  7. * - 只在 legacy_oracle / legacy_finance 上写;finance_bk 一律拒绝
  8. * - 整个结算过程包在一个事务里,finally 必定 rollBack
  9. * - 每次进程独立 cachePath ⇒ 缓存不跨 case,保证幂等
  10. * - 口令只从环境变量读,不打印
  11. *
  12. * 入参(全部环境变量):
  13. * AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS
  14. * AC15_LEGACY_REPO legacy 应用根目录
  15. * AC15_RECORD_ID 结课记录 id(varchar(32))
  16. * AC15_IS_SUPPLEMENT "0"/"1"
  17. * AC15_FROZEN_EPOCH 冻结时钟的 epoch(整秒,字符串)
  18. * AC15_MUTATIONS JSON 数组:事务内、结算前要执行的 SQL(合成 case 造数用;空数组表示不改)
  19. *
  20. * 出参:stdout 一个 JSON 对象(不夹带任何调试噪声)
  21. */
  22. $repo = getenv('AC15_LEGACY_REPO') ?: 'E:/study/php/ysw-finance/yuansiwei-finance';
  23. $host = getenv('AC15_DB_HOST') ?: '127.0.0.1';
  24. $port = (int) (getenv('AC15_DB_PORT') ?: 3306);
  25. $name = getenv('AC15_DB_NAME') ?: 'legacy_oracle';
  26. $user = getenv('AC15_DB_USER') ?: 'dev_legacy';
  27. $pass = getenv('AC15_DB_PASS');
  28. $recordId = getenv('AC15_RECORD_ID');
  29. $isSupplement = getenv('AC15_IS_SUPPLEMENT') === '1';
  30. $frozenEpoch = (int) (getenv('AC15_FROZEN_EPOCH') ?: 0);
  31. $mutations = json_decode(getenv('AC15_MUTATIONS') ?: '[]', true) ?: [];
  32. function out(array $payload): void
  33. {
  34. $payload['harness'] = 'ac15-stage1-oracle';
  35. // 输出标记:PHP 的 notice/warning 可能混进 stdout,Go 侧靠这个标记定位 JSON 起点
  36. echo "##AC15_JSON_BEGIN##";
  37. echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
  38. exit(0);
  39. }
  40. function bail(string $code, string $detail): void
  41. {
  42. out(['ok' => false, 'error' => $code, 'errorDetail' => $detail]);
  43. }
  44. // ---- 硬守卫:库名 + host 白名单(拒绝 finance_bk 与任何远端 host)----
  45. if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
  46. bail('GUARD_HOST', 'host not in allow-list');
  47. }
  48. if (!in_array($name, ['legacy_oracle', 'legacy_finance'], true)) {
  49. bail('GUARD_DB', 'write target must be legacy_oracle / legacy_finance, got ' . $name);
  50. }
  51. if ($pass === false || $pass === '') {
  52. bail('NO_CREDENTIAL', 'AC15_DB_PASS missing');
  53. }
  54. if (empty($recordId)) {
  55. bail('NO_RECORD', 'AC15_RECORD_ID missing');
  56. }
  57. define('YII_DEBUG', false);
  58. define('YII_ENV', 'prod');
  59. require $repo . '/vendor/autoload.php';
  60. require $repo . '/vendor/yiisoft/yii2/Yii.php';
  61. $config = require $repo . '/config/console.php';
  62. $localDb = [
  63. 'class' => 'yii\db\Connection',
  64. 'dsn' => sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $name),
  65. 'username' => $user,
  66. 'password' => $pass,
  67. 'charset' => 'utf8mb4',
  68. 'tablePrefix' => 't_',
  69. 'enableSchemaCache' => false,
  70. ];
  71. $config['components']['db'] = $localDb;
  72. $config['components']['dbFinance'] = $localDb;
  73. // 独立 cachePath:保证每次进程冷缓存,重复执行结论一致(幂等)
  74. $cachePath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ac15-cache-' . getmypid();
  75. $config['components']['cache'] = ['class' => 'yii\caching\FileCache', 'cachePath' => $cachePath];
  76. try {
  77. $app = new yii\console\Application($config);
  78. } catch (Throwable $e) {
  79. bail('BOOT_FAIL', $e->getMessage());
  80. }
  81. use app\modules\courses\helpers\CoursesHelper;
  82. use app\modules\courses\helpers\CoursesRecordHelper;
  83. use app\modules\courses\helpers\CoursePaymentDetailHelper;
  84. use app\modules\user\helpers\TeacherInformationHelper;
  85. $db = Yii::$app->dbFinance;
  86. $clock = [];
  87. try {
  88. // ---- 冻结时钟(会话级)+ 时区统一 ----
  89. if ($frozenEpoch > 0) {
  90. // 注意:timestamp 是数值型会话变量,pdO 绑定成字符串会报
  91. // `1232 Incorrect argument type to variable 'timestamp'`
  92. // 所以这两个都用整数/字面量直接拼(值来源已强制转 int/固定常量,无注入面)
  93. $db->createCommand("SET SESSION time_zone = '+08:00'")->execute();
  94. $db->createCommand('SET SESSION timestamp = ' . (int) $frozenEpoch)->execute();
  95. $clock = [
  96. 'frozenEpoch' => $frozenEpoch,
  97. 'now' => (string) $db->createCommand('SELECT NOW()')->queryScalar(),
  98. 'unixNow' => (string) $db->createCommand('SELECT UNIX_TIMESTAMP()')->queryScalar(),
  99. 'phpTime' => time(),
  100. 'phpDate' => date('Y-m-d H:i:s'),
  101. 'timeZone' => (string) $db->createCommand('SELECT @@session.time_zone')->queryScalar(),
  102. ];
  103. }
  104. $rec = $db->createCommand(
  105. "SELECT id, schoolid, campusid, teacherid, coursesid, classid, teacher_reward_type,
  106. student_number, status, income, teacher_reward,
  107. DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') st,
  108. DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') et
  109. FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
  110. if (!$rec) {
  111. bail('RECORD_NOT_FOUND', $recordId);
  112. }
  113. $classifyid = CoursesHelper::getClassifyidById($rec['coursesid']);
  114. $teacherExp = TeacherInformationHelper::getTeacherExp($rec['teacherid']);
  115. $income = round((float) $db->createCommand(
  116. "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
  117. $studentsBefore = $db->createCommand(
  118. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  119. [':r' => $recordId])->queryAll();
  120. $eligible = 0;
  121. foreach ($studentsBefore as $s) {
  122. if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
  123. $eligible++;
  124. }
  125. }
  126. // SP 命中的原始规则:**必须在事务内、造数之后读**(否则拿到的是造数前的旧规则,
  127. // 会造成"PHP 用新规则、target 用旧规则"的假 DEFECT)
  128. $quote = null;
  129. $quoteOut = null;
  130. // ---- 事务内:造数 → **重读权威输入** → 结算 → 读回 → 必定回滚 ----
  131. $tx = $db->beginTransaction();
  132. $exception = null;
  133. $mutationErrors = [];
  134. $ret = null;
  135. $after = null;
  136. $studentsAfter = [];
  137. $inputs = null;
  138. $studentsEffective = $studentsBefore;
  139. try {
  140. foreach ($mutations as $sql) {
  141. try {
  142. $db->createCommand($sql)->execute();
  143. } catch (Throwable $e) {
  144. $mutationErrors[] = ['sql' => $sql, 'error' => $e->getMessage()];
  145. }
  146. }
  147. // 造数会改 cost / teacher_reward_type 等,**必须重读**:PHP 结算用的是造数之后的世界,
  148. // target 侧若用造数之前的值,就会出现"基数不同"的假 DEFECT。
  149. $rec2 = $db->createCommand(
  150. "SELECT teacher_reward_type, student_number, status, income, teacher_reward
  151. FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
  152. $studentsEffective = $db->createCommand(
  153. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  154. [':r' => $recordId])->queryAll();
  155. $incomeEffective = round((float) $db->createCommand(
  156. "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
  157. $eligibleEffective = 0;
  158. foreach ($studentsEffective as $s) {
  159. if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
  160. $eligibleEffective++;
  161. }
  162. }
  163. // 读规则(此时造数已生效,SP 会看到注入的规则)
  164. try {
  165. $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
  166. $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
  167. );
  168. } catch (Throwable $e) {
  169. $quote = null;
  170. }
  171. if (is_array($quote)) {
  172. $quoteOut = [];
  173. foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
  174. 'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
  175. 'teacher_exp_min', 'teacher_exp_max'] as $k) {
  176. if (array_key_exists($k, $quote)) {
  177. $quoteOut[$k] = (float) $quote[$k];
  178. }
  179. }
  180. }
  181. $inputs = [
  182. 'schoolid' => (int) $rec['schoolid'],
  183. 'campusid' => (int) $rec['campusid'],
  184. 'teacherid' => (int) $rec['teacherid'],
  185. 'coursesid' => (int) $rec['coursesid'],
  186. 'classifyid' => (int) $classifyid,
  187. 'teacherExp' => (float) $teacherExp,
  188. 'teacherRewardType' => (int) $rec2['teacher_reward_type'],
  189. 'studentNumber' => (int) $rec2['student_number'],
  190. 'eligibleCount' => $eligibleEffective,
  191. 'incomeSumCost3' => $incomeEffective,
  192. 'statusBefore' => (int) $rec2['status'],
  193. 'teacherRewardBefore' => (float) $rec2['teacher_reward'],
  194. 'actualStartTime' => $rec['st'],
  195. ];
  196. try {
  197. $ret = CoursesRecordHelper::settlementExpenses($recordId, $isSupplement);
  198. } catch (Throwable $e) {
  199. $exception = [
  200. 'class' => get_class($e),
  201. 'message' => $e->getMessage(),
  202. 'file' => $e->getFile(),
  203. 'line' => $e->getLine(),
  204. ];
  205. }
  206. // A24:它必抛异常,所以要读"异常前已写入"的库值
  207. $after = $db->createCommand(
  208. "SELECT status, income, teacher_reward, student_number,
  209. DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') aet
  210. FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
  211. $studentsAfter = $db->createCommand(
  212. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  213. [':r' => $recordId])->queryAll();
  214. } finally {
  215. if ($tx !== null && $tx->getIsActive()) {
  216. $tx->rollBack();
  217. }
  218. }
  219. // 回滚后复核(证明零残留)
  220. $studentsRestored = $db->createCommand(
  221. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  222. [':r' => $recordId])->queryAll();
  223. $restored = ($db->createCommand("SELECT teacher_reward FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryScalar() == $rec['teacher_reward'])
  224. && (json_encode($studentsRestored) === json_encode($studentsBefore));
  225. // PHP 是否真的重写了逐行(recount 分支是否进入):只有重写过,逐行比对才有意义
  226. $perRowRewritten = false;
  227. $effMap = [];
  228. foreach ($studentsEffective as $s) {
  229. $effMap[(int) $s['studentid']] = (float) $s['teacher_reward'];
  230. }
  231. foreach ($studentsAfter as $s) {
  232. $sid = (int) $s['studentid'];
  233. if (isset($effMap[$sid]) && abs($effMap[$sid] - (float) $s['teacher_reward']) > 0.0005) {
  234. $perRowRewritten = true;
  235. break;
  236. }
  237. }
  238. out([
  239. 'ok' => true,
  240. 'recordId' => $recordId,
  241. 'isSupplement' => $isSupplement,
  242. 'perRowRewritten' => $perRowRewritten,
  243. 'inputsEffective' => $inputs,
  244. 'inputsBeforeMutation' => [
  245. 'schoolid' => (int) $rec['schoolid'],
  246. 'campusid' => (int) $rec['campusid'],
  247. 'teacherid' => (int) $rec['teacherid'],
  248. 'coursesid' => (int) $rec['coursesid'],
  249. 'classifyid' => (int) $classifyid,
  250. 'teacherExp' => (float) $teacherExp,
  251. 'teacherRewardType' => (int) $rec['teacher_reward_type'],
  252. 'studentNumber' => (int) $rec['student_number'],
  253. 'eligibleCount' => $eligible,
  254. 'incomeSumCost3' => $income,
  255. 'statusBefore' => (int) $rec['status'],
  256. 'teacherRewardBefore' => (float) $rec['teacher_reward'],
  257. 'actualStartTime' => $rec['st'],
  258. ],
  259. 'quote' => $quoteOut,
  260. 'clock' => $clock,
  261. 'mutations' => $mutations,
  262. 'mutationErrors' => $mutationErrors,
  263. 'return' => $ret,
  264. 'exception' => $exception,
  265. 'after' => $after === null ? null : [
  266. 'status' => (int) $after['status'],
  267. 'income' => (float) $after['income'],
  268. 'teacherReward' => (float) $after['teacher_reward'],
  269. 'studentNumber' => (int) $after['student_number'],
  270. 'actualEndTime' => $after['aet'],
  271. ],
  272. 'studentsEffective' => array_map(static function ($r) {
  273. return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
  274. 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
  275. }, $studentsEffective),
  276. 'studentsBefore' => array_map(static function ($r) {
  277. return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
  278. 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
  279. }, $studentsBefore),
  280. 'studentsAfter' => array_map(static function ($r) {
  281. return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
  282. 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
  283. }, $studentsAfter),
  284. 'rollbackRestored' => $restored,
  285. ]);
  286. } catch (Throwable $e) {
  287. bail('UNEXPECTED', $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
  288. }