first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\model\Order;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\service\ExpressTrackingService;
use think\facade\Config;
use think\facade\Log;
use think\Response;
/**
* 甘草订单状态回调控制器
*
* 回调地址在【中药处方下单】时通过 callback_url 字段传入。
* 当订单状态发生变化后,甘草会 POST 回调此地址。
* 必须在 5 秒内返回纯文本 "ok",否则甘草视为失败并最多重试 10 次(间隔=失败次数×5分钟)。
*
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#订单状态回调
*/
class GancaoCallbackController extends BaseApiController
{
public array $notNeedLogin = ['orderStatus'];
/**
* 甘草 state → 中文名称映射
*/
private const STATE_MAP = [
10 => '系统审核中',
11 => '系统审核通过',
110 => '订单药房流转制作中',
20 => '物流中',
30 => '完成',
90 => '拦截',
91 => '主动撤单',
92 => '驳回',
];
/**
* 物流商名称 → express_company 编码映射
*/
private const EXPRESS_MAP = [
'顺丰' => 'sf',
'京东' => 'jd',
'极兔' => 'jt',
'圆通' => 'yt',
'中通' => 'zt',
'韵达' => 'yd',
'申通' => 'st',
'邮政' => 'yz',
'EMS' => 'ems',
];
/**
* 甘草订单状态回调入口
*/
public function orderStatus(): Response
{
$rawBody = (string) file_get_contents('php://input');
$headers = $this->request->header();
$accessAppkey = (string) $this->pickHeader($headers, ['access-appkey', 'accessappkey', 'x-access-appkey']);
$accessNonce = (string) $this->pickHeader($headers, ['access-nonce', 'accessnonce', 'x-access-nonce']);
$accessTimestamp = (string) $this->pickHeader($headers, ['access-timestamp', 'accesstimestamp', 'x-access-timestamp']);
$accessSign = (string) $this->pickHeader($headers, ['access-sign', 'accesssign', 'x-access-sign']);
Log::info(sprintf(
'Gancao callback received | appkey=%s | nonce=%s | ts=%s | sign=%s | body=%s | headers=%s',
$accessAppkey !== '' ? $accessAppkey : '(empty)',
$accessNonce !== '' ? $accessNonce : '(empty)',
$accessTimestamp !== '' ? $accessTimestamp : '(empty)',
$accessSign !== '' ? $accessSign : '(empty)',
$rawBody,
json_encode($headers, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
));
try {
if (!$this->verifySign($accessAppkey, $accessNonce, $accessTimestamp, $accessSign, $rawBody)) {
Log::warning('Gancao callback sign verification failed');
return $this->ok();
}
$data = json_decode($rawBody, true);
if (!is_array($data)) {
Log::error('Gancao callback invalid json', ['body' => $rawBody]);
return $this->ok();
}
$this->handleCallback($data);
} catch (\Throwable $e) {
Log::error('Gancao callback exception', [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
}
return $this->ok();
}
/* ------------------------------------------------------------------ */
/* 签名验证 */
/* ------------------------------------------------------------------ */
/**
* 兼容多种 header key 命名(ThinkPHP 默认都会统一成小写-连字符,但不同反向代理/php-fpm 下可能变体)
*
* @param array<string, string|array<int, string>> $headers
* @param array<int, string> $candidates 按优先级排列的 header key
*/
private function pickHeader(array $headers, array $candidates): string
{
foreach ($candidates as $key) {
if (!isset($headers[$key])) {
continue;
}
$v = $headers[$key];
if (is_array($v)) {
$v = reset($v);
}
$v = trim((string) $v);
if ($v !== '') {
return $v;
}
}
return '';
}
/**
* md5(access-appkey + secret-key + access-nonce + access-timestamp + $sBody)
*
* 注意:回调签名使用的是「回调通知账号」—— callback_appkey / callback_secret
* 与下单使用的 biz_ak / biz_sk 是不同的两套凭证。
*/
private function verifySign(string $appkey, string $nonce, string $timestamp, string $sign, string $body): bool
{
$config = Config::get('gancao_scm', []);
$cfgAppkey = (string) ($config['callback_appkey'] ?? '');
$secretKey = (string) ($config['callback_secret'] ?? '');
if ($appkey === '' || $sign === '') {
Log::warning(sprintf(
'Gancao callback missing header | appkey=%s | sign=%s',
$appkey !== '' ? $appkey : '(empty)',
$sign !== '' ? $sign : '(empty)'
));
return false;
}
if ($appkey !== $cfgAppkey) {
Log::warning(sprintf(
'Gancao callback appkey mismatch | received=%s | expected(config.callback_appkey)=%s',
$appkey,
$cfgAppkey !== '' ? $cfgAppkey : '(empty, check GANCAO_SCM_CALLBACK_APPKEY in .env)'
));
return false;
}
$expected = md5($appkey . $secretKey . $nonce . $timestamp . $body);
if (!hash_equals($expected, $sign)) {
Log::warning(sprintf(
'Gancao callback sign mismatch | received=%s | expected=%s | nonce=%s | ts=%s',
$sign,
$expected,
$nonce,
$timestamp
));
return false;
}
return true;
}
/* ------------------------------------------------------------------ */
/* 回调数据处理 */
/* ------------------------------------------------------------------ */
private function handleCallback(array $data): void
{
$recipelOrderNo = (string) ($data['recipel_order_no'] ?? '');
$appOrderNo = (string) ($data['app_order_no'] ?? '');
$state = (int) ($data['state'] ?? 0);
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : [];
if ($recipelOrderNo === '' && $appOrderNo === '') {
Log::warning('Gancao callback missing order no', ['data' => $data]);
return;
}
$order = $this->findOrder($recipelOrderNo, $appOrderNo);
if (!$order) {
Log::warning('Gancao callback order not found', compact('recipelOrderNo', 'appOrderNo'));
return;
}
$this->updateOrderStatus($order, $state, $ext);
$this->writeCallbackLog($order, $state, $ext);
Log::info('Gancao callback processed', [
'order_id' => $order->id,
'recipel_order_no' => $recipelOrderNo,
'state' => $state,
'ext' => $ext,
]);
}
/**
* 通过甘草处方单号或应用商订单号查找本地订单
*/
private function findOrder(string $recipelOrderNo, string $appOrderNo): ?PrescriptionOrder
{
if ($recipelOrderNo !== '') {
$order = PrescriptionOrder::where('gancao_reciperl_order_no', $recipelOrderNo)
->whereNull('delete_time')
->find();
if ($order) {
return $order;
}
}
if ($appOrderNo !== '') {
return PrescriptionOrder::where('order_no', $appOrderNo)
->whereNull('delete_time')
->find() ?: null;
}
return null;
}
/* ------------------------------------------------------------------ */
/* 订单状态更新 */
/* ------------------------------------------------------------------ */
/**
* state 说明:
* 10 系统审核中
* 11 系统审核通过
* 110 订单药房流转制作中(ext: flow_name, supplier
* 20 物流中(ext: shipping_name, nu, supplier
* 30 完成 - 终态。fulfillment 见 resolveFulfilmentOnGancaoState30(与 zyt_order 已付/关联合计对比业务订单 amount)
* 90 拦截 - 可恢复
* 91 主动撤单 - 终态(退费)
* 92 驳回 - 终态(无法制作并退费)
*/
/**
* 甘草 state=30:返回 fulfillment_status 3=已完成 或 6=已签收
* 1) 已支付金额(zyt_order.status=2 的 amount 合计)与业务订单 amount 一致 → 3
* 2) 否则已关联订单金额合计(全部关联单 amount)与业务订单 amount 一致 → 3
* 3) 否则 → 6(含:已支付与总金额不一致且关联合计也不一致)
* 无关联 zyt_order:仅甘草完成则 3
*/
private function resolveFulfilmentOnGancaoState30(PrescriptionOrder $order): int
{
$poId = (int) $order->id;
if ($poId <= 0) {
return 3;
}
$payIds = PrescriptionOrderPayOrder::where('prescription_order_id', $poId)
->column('pay_order_id');
$payIds = array_values(array_filter(
array_map('intval', is_array($payIds) ? $payIds : []),
static fn (int $id): bool => $id > 0
));
if ($payIds === []) {
return 3;
}
$orderAmt = round((float) ($order->amount ?? 0), 2);
$sumAll = round(
(float) Order::whereIn('id', $payIds)->whereNull('delete_time')->sum('amount'),
2
);
$sumPaid = round(
(float) Order::whereIn('id', $payIds)
->whereNull('delete_time')
->where('status', 2)
->sum('amount'),
2
);
if (abs($sumPaid - $orderAmt) <= 0.02) {
return 3;
}
if (abs($sumAll - $orderAmt) <= 0.02) {
return 3;
}
Log::warning('Gancao 完成回调:已支付(status=2)与关联合计均未与业务订单金额对齐,标已签收(6)', [
'prescription_order_id' => $poId,
'order_no' => (string) ($order->order_no ?? ''),
'tcm_order_amount' => $orderAmt,
'sum_paid_status2' => $sumPaid,
'sum_linked_all' => $sumAll,
'linked_pay_order_ids' => $payIds,
]);
return 6;
}
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
{
$order->gancao_order_state = $state;
switch ($state) {
case 10:
case 11:
break;
case 110:
$this->handleProduction($order, $ext);
break;
case 20:
$this->handleShipping($order, $ext);
break;
case 30:
$this->handleShipping($order, $ext);
if ((int) $order->fulfillment_status !== 4) {
$order->fulfillment_status = $this->resolveFulfilmentOnGancaoState30($order);
}
break;
case 90:
$order->gancao_remark = '甘草订单被拦截(可恢复)';
break;
case 91:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4; // 已取消
}
$order->gancao_remark = '甘草主动撤单(已退费)';
break;
case 92:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4; // 已取消
}
$order->gancao_remark = '甘草驳回(无法制作并退费)';
break;
}
$savedOk = false;
try {
$order->save();
$savedOk = true;
} catch (\Throwable $e) {
Log::error('Gancao callback save failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) {
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_number' => (string) ($order->tracking_number ?? ''),
'source' => 'gancao_callback',
]);
}
}
/**
* state=110:药房流转制作中
*/
private function handleProduction(PrescriptionOrder $order, array $ext): void
{
$flowName = (string) ($ext['flow_name'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
if ($flowName !== '') {
$order->gancao_flow_name = mb_substr($flowName, 0, 100);
}
if ($supplier !== '') {
$order->gancao_supplier = mb_substr($supplier, 0, 100);
}
$fs = (int) $order->fulfillment_status;
if ($fs === 2 && (str_contains($flowName, '发货') || str_contains($flowName, '寄出'))) {
$order->fulfillment_status = 5; // 已发货
}
}
/**
* state=20/30:物流中 / 已完成 — 回写快递单号与快递公司
*/
private function handleShipping(PrescriptionOrder $order, array $ext): void
{
$shippingName = (string) ($ext['shipping_name'] ?? '');
$nu = (string) ($ext['nu'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
if ($nu !== '' && trim((string) ($order->tracking_number ?? '')) === '') {
$order->tracking_number = mb_substr($nu, 0, 80);
}
if ($shippingName !== '') {
$order->gancao_shipping_name = mb_substr($shippingName, 0, 50);
$order->express_company = $this->resolveExpressCode($shippingName);
}
if ($supplier !== '') {
$order->gancao_supplier = mb_substr($supplier, 0, 100);
}
$fs = (int) $order->fulfillment_status;
if (in_array($fs, [1, 2], true)) {
$order->fulfillment_status = 5; // 已发货
}
}
/**
* 将甘草返回的物流商名称解析为系统内 express_company 短码
*/
private function resolveExpressCode(string $shippingName): string
{
foreach (self::EXPRESS_MAP as $keyword => $code) {
if (str_contains($shippingName, $keyword)) {
return $code;
}
}
return 'auto';
}
/* ------------------------------------------------------------------ */
/* 操作日志 */
/* ------------------------------------------------------------------ */
private function writeCallbackLog(PrescriptionOrder $order, int $state, array $ext): void
{
$stateName = self::STATE_MAP[$state] ?? "未知状态({$state})";
$summary = "甘草回调:{$stateName}";
if (isset($ext['flow_name'])) {
$summary .= " | 流程:{$ext['flow_name']}";
}
if (isset($ext['supplier'])) {
$summary .= " | 药房:{$ext['supplier']}";
}
if (isset($ext['shipping_name'])) {
$summary .= " | 物流:{$ext['shipping_name']}";
}
if (isset($ext['nu'])) {
$summary .= " | 单号:{$ext['nu']}";
}
try {
$log = new PrescriptionOrderLog();
$log->prescription_order_id = (int) $order->id;
$log->admin_id = 0;
$log->admin_name = '甘草系统';
$log->action = 'gancao_callback';
$log->summary = mb_substr($summary, 0, 500);
$log->create_time = time();
$log->save();
} catch (\Throwable $e) {
Log::warning('Gancao callback log write failed', ['error' => $e->getMessage()]);
}
}
/* ------------------------------------------------------------------ */
/* 响应 */
/* ------------------------------------------------------------------ */
private function ok(): Response
{
return response('ok', 200, [], 'html');
}
}