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()); }