oracle_probe.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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. echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
  36. exit(0);
  37. }
  38. function bail(string $code, string $detail): void
  39. {
  40. out(['ok' => false, 'error' => $code, 'errorDetail' => $detail]);
  41. }
  42. // ---- 硬守卫:库名 + host 白名单(拒绝 finance_bk 与任何远端 host)----
  43. if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
  44. bail('GUARD_HOST', 'host not in allow-list');
  45. }
  46. if (!in_array($name, ['legacy_oracle', 'legacy_finance'], true)) {
  47. bail('GUARD_DB', 'write target must be legacy_oracle / legacy_finance, got ' . $name);
  48. }
  49. if ($pass === false || $pass === '') {
  50. bail('NO_CREDENTIAL', 'AC15_DB_PASS missing');
  51. }
  52. if (empty($recordId)) {
  53. bail('NO_RECORD', 'AC15_RECORD_ID missing');
  54. }
  55. define('YII_DEBUG', false);
  56. define('YII_ENV', 'prod');
  57. require $repo . '/vendor/autoload.php';
  58. require $repo . '/vendor/yiisoft/yii2/Yii.php';
  59. $config = require $repo . '/config/console.php';
  60. $localDb = [
  61. 'class' => 'yii\db\Connection',
  62. 'dsn' => sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $name),
  63. 'username' => $user,
  64. 'password' => $pass,
  65. 'charset' => 'utf8mb4',
  66. 'tablePrefix' => 't_',
  67. 'enableSchemaCache' => false,
  68. ];
  69. $config['components']['db'] = $localDb;
  70. $config['components']['dbFinance'] = $localDb;
  71. // 独立 cachePath:保证每次进程冷缓存,重复执行结论一致(幂等)
  72. $cachePath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ac15-cache-' . getmypid();
  73. $config['components']['cache'] = ['class' => 'yii\caching\FileCache', 'cachePath' => $cachePath];
  74. try {
  75. $app = new yii\console\Application($config);
  76. } catch (Throwable $e) {
  77. bail('BOOT_FAIL', $e->getMessage());
  78. }
  79. use app\modules\courses\helpers\CoursesHelper;
  80. use app\modules\courses\helpers\CoursesRecordHelper;
  81. use app\modules\courses\helpers\CoursePaymentDetailHelper;
  82. use app\modules\user\helpers\TeacherInformationHelper;
  83. $db = Yii::$app->dbFinance;
  84. $clock = [];
  85. try {
  86. // ---- 冻结时钟(会话级)+ 时区统一 ----
  87. if ($frozenEpoch > 0) {
  88. $db->createCommand('SET SESSION time_zone = :tz', [':tz' => '+08:00'])->execute();
  89. $db->createCommand('SET SESSION timestamp = :ts', [':ts' => (string) $frozenEpoch])->execute();
  90. $clock = [
  91. 'frozenEpoch' => $frozenEpoch,
  92. 'now' => (string) $db->createCommand('SELECT NOW()')->queryScalar(),
  93. 'unixNow' => (string) $db->createCommand('SELECT UNIX_TIMESTAMP()')->queryScalar(),
  94. 'phpTime' => time(),
  95. 'phpDate' => date('Y-m-d H:i:s'),
  96. 'timeZone' => (string) $db->createCommand('SELECT @@session.time_zone')->queryScalar(),
  97. ];
  98. }
  99. $rec = $db->createCommand(
  100. "SELECT id, schoolid, campusid, teacherid, coursesid, classid, teacher_reward_type,
  101. student_number, status, income, teacher_reward,
  102. DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') st,
  103. DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') et
  104. FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
  105. if (!$rec) {
  106. bail('RECORD_NOT_FOUND', $recordId);
  107. }
  108. $classifyid = CoursesHelper::getClassifyidById($rec['coursesid']);
  109. $teacherExp = TeacherInformationHelper::getTeacherExp($rec['teacherid']);
  110. $income = round((float) $db->createCommand(
  111. "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
  112. $studentsBefore = $db->createCommand(
  113. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  114. [':r' => $recordId])->queryAll();
  115. $eligible = 0;
  116. foreach ($studentsBefore as $s) {
  117. if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
  118. $eligible++;
  119. }
  120. }
  121. // SP 命中的原始规则(绕过 helper 缓存,单独取一次,作为"路径证据"的 detail_id 来源)
  122. $quote = null;
  123. try {
  124. $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
  125. $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
  126. );
  127. } catch (Throwable $e) {
  128. $quote = null;
  129. }
  130. $quoteOut = null;
  131. if (is_array($quote)) {
  132. $quoteOut = [];
  133. foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
  134. 'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
  135. 'teacher_exp_min', 'teacher_exp_max'] as $k) {
  136. if (array_key_exists($k, $quote)) {
  137. $quoteOut[$k] = (float) $quote[$k];
  138. }
  139. }
  140. }
  141. // ---- 事务内:造数 → 结算 → 读回 → 必定回滚 ----
  142. $tx = $db->beginTransaction();
  143. $exception = null;
  144. $mutationErrors = [];
  145. $ret = null;
  146. $after = null;
  147. $studentsAfter = [];
  148. try {
  149. foreach ($mutations as $sql) {
  150. try {
  151. $db->createCommand($sql)->execute();
  152. } catch (Throwable $e) {
  153. $mutationErrors[] = ['sql' => $sql, 'error' => $e->getMessage()];
  154. }
  155. }
  156. try {
  157. $ret = CoursesRecordHelper::settlementExpenses($recordId, $isSupplement);
  158. } catch (Throwable $e) {
  159. $exception = [
  160. 'class' => get_class($e),
  161. 'message' => $e->getMessage(),
  162. 'file' => $e->getFile(),
  163. 'line' => $e->getLine(),
  164. ];
  165. }
  166. // A24:它必抛异常,所以要读"异常前已写入"的库值
  167. $after = $db->createCommand(
  168. "SELECT status, income, teacher_reward, student_number,
  169. DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') aet
  170. FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
  171. $studentsAfter = $db->createCommand(
  172. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  173. [':r' => $recordId])->queryAll();
  174. } finally {
  175. if ($tx !== null && $tx->getIsActive()) {
  176. $tx->rollBack();
  177. }
  178. }
  179. // 回滚后复核(证明零残留)
  180. $studentsRestored = $db->createCommand(
  181. "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
  182. [':r' => $recordId])->queryAll();
  183. $restored = ($db->createCommand("SELECT teacher_reward FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryScalar() == $rec['teacher_reward'])
  184. && (json_encode($studentsRestored) === json_encode($studentsBefore));
  185. out([
  186. 'ok' => true,
  187. 'recordId' => $recordId,
  188. 'isSupplement' => $isSupplement,
  189. 'inputs' => [
  190. 'schoolid' => (int) $rec['schoolid'],
  191. 'campusid' => (int) $rec['campusid'],
  192. 'teacherid' => (int) $rec['teacherid'],
  193. 'coursesid' => (int) $rec['coursesid'],
  194. 'classifyid' => (int) $classifyid,
  195. 'teacherExp' => (float) $teacherExp,
  196. 'teacherRewardType' => (int) $rec['teacher_reward_type'],
  197. 'studentNumber' => (int) $rec['student_number'],
  198. 'eligibleCount' => $eligible,
  199. 'incomeSumCost3' => $income,
  200. 'statusBefore' => (int) $rec['status'],
  201. 'teacherRewardBefore' => (float) $rec['teacher_reward'],
  202. 'actualStartTime' => $rec['st'],
  203. ],
  204. 'quote' => $quoteOut,
  205. 'clock' => $clock,
  206. 'mutations' => $mutations,
  207. 'mutationErrors' => $mutationErrors,
  208. 'return' => $ret,
  209. 'exception' => $exception,
  210. 'after' => $after === null ? null : [
  211. 'status' => (int) $after['status'],
  212. 'income' => (float) $after['income'],
  213. 'teacherReward' => (float) $after['teacher_reward'],
  214. 'studentNumber' => (int) $after['student_number'],
  215. 'actualEndTime' => $after['aet'],
  216. ],
  217. 'studentsBefore' => array_map(static function ($r) {
  218. return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
  219. 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
  220. }, $studentsBefore),
  221. 'studentsAfter' => array_map(static function ($r) {
  222. return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
  223. 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
  224. }, $studentsAfter),
  225. 'rollbackRestored' => $restored,
  226. ]);
  227. } catch (Throwable $e) {
  228. bail('UNEXPECTED', $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
  229. }