| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- <?php
- /**
- * AC-15 阶段 1 · oracle 侧探针(由 Go runner 通过 embed 释放到临时目录后执行)
- *
- * 职责:在 legacy 应用里真实调用一次结课结算,把"异常前已写入的库值"读回来,以 JSON 输出。
- * 纪律:
- * - 只在 legacy_oracle / legacy_finance 上写;finance_bk 一律拒绝
- * - 整个结算过程包在一个事务里,finally 必定 rollBack
- * - 每次进程独立 cachePath ⇒ 缓存不跨 case,保证幂等
- * - 口令只从环境变量读,不打印
- *
- * 入参(全部环境变量):
- * AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS
- * AC15_LEGACY_REPO legacy 应用根目录
- * AC15_RECORD_ID 结课记录 id(varchar(32))
- * AC15_IS_SUPPLEMENT "0"/"1"
- * AC15_FROZEN_EPOCH 冻结时钟的 epoch(整秒,字符串)
- * AC15_MUTATIONS JSON 数组:事务内、结算前要执行的 SQL(合成 case 造数用;空数组表示不改)
- *
- * 出参:stdout 一个 JSON 对象(不夹带任何调试噪声)
- */
- $repo = getenv('AC15_LEGACY_REPO') ?: 'E:/study/php/ysw-finance/yuansiwei-finance';
- $host = getenv('AC15_DB_HOST') ?: '127.0.0.1';
- $port = (int) (getenv('AC15_DB_PORT') ?: 3306);
- $name = getenv('AC15_DB_NAME') ?: 'legacy_oracle';
- $user = getenv('AC15_DB_USER') ?: 'dev_legacy';
- $pass = getenv('AC15_DB_PASS');
- $recordId = getenv('AC15_RECORD_ID');
- $isSupplement = getenv('AC15_IS_SUPPLEMENT') === '1';
- $frozenEpoch = (int) (getenv('AC15_FROZEN_EPOCH') ?: 0);
- $mutations = json_decode(getenv('AC15_MUTATIONS') ?: '[]', true) ?: [];
- function out(array $payload): void
- {
- $payload['harness'] = 'ac15-stage1-oracle';
- // 输出标记:PHP 的 notice/warning 可能混进 stdout,Go 侧靠这个标记定位 JSON 起点
- echo "##AC15_JSON_BEGIN##";
- echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
- exit(0);
- }
- function bail(string $code, string $detail): void
- {
- out(['ok' => false, 'error' => $code, 'errorDetail' => $detail]);
- }
- // ---- 硬守卫:库名 + host 白名单(拒绝 finance_bk 与任何远端 host)----
- if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
- bail('GUARD_HOST', 'host not in allow-list');
- }
- if (!in_array($name, ['legacy_oracle', 'legacy_finance'], true)) {
- bail('GUARD_DB', 'write target must be legacy_oracle / legacy_finance, got ' . $name);
- }
- if ($pass === false || $pass === '') {
- bail('NO_CREDENTIAL', 'AC15_DB_PASS missing');
- }
- if (empty($recordId)) {
- bail('NO_RECORD', 'AC15_RECORD_ID missing');
- }
- define('YII_DEBUG', false);
- define('YII_ENV', 'prod');
- require $repo . '/vendor/autoload.php';
- require $repo . '/vendor/yiisoft/yii2/Yii.php';
- $config = require $repo . '/config/console.php';
- $localDb = [
- 'class' => 'yii\db\Connection',
- 'dsn' => sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $name),
- 'username' => $user,
- 'password' => $pass,
- 'charset' => 'utf8mb4',
- 'tablePrefix' => 't_',
- 'enableSchemaCache' => false,
- ];
- $config['components']['db'] = $localDb;
- $config['components']['dbFinance'] = $localDb;
- // 独立 cachePath:保证每次进程冷缓存,重复执行结论一致(幂等)
- $cachePath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ac15-cache-' . getmypid();
- $config['components']['cache'] = ['class' => 'yii\caching\FileCache', 'cachePath' => $cachePath];
- try {
- $app = new yii\console\Application($config);
- } catch (Throwable $e) {
- bail('BOOT_FAIL', $e->getMessage());
- }
- use app\modules\courses\helpers\CoursesHelper;
- use app\modules\courses\helpers\CoursesRecordHelper;
- use app\modules\courses\helpers\CoursePaymentDetailHelper;
- use app\modules\user\helpers\TeacherInformationHelper;
- $db = Yii::$app->dbFinance;
- $clock = [];
- try {
- // ---- 冻结时钟(会话级)+ 时区统一 ----
- if ($frozenEpoch > 0) {
- // 注意:timestamp 是数值型会话变量,pdO 绑定成字符串会报
- // `1232 Incorrect argument type to variable 'timestamp'`
- // 所以这两个都用整数/字面量直接拼(值来源已强制转 int/固定常量,无注入面)
- $db->createCommand("SET SESSION time_zone = '+08:00'")->execute();
- $db->createCommand('SET SESSION timestamp = ' . (int) $frozenEpoch)->execute();
- $clock = [
- 'frozenEpoch' => $frozenEpoch,
- 'now' => (string) $db->createCommand('SELECT NOW()')->queryScalar(),
- 'unixNow' => (string) $db->createCommand('SELECT UNIX_TIMESTAMP()')->queryScalar(),
- 'phpTime' => time(),
- 'phpDate' => date('Y-m-d H:i:s'),
- 'timeZone' => (string) $db->createCommand('SELECT @@session.time_zone')->queryScalar(),
- ];
- }
- $rec = $db->createCommand(
- "SELECT id, schoolid, campusid, teacherid, coursesid, classid, teacher_reward_type,
- student_number, status, income, teacher_reward,
- DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') st,
- DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') et
- FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
- if (!$rec) {
- bail('RECORD_NOT_FOUND', $recordId);
- }
- $classifyid = CoursesHelper::getClassifyidById($rec['coursesid']);
- $teacherExp = TeacherInformationHelper::getTeacherExp($rec['teacherid']);
- $income = round((float) $db->createCommand(
- "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
- $studentsBefore = $db->createCommand(
- "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
- [':r' => $recordId])->queryAll();
- $eligible = 0;
- foreach ($studentsBefore as $s) {
- if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
- $eligible++;
- }
- }
- // SP 命中的原始规则:**必须在事务内、造数之后读**(否则拿到的是造数前的旧规则,
- // 会造成"PHP 用新规则、target 用旧规则"的假 DEFECT)
- $quote = null;
- $quoteOut = null;
- // ---- 事务内:造数 → **重读权威输入** → 结算 → 读回 → 必定回滚 ----
- $tx = $db->beginTransaction();
- $exception = null;
- $mutationErrors = [];
- $ret = null;
- $after = null;
- $studentsAfter = [];
- $inputs = null;
- $studentsEffective = $studentsBefore;
- try {
- foreach ($mutations as $sql) {
- try {
- $db->createCommand($sql)->execute();
- } catch (Throwable $e) {
- $mutationErrors[] = ['sql' => $sql, 'error' => $e->getMessage()];
- }
- }
- // 造数会改 cost / teacher_reward_type 等,**必须重读**:PHP 结算用的是造数之后的世界,
- // target 侧若用造数之前的值,就会出现"基数不同"的假 DEFECT。
- $rec2 = $db->createCommand(
- "SELECT teacher_reward_type, student_number, status, income, teacher_reward
- FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
- $studentsEffective = $db->createCommand(
- "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
- [':r' => $recordId])->queryAll();
- $incomeEffective = round((float) $db->createCommand(
- "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
- $eligibleEffective = 0;
- foreach ($studentsEffective as $s) {
- if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
- $eligibleEffective++;
- }
- }
- // 读规则(此时造数已生效,SP 会看到注入的规则)
- try {
- $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
- $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
- );
- } catch (Throwable $e) {
- $quote = null;
- }
- if (is_array($quote)) {
- $quoteOut = [];
- foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
- 'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
- 'teacher_exp_min', 'teacher_exp_max'] as $k) {
- if (array_key_exists($k, $quote)) {
- $quoteOut[$k] = (float) $quote[$k];
- }
- }
- }
- $inputs = [
- 'schoolid' => (int) $rec['schoolid'],
- 'campusid' => (int) $rec['campusid'],
- 'teacherid' => (int) $rec['teacherid'],
- 'coursesid' => (int) $rec['coursesid'],
- 'classifyid' => (int) $classifyid,
- 'teacherExp' => (float) $teacherExp,
- 'teacherRewardType' => (int) $rec2['teacher_reward_type'],
- 'studentNumber' => (int) $rec2['student_number'],
- 'eligibleCount' => $eligibleEffective,
- 'incomeSumCost3' => $incomeEffective,
- 'statusBefore' => (int) $rec2['status'],
- 'teacherRewardBefore' => (float) $rec2['teacher_reward'],
- 'actualStartTime' => $rec['st'],
- ];
- try {
- $ret = CoursesRecordHelper::settlementExpenses($recordId, $isSupplement);
- } catch (Throwable $e) {
- $exception = [
- 'class' => get_class($e),
- 'message' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- ];
- }
- // A24:它必抛异常,所以要读"异常前已写入"的库值
- $after = $db->createCommand(
- "SELECT status, income, teacher_reward, student_number,
- DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') aet
- FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
- $studentsAfter = $db->createCommand(
- "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
- [':r' => $recordId])->queryAll();
- } finally {
- if ($tx !== null && $tx->getIsActive()) {
- $tx->rollBack();
- }
- }
- // 回滚后复核(证明零残留)
- $studentsRestored = $db->createCommand(
- "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
- [':r' => $recordId])->queryAll();
- $restored = ($db->createCommand("SELECT teacher_reward FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryScalar() == $rec['teacher_reward'])
- && (json_encode($studentsRestored) === json_encode($studentsBefore));
- // PHP 是否真的重写了逐行(recount 分支是否进入):只有重写过,逐行比对才有意义
- $perRowRewritten = false;
- $effMap = [];
- foreach ($studentsEffective as $s) {
- $effMap[(int) $s['studentid']] = (float) $s['teacher_reward'];
- }
- foreach ($studentsAfter as $s) {
- $sid = (int) $s['studentid'];
- if (isset($effMap[$sid]) && abs($effMap[$sid] - (float) $s['teacher_reward']) > 0.0005) {
- $perRowRewritten = true;
- break;
- }
- }
- out([
- 'ok' => true,
- 'recordId' => $recordId,
- 'isSupplement' => $isSupplement,
- 'perRowRewritten' => $perRowRewritten,
- 'inputsEffective' => $inputs,
- 'inputsBeforeMutation' => [
- 'schoolid' => (int) $rec['schoolid'],
- 'campusid' => (int) $rec['campusid'],
- 'teacherid' => (int) $rec['teacherid'],
- 'coursesid' => (int) $rec['coursesid'],
- 'classifyid' => (int) $classifyid,
- 'teacherExp' => (float) $teacherExp,
- 'teacherRewardType' => (int) $rec['teacher_reward_type'],
- 'studentNumber' => (int) $rec['student_number'],
- 'eligibleCount' => $eligible,
- 'incomeSumCost3' => $income,
- 'statusBefore' => (int) $rec['status'],
- 'teacherRewardBefore' => (float) $rec['teacher_reward'],
- 'actualStartTime' => $rec['st'],
- ],
- 'quote' => $quoteOut,
- 'clock' => $clock,
- 'mutations' => $mutations,
- 'mutationErrors' => $mutationErrors,
- 'return' => $ret,
- 'exception' => $exception,
- 'after' => $after === null ? null : [
- 'status' => (int) $after['status'],
- 'income' => (float) $after['income'],
- 'teacherReward' => (float) $after['teacher_reward'],
- 'studentNumber' => (int) $after['student_number'],
- 'actualEndTime' => $after['aet'],
- ],
- 'studentsEffective' => array_map(static function ($r) {
- return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
- 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
- }, $studentsEffective),
- 'studentsBefore' => array_map(static function ($r) {
- return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
- 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
- }, $studentsBefore),
- 'studentsAfter' => array_map(static function ($r) {
- return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
- 'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
- }, $studentsAfter),
- 'rollbackRestored' => $restored,
- ]);
- } catch (Throwable $e) {
- bail('UNEXPECTED', $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
- }
|