first commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\lists\AccountLogLists;
|
||||
|
||||
/**
|
||||
* 账户流水
|
||||
* Class AccountLogController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class AccountLogController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* @notes 账户流水
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 14:34
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new AccountLogLists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\lists\article\ArticleCollectLists;
|
||||
use app\api\lists\article\ArticleLists;
|
||||
use app\api\logic\ArticleLogic;
|
||||
|
||||
/**
|
||||
* 文章管理
|
||||
* Class ArticleController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class ArticleController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['lists', 'cate', 'detail'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文章列表
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 15:30
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new ArticleLists());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文章分类列表
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 15:30
|
||||
*/
|
||||
public function cate()
|
||||
{
|
||||
return $this->data(ArticleLogic::cate());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 收藏列表
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 16:31
|
||||
*/
|
||||
public function collect()
|
||||
{
|
||||
return $this->dataLists(new ArticleCollectLists());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文章详情
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 17:09
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$id = $this->request->get('id/d');
|
||||
$result = ArticleLogic::detail($id, $this->userId);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 加入收藏
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 17:01
|
||||
*/
|
||||
public function addCollect()
|
||||
{
|
||||
$articleId = $this->request->post('id/d');
|
||||
ArticleLogic::addCollect($articleId, $this->userId);
|
||||
return $this->success('操作成功');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 取消收藏
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 17:01
|
||||
*/
|
||||
public function cancelCollect()
|
||||
{
|
||||
$articleId = $this->request->post('id/d');
|
||||
ArticleLogic::cancelCollect($articleId, $this->userId);
|
||||
return $this->success('操作成功');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\BaseLikeAdminController;
|
||||
|
||||
class BaseApiController extends BaseLikeAdminController
|
||||
{
|
||||
protected int $userId = 0;
|
||||
protected array $userInfo = [];
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
if (isset($this->request->userInfo) && $this->request->userInfo) {
|
||||
$this->userInfo = $this->request->userInfo;
|
||||
$this->userId = $this->request->userInfo['user_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\logic\ChatNotifyLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 聊天相关接口(C端-患者)
|
||||
*/
|
||||
class ChatController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['notifyOpen', 'notifyClose', 'logAvPermission'];
|
||||
|
||||
/**
|
||||
* 上报音视频权限拒绝日志
|
||||
* 患者小程序请求摄像头/麦克风权限被拒绝,且用户点击「取消」未前往设置页时调用
|
||||
*/
|
||||
public function logAvPermission()
|
||||
{
|
||||
$patientId = trim((string) ($this->request->post('patient_id', '')));
|
||||
$doctorId = trim((string) ($this->request->post('doctor_id', '')));
|
||||
$deniedScope = trim((string) ($this->request->post('denied_scope', '')));
|
||||
$scene = trim((string) ($this->request->post('scene', '')));
|
||||
$action = trim((string) ($this->request->post('action', 'cancel')));
|
||||
$wxVersion = trim((string) ($this->request->post('wx_version', '')));
|
||||
|
||||
Db::name('av_permission_log')->insert([
|
||||
'patient_id' => $patientId,
|
||||
'doctor_id' => $doctorId,
|
||||
'denied_scope' => $deniedScope ?: 'unknown',
|
||||
'scene' => $scene ?: 'unknown',
|
||||
'action' => in_array($action, ['cancel', 'open_setting']) ? $action : 'cancel',
|
||||
'wx_version' => $wxVersion,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->success('已记录');
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者打开会话时通知医生
|
||||
* 无需登录,通过参数传递
|
||||
*/
|
||||
public function notifyOpen()
|
||||
{
|
||||
$doctorId = (int) ($this->request->post('doctor_id') ?: $this->request->post('doctorId'));
|
||||
$patientId = trim((string) ($this->request->post('patient_id') ?: $this->request->post('patientId') ?: ''));
|
||||
$patientName = trim((string) ($this->request->post('patient_name') ?: $this->request->post('patientName') ?: '患者'));
|
||||
|
||||
if ($doctorId <= 0) {
|
||||
return $this->fail('医生ID无效');
|
||||
}
|
||||
|
||||
ChatNotifyLogic::addNotify($doctorId, $patientId ?: '0', $patientName);
|
||||
return $this->success('已通知');
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者离开会话页时通知医生(管理后台轮询 + 可与 IM 信令配合)
|
||||
*/
|
||||
public function notifyClose()
|
||||
{
|
||||
$doctorId = (int) ($this->request->post('doctor_id') ?: $this->request->post('doctorId'));
|
||||
$patientId = trim((string) ($this->request->post('patient_id') ?: $this->request->post('patientId') ?: ''));
|
||||
$patientName = trim((string) ($this->request->post('patient_name') ?: $this->request->post('patientName') ?: '患者'));
|
||||
|
||||
if ($doctorId <= 0) {
|
||||
return $this->fail('医生ID无效');
|
||||
}
|
||||
|
||||
ChatNotifyLogic::addPatientLeftNotify($doctorId, $patientId ?: '0', $patientName);
|
||||
return $this->success('已通知');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\logic\DoctorLogic;
|
||||
|
||||
/**
|
||||
* 医生控制器
|
||||
* Class DoctorController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class DoctorController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['lists', 'detail', 'reviews'];
|
||||
|
||||
/**
|
||||
* @notes 获取医生列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
$page_no = $this->request->get('page_no', 1);
|
||||
$page_size = $this->request->get('page_size', 10);
|
||||
$keyword = $this->request->get('keyword', '');
|
||||
|
||||
$params = [
|
||||
'page_no' => $page_no,
|
||||
'page_size' => $page_size,
|
||||
'keyword' => $keyword
|
||||
];
|
||||
|
||||
$result = DoctorLogic::getDoctorList($params);
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生详情
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$doctorId = $this->request->get('id', 0);
|
||||
|
||||
if (!$doctorId) {
|
||||
return $this->fail('医生ID不能为空');
|
||||
}
|
||||
|
||||
$result = DoctorLogic::getDoctorDetail($doctorId);
|
||||
|
||||
if (!$result) {
|
||||
return $this->fail('医生不存在');
|
||||
}
|
||||
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生排班
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function roster()
|
||||
{
|
||||
$doctorId = $this->request->get('doctor_id', 0);
|
||||
$date = $this->request->get('date', '');
|
||||
|
||||
if (!$doctorId || !$date) {
|
||||
return $this->fail('参数不能为空');
|
||||
}
|
||||
|
||||
$result = DoctorLogic::getDoctorRoster($doctorId, $date);
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生评价列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function reviews()
|
||||
{
|
||||
$doctorId = $this->request->get('doctor_id', 0);
|
||||
$pageNo = $this->request->get('page_no', 1);
|
||||
$pageSize = $this->request->get('page_size', 10);
|
||||
|
||||
if (!$doctorId) {
|
||||
return $this->fail('医生ID不能为空');
|
||||
}
|
||||
|
||||
$result = DoctorLogic::getDoctorReviews($doctorId, $pageNo, $pageSize);
|
||||
return $this->success('', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use DomainException;
|
||||
use app\common\model\ExpressTrace;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\model\pharmacy\EjPharmacyCallbackInbox;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
|
||||
use app\common\service\pharmacy\EjPharmacySignature;
|
||||
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
|
||||
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
|
||||
use app\common\service\pharmacy\PharmacyLogisticsValue;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use Throwable;
|
||||
|
||||
class EjPharmacyCallbackController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['webhook'];
|
||||
|
||||
public function webhook()
|
||||
{
|
||||
$body = $this->callbackBody();
|
||||
if (!$this->isAuthenticCallback($body)) {
|
||||
return $this->callbackResponse(['code' => 401, 'message' => 'invalid signature'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->decodePayload($body);
|
||||
$payload = array_replace($payload, $this->normalizeLogistics($payload));
|
||||
$result = $this->callbackWorkflow()->handle($payload);
|
||||
$httpStatus = (int) ($result['http_status'] ?? 500);
|
||||
$message = (string) ($result['message'] ?? 'callback processing failed');
|
||||
if ($httpStatus >= 500) {
|
||||
$this->logCallbackFailure($message);
|
||||
}
|
||||
|
||||
return $this->callbackResponse([
|
||||
'code' => $httpStatus === 200 ? 0 : $httpStatus,
|
||||
'message' => $message,
|
||||
'data' => ['duplicate' => (bool) ($result['duplicate'] ?? false)],
|
||||
], $httpStatus);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->callbackResponse(['code' => 400, 'message' => 'invalid JSON payload'], 400);
|
||||
} catch (InvalidArgumentException|DomainException $exception) {
|
||||
return $this->callbackResponse(['code' => 422, 'message' => $exception->getMessage()], 422);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logCallbackFailure($exception->getMessage());
|
||||
return $this->callbackResponse(['code' => 500, 'message' => $exception->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
protected function callbackResponse(array $payload, int $httpStatus)
|
||||
{
|
||||
return json($payload, $httpStatus);
|
||||
}
|
||||
|
||||
protected function logCallbackFailure(string $message): void
|
||||
{
|
||||
Log::error('ej pharmacy callback failed', ['error' => $message]);
|
||||
}
|
||||
|
||||
protected function callbackBody(): string
|
||||
{
|
||||
return (string) $this->request->getContent();
|
||||
}
|
||||
|
||||
protected function isAuthenticCallback(string $body): bool
|
||||
{
|
||||
$timestamp = trim((string) $this->request->header('x-timestamp'));
|
||||
$nonce = trim((string) $this->request->header('x-nonce'));
|
||||
$signature = trim((string) $this->request->header('x-signature'));
|
||||
$secret = trim((string) Config::get('ej_pharmacy.callback_secret', ''));
|
||||
$canonical = EjPharmacySignature::canonical('POST', '/api/ej-pharmacy/webhook', $timestamp, $nonce, $body);
|
||||
|
||||
return $secret !== ''
|
||||
&& ctype_digit($timestamp)
|
||||
&& abs(time() - (int) $timestamp) <= 300
|
||||
&& $nonce !== ''
|
||||
&& EjPharmacySignature::verify($secret, $canonical, $signature);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
protected function decodePayload(string $body): array
|
||||
{
|
||||
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($payload)) {
|
||||
throw new InvalidArgumentException('payload must be an object');
|
||||
}
|
||||
foreach (['event_id', 'pharmacy_order_no', 'source_order_no'] as $field) {
|
||||
if (trim((string) ($payload[$field] ?? '')) === '') {
|
||||
throw new InvalidArgumentException($field . ' is required');
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function callbackWorkflow(): EjPharmacyCallbackWorkflow
|
||||
{
|
||||
$loadInbox = static function (string $eventId): ?array {
|
||||
$inbox = EjPharmacyCallbackInbox::where('event_id', $eventId)->find();
|
||||
return $inbox ? $inbox->toArray() : null;
|
||||
};
|
||||
|
||||
return new EjPharmacyCallbackWorkflow(
|
||||
$loadInbox,
|
||||
static function (array $payload): array {
|
||||
$inbox = EjPharmacyCallbackInbox::create([
|
||||
'event_id' => trim((string) $payload['event_id']),
|
||||
'pharmacy_order_no' => trim((string) $payload['pharmacy_order_no']),
|
||||
'source_order_no' => trim((string) $payload['source_order_no']),
|
||||
'event_type' => (string) ($payload['event_type'] ?? ''),
|
||||
'status_version' => (int) ($payload['status_version'] ?? 0),
|
||||
'payload' => json_encode(
|
||||
$payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||
),
|
||||
'process_status' => 'PENDING',
|
||||
]);
|
||||
return $inbox->toArray();
|
||||
},
|
||||
$loadInbox,
|
||||
fn (array $inbox, array $payload): array => $this->processCallback($inbox, $payload),
|
||||
static function (array $inbox, string $error): void {
|
||||
$inboxId = (int) ($inbox['id'] ?? 0);
|
||||
$updated = EjPharmacyCallbackFailureTransition::apply(
|
||||
$inboxId,
|
||||
$error,
|
||||
static fn (int $id, array $values, string $protectedStatus): bool =>
|
||||
EjPharmacyCallbackInbox::where('id', $id)
|
||||
->where('process_status', '<>', $protectedStatus)
|
||||
->update($values) === 1
|
||||
);
|
||||
if ($updated) {
|
||||
return;
|
||||
}
|
||||
$currentStatus = EjPharmacyCallbackInbox::where('id', $inboxId)->value('process_status');
|
||||
if ($currentStatus === null) {
|
||||
throw new RuntimeException('callback inbox could not be marked failed');
|
||||
}
|
||||
},
|
||||
static fn (Throwable $exception): bool => (string) $exception->getCode() === '23000'
|
||||
|| str_contains($exception->getMessage(), '1062')
|
||||
|| str_contains(strtolower($exception->getMessage()), 'duplicate')
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $inbox @param array<string,mixed> $payload @return array<string,mixed> */
|
||||
private function processCallback(array $inbox, array $payload): array
|
||||
{
|
||||
$logistics = $payload;
|
||||
$pharmacyOrderNo = trim((string) $payload['pharmacy_order_no']);
|
||||
$sourceOrderNo = trim((string) $payload['source_order_no']);
|
||||
|
||||
return Db::transaction(function () use ($inbox, $payload, $logistics, $pharmacyOrderNo, $sourceOrderNo): array {
|
||||
$inboxModel = EjPharmacyCallbackInbox::where('id', (int) ($inbox['id'] ?? 0))->lock(true)->find();
|
||||
if (!$inboxModel) {
|
||||
throw new RuntimeException('callback inbox does not exist');
|
||||
}
|
||||
if (strtoupper((string) $inboxModel->process_status) === 'PROCESSED') {
|
||||
return $inboxModel->toArray();
|
||||
}
|
||||
|
||||
$order = PrescriptionOrder::where('ej_pharmacy_order_no', $pharmacyOrderNo)
|
||||
->where('order_no', $sourceOrderNo)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立,请稍后重试');
|
||||
}
|
||||
|
||||
$incomingVersion = (int) ($payload['status_version'] ?? 0);
|
||||
$previousFulfillmentStatus = (int) ($order->fulfillment_status ?? 0);
|
||||
$rollbackFulfillmentStatus = (int) ($order->ej_pharmacy_previous_fulfillment_status ?? 0);
|
||||
$versionAdvanced = $incomingVersion > (int) ($order->ej_pharmacy_status_version ?? 0);
|
||||
$nextFulfillmentStatus = $previousFulfillmentStatus;
|
||||
if ($versionAdvanced) {
|
||||
$nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||||
$previousFulfillmentStatus,
|
||||
$payload,
|
||||
$rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null
|
||||
);
|
||||
$orderValues = [
|
||||
'ej_pharmacy_status' => (string) ($payload['status'] ?? $order->ej_pharmacy_status),
|
||||
'ej_pharmacy_review_status' => (string) ($payload['review_status'] ?? $order->ej_pharmacy_review_status),
|
||||
'ej_pharmacy_status_version' => $incomingVersion,
|
||||
'ej_pharmacy_current_step' => mb_substr((string) ($payload['step_name'] ?? ''), 0, 100),
|
||||
'ej_pharmacy_remark' => mb_substr((string) ($payload['remark'] ?? ''), 0, 500),
|
||||
'express_company' => $logistics['express_company'] !== ''
|
||||
? $logistics['express_company']
|
||||
: (string) $order->express_company,
|
||||
'tracking_number' => $logistics['tracking_number'] !== ''
|
||||
? $logistics['tracking_number']
|
||||
: (string) $order->tracking_number,
|
||||
];
|
||||
if ($nextFulfillmentStatus !== (int) ($order->fulfillment_status ?? 0)) {
|
||||
$orderValues['fulfillment_status'] = $nextFulfillmentStatus;
|
||||
}
|
||||
$order->save($orderValues);
|
||||
$tracking = self::syncLogistics($order, $payload, $logistics);
|
||||
if (EjPharmacyShipmentPolicy::isShippedEvent($payload)
|
||||
&& (int) ($order->fulfillment_status ?? 0) === 5) {
|
||||
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||
'tracking_id' => $tracking ? (int) $tracking->id : 0,
|
||||
'tracking_number' => (string) ($order->tracking_number ?? ''),
|
||||
'source' => 'ej_pharmacy_callback',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (!$versionAdvanced) {
|
||||
$nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||||
$previousFulfillmentStatus,
|
||||
$payload,
|
||||
$rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null
|
||||
);
|
||||
}
|
||||
|
||||
// Each unique callback is an auditable order operation, including
|
||||
// callbacks that arrive out of order and are ignored by the
|
||||
// status-version gate. This keeps EJ review/production nodes
|
||||
// visible in the same timeline as the upload operation.
|
||||
$eventType = strtoupper(trim((string) ($payload['event_type'] ?? '')));
|
||||
$stepName = trim((string) ($payload['step_name'] ?? ''));
|
||||
$operatorName = trim((string) ($payload['operator_name'] ?? ''));
|
||||
$remoteStatus = trim((string) ($payload['status'] ?? ''));
|
||||
$displayOperatorName = self::callbackOperatorLabel($operatorName);
|
||||
$isWorkflowStep = $eventType === 'WORKFLOW_STEP_COMPLETED' && $stepName !== '';
|
||||
if ($isWorkflowStep) {
|
||||
// Keep the same readable production-flow format as Gancao:
|
||||
// flow name and pharmacy are the primary information.
|
||||
$summaryParts = [
|
||||
'洛阳药房回传:订单药房流转制作中',
|
||||
'流程:' . $stepName,
|
||||
'药房:洛阳药房',
|
||||
];
|
||||
if ($displayOperatorName !== '') {
|
||||
$summaryParts[] = '操作人:' . $displayOperatorName;
|
||||
}
|
||||
} else {
|
||||
$summaryParts = ['洛阳药房回传:' . self::callbackEventLabel($eventType)];
|
||||
if ($operatorName !== '') {
|
||||
$summaryParts[] = '操作人:' . $displayOperatorName;
|
||||
}
|
||||
if ($remoteStatus !== '') {
|
||||
$summaryParts[] = '远端状态:' . self::callbackStatusLabel($remoteStatus);
|
||||
}
|
||||
$summaryParts[] = '履约状态:' . self::fulfillmentStatusLabel($previousFulfillmentStatus)
|
||||
. ' → ' . self::fulfillmentStatusLabel($nextFulfillmentStatus);
|
||||
if (!$versionAdvanced) {
|
||||
$summaryParts[] = '(版本已处理,保留回传日志)';
|
||||
}
|
||||
}
|
||||
$log = new PrescriptionOrderLog();
|
||||
$log->prescription_order_id = (int) $order->id;
|
||||
$log->admin_id = 0;
|
||||
$log->admin_name = mb_substr(
|
||||
$isWorkflowStep
|
||||
? '洛阳药房系统'
|
||||
: ($operatorName !== '' ? '洛阳药房:' . $displayOperatorName : '洛阳药房ERP'),
|
||||
0,
|
||||
64
|
||||
);
|
||||
$log->action = 'ej_pharmacy_callback';
|
||||
// Match the Gancao production-flow presentation so both pharmacy
|
||||
// timelines use the same field separator.
|
||||
$log->summary = mb_substr(implode($isWorkflowStep ? ' | ' : ';', $summaryParts), 0, 500);
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
$inboxModel->save(['process_status' => 'PROCESSED', 'processed_time' => time(), 'error_message' => '']);
|
||||
|
||||
return $inboxModel->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
private static function fulfillmentStatusLabel(int $status): string
|
||||
{
|
||||
return [
|
||||
1 => '待双审通过',
|
||||
2 => '待发货/发货与履约',
|
||||
3 => '已完成',
|
||||
4 => '已取消',
|
||||
5 => '已发货',
|
||||
6 => '已签收',
|
||||
7 => '进行中',
|
||||
8 => '暂不制药',
|
||||
9 => '拒收',
|
||||
10 => '退款',
|
||||
11 => '保留药方',
|
||||
12 => '制药缓发',
|
||||
][$status] ?? ('状态' . $status);
|
||||
}
|
||||
|
||||
private static function callbackEventLabel(string $eventType): string
|
||||
{
|
||||
return [
|
||||
'ORDER_CREATED' => '订单已创建',
|
||||
'REVIEW_APPROVED' => '审核通过',
|
||||
'REVIEW_REJECTED' => '审核驳回',
|
||||
'INVENTORY_SHORTAGE' => '库存不足',
|
||||
'WORKFLOW_STEP_COMPLETED' => '流程节点完成',
|
||||
'ORDER_SHIPPED' => '订单已发货',
|
||||
'ORDER_UPDATED' => '订单状态更新',
|
||||
][$eventType] ?? ($eventType !== '' ? $eventType : '状态更新');
|
||||
}
|
||||
|
||||
private static function callbackStatusLabel(string $status): string
|
||||
{
|
||||
return [
|
||||
'PENDING_REVIEW' => '待审核',
|
||||
'READY_FOR_PRODUCTION' => '待生产',
|
||||
'IN_PRODUCTION' => '生产中',
|
||||
'PACKAGED' => '已包装',
|
||||
'SHIPPED' => '已发货',
|
||||
'COMPLETED' => '已完成',
|
||||
'REJECTED' => '已驳回',
|
||||
'STOCK_SHORTAGE' => '库存不足',
|
||||
'CANCELLED' => '已取消',
|
||||
][$status] ?? $status;
|
||||
}
|
||||
|
||||
private static function callbackOperatorLabel(string $operatorName): string
|
||||
{
|
||||
return [
|
||||
'admin' => '管理员',
|
||||
'admin_user' => '管理员',
|
||||
'system' => '系统',
|
||||
][$operatorName] ?? $operatorName;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{express_company:string,tracking_number:string} */
|
||||
private function normalizeLogistics(array $payload): array
|
||||
{
|
||||
return [
|
||||
'express_company' => PharmacyLogisticsValue::normalize(
|
||||
$payload['express_company'] ?? '',
|
||||
32,
|
||||
'快递公司'
|
||||
),
|
||||
'tracking_number' => PharmacyLogisticsValue::normalize(
|
||||
$payload['tracking_number'] ?? '',
|
||||
100,
|
||||
'运单号'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
private static function syncLogistics(PrescriptionOrder $order, array $payload, array $logistics): ?ExpressTracking
|
||||
{
|
||||
$trackingNumber = $logistics['tracking_number'];
|
||||
if ($trackingNumber === '') {
|
||||
return null;
|
||||
}
|
||||
$tracking = ExpressTracking::where('order_id', (int) $order->id)
|
||||
->where('order_type', 'prescription')
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->lock(true)
|
||||
->find();
|
||||
$matching = null;
|
||||
if (!$tracking || trim((string) $tracking->tracking_number) !== $trackingNumber) {
|
||||
$matching = ExpressTracking::where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
}
|
||||
$decision = EjPharmacyTrackingPolicy::select(
|
||||
$tracking ? $tracking->toArray() : null,
|
||||
$matching ? $matching->toArray() : null,
|
||||
(int) $order->id,
|
||||
$trackingNumber
|
||||
);
|
||||
$now = time();
|
||||
if ($decision['archive_current']) {
|
||||
ExpressTracking::where('order_id', (int) $order->id)
|
||||
->where('order_type', 'prescription')
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'order_type' => 'prescription_history',
|
||||
'auto_update' => 0,
|
||||
'next_update_time' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
if ($decision['action'] === 'REUSE_MATCHING') {
|
||||
$tracking = $matching;
|
||||
} elseif ($decision['action'] === 'CREATE') {
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->create_time = $now;
|
||||
}
|
||||
$trackingState = trim((string) ($payload['logistics_state'] ?? ''));
|
||||
$tracking->save([
|
||||
'order_id' => (int) $order->id,
|
||||
'order_type' => 'prescription',
|
||||
'tracking_number' => $trackingNumber,
|
||||
'express_company' => $logistics['express_company'] !== '' ? $logistics['express_company'] : 'auto',
|
||||
'express_company_name' => mb_substr((string) ($payload['express_company_name'] ?? ''), 0, 100),
|
||||
'recipient_phone' => mb_substr((string) $order->recipient_phone, 0, 20),
|
||||
'recipient_name' => mb_substr((string) $order->recipient_name, 0, 100),
|
||||
'recipient_address' => mb_substr((string) $order->shipping_address, 0, 500),
|
||||
'current_state' => $trackingState !== '' ? $trackingState : (string) ($tracking->current_state ?: '0'),
|
||||
'current_state_text' => mb_substr(
|
||||
(string) ($payload['logistics_state_text'] ?? $tracking->current_state_text ?? '在途'),
|
||||
0,
|
||||
50
|
||||
),
|
||||
'data_source' => 'ej_pharmacy',
|
||||
'auto_update' => 1,
|
||||
'next_update_time' => $now + 1800,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$traces = is_array($payload['logistics_traces'] ?? null) ? $payload['logistics_traces'] : [];
|
||||
foreach ($traces as $trace) {
|
||||
if (!is_array($trace)) {
|
||||
continue;
|
||||
}
|
||||
$context = mb_substr((string) ($trace['context'] ?? ''), 0, 1000);
|
||||
$timestamp = (int) ($trace['timestamp'] ?? 0);
|
||||
if ($context === '' || ExpressTrace::where('tracking_id', (int) $tracking->id)
|
||||
->where('trace_time_stamp', $timestamp)
|
||||
->where('trace_context', $context)
|
||||
->count() > 0) {
|
||||
continue;
|
||||
}
|
||||
ExpressTrace::create([
|
||||
'tracking_id' => (int) $tracking->id,
|
||||
'tracking_number' => $trackingNumber,
|
||||
'trace_time' => (string) ($trace['time'] ?? ''),
|
||||
'trace_time_stamp' => $timestamp,
|
||||
'trace_context' => $context,
|
||||
'status' => (string) ($trace['status'] ?? ''),
|
||||
'status_code' => (string) ($trace['status_code'] ?? ''),
|
||||
'location' => (string) ($trace['location'] ?? ''),
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\logic\IndexLogic;
|
||||
use think\response\Json;
|
||||
|
||||
|
||||
/**
|
||||
* index
|
||||
* Class IndexController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class IndexController extends BaseApiController
|
||||
{
|
||||
|
||||
|
||||
public array $notNeedLogin = ['index', 'config', 'policy', 'decorate'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 首页数据
|
||||
* @return Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:15
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$result = IndexLogic::getIndexData();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 全局配置
|
||||
* @return Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:41
|
||||
*/
|
||||
public function config()
|
||||
{
|
||||
$result = IndexLogic::getConfigData();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 政策协议
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 20:00
|
||||
*/
|
||||
public function policy()
|
||||
{
|
||||
$type = $this->request->get('type/s', '');
|
||||
$result = IndexLogic::getPolicyByType($type);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 装修信息
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 18:37
|
||||
*/
|
||||
public function decorate()
|
||||
{
|
||||
$id = $this->request->get('id/d');
|
||||
$result = IndexLogic::getDecorate($id);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\validate\{LoginAccountValidate, RegisterValidate, WebScanLoginValidate, WechatLoginValidate};
|
||||
use app\api\logic\LoginLogic;
|
||||
|
||||
/**
|
||||
* 登录注册
|
||||
* Class LoginController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class LoginController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['register', 'account', 'logout', 'codeUrl', 'oaLogin', 'mnpLogin', 'getScanCode', 'scanLogin'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 注册账号
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/7 15:38
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$params = (new RegisterValidate())->post()->goCheck('register');
|
||||
$result = LoginLogic::register($params);
|
||||
if (true === $result) {
|
||||
return $this->success('注册成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 账号密码/手机号密码/手机号验证码登录
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:42
|
||||
*/
|
||||
public function account()
|
||||
{
|
||||
$params = (new LoginAccountValidate())->post()->goCheck();
|
||||
$result = LoginLogic::login($params);
|
||||
if (false === $result) {
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 退出登录
|
||||
* @return \think\response\Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:42
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
LoginLogic::logout($this->userInfo);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取微信请求code的链接
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/15 18:27
|
||||
*/
|
||||
public function codeUrl()
|
||||
{
|
||||
$url = $this->request->get('url');
|
||||
$result = ['url' => LoginLogic::codeUrl($url)];
|
||||
return $this->success('获取成功', $result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号登录
|
||||
* @return \think\response\Json
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:48
|
||||
*/
|
||||
public function oaLogin()
|
||||
{
|
||||
$params = (new WechatLoginValidate())->post()->goCheck('oa');
|
||||
$res = LoginLogic::oaLogin($params);
|
||||
if (false === $res) {
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
return $this->success('', $res);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序-登录接口
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:48
|
||||
*/
|
||||
public function mnpLogin()
|
||||
{
|
||||
$params = (new WechatLoginValidate())->post()->goCheck('mnpLogin');
|
||||
$res = LoginLogic::mnpLogin($params);
|
||||
if (false === $res) {
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
return $this->success('', $res);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序绑定微信
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:48
|
||||
*/
|
||||
public function mnpAuthBind()
|
||||
{
|
||||
$params = (new WechatLoginValidate())->post()->goCheck("wechatAuth");
|
||||
$params['user_id'] = $this->userId;
|
||||
$result = LoginLogic::mnpAuthLogin($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
return $this->success('绑定成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号绑定微信
|
||||
* @return \think\response\Json
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:48
|
||||
*/
|
||||
public function oaAuthBind()
|
||||
{
|
||||
$params = (new WechatLoginValidate())->post()->goCheck("wechatAuth");
|
||||
$params['user_id'] = $this->userId;
|
||||
$result = LoginLogic::oaAuthLogin($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(LoginLogic::getError());
|
||||
}
|
||||
return $this->success('绑定成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取扫码地址
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 18:25
|
||||
*/
|
||||
public function getScanCode()
|
||||
{
|
||||
$redirectUri = $this->request->get('url/s');
|
||||
$result = LoginLogic::getScanCode($redirectUri);
|
||||
if (false === $result) {
|
||||
return $this->fail(LoginLogic::getError() ?? '未知错误');
|
||||
}
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 网站扫码登录
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/10/21 10:28
|
||||
*/
|
||||
public function scanLogin()
|
||||
{
|
||||
$params = (new WebScanLoginValidate())->post()->goCheck();
|
||||
$result = LoginLogic::scanLogin($params);
|
||||
if (false === $result) {
|
||||
return $this->fail(LoginLogic::getError() ?? '登录失败');
|
||||
}
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 更新用户头像昵称
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/22 11:15
|
||||
*/
|
||||
public function updateUser()
|
||||
{
|
||||
$params = (new WechatLoginValidate())->post()->goCheck("updateUser");
|
||||
LoginLogic::updateUser($params, $this->userId);
|
||||
return $this->success('操作成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\validate\PayValidate;
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\logic\PaymentLogic;
|
||||
use app\common\service\pay\AliPayService;
|
||||
use app\common\service\pay\WeChatPayService;
|
||||
|
||||
/**
|
||||
* 支付
|
||||
* Class PayController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class PayController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['notifyMnp', 'notifyOa', 'aliNotify'];
|
||||
|
||||
/**
|
||||
* @notes 支付方式
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 17:54
|
||||
*/
|
||||
public function payWay()
|
||||
{
|
||||
$params = (new PayValidate())->goCheck('payway');
|
||||
$result = PaymentLogic::getPayWay($this->userId, $this->userInfo['terminal'], $params);
|
||||
if ($result === false) {
|
||||
return $this->fail(PaymentLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 预支付
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 14:21
|
||||
*/
|
||||
public function prepay()
|
||||
{
|
||||
$params = (new PayValidate())->post()->goCheck();
|
||||
//订单信息
|
||||
$order = PaymentLogic::getPayOrderInfo($params);
|
||||
if (false === $order) {
|
||||
return $this->fail(PaymentLogic::getError(), $params);
|
||||
}
|
||||
if (empty($order['user_id'])) {
|
||||
$order['user_id'] = $this->userId;
|
||||
}
|
||||
//支付流程
|
||||
$redirectUrl = $params['redirect'] ?? '/pages/payment/payment';
|
||||
$result = PaymentLogic::pay($params['pay_way'], $params['from'], $order, $this->userInfo['terminal'], $redirectUrl);
|
||||
if (false === $result) {
|
||||
return $this->fail(PaymentLogic::getError(), $params);
|
||||
}
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取支付状态
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 16:23
|
||||
*/
|
||||
public function payStatus()
|
||||
{
|
||||
$params = (new PayValidate())->goCheck('status', ['user_id' => $this->userId]);
|
||||
$result = PaymentLogic::getPayStatus($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(PaymentLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序支付回调
|
||||
* @return \Psr\Http\Message\ResponseInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\RuntimeException
|
||||
* @throws \ReflectionException
|
||||
* @throws \Throwable
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 14:21
|
||||
*/
|
||||
public function notifyMnp()
|
||||
{
|
||||
return (new WeChatPayService(UserTerminalEnum::WECHAT_MMP))->notify();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号支付回调
|
||||
* @return \Psr\Http\Message\ResponseInterface
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\RuntimeException
|
||||
* @throws \ReflectionException
|
||||
* @throws \Throwable
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 14:21
|
||||
*/
|
||||
public function notifyOa()
|
||||
{
|
||||
return (new WeChatPayService(UserTerminalEnum::WECHAT_OA))->notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 支付宝回调
|
||||
* @author mjf
|
||||
* @date 2024/3/18 16:50
|
||||
*/
|
||||
public function aliNotify()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$result = (new AliPayService())->notify($params);
|
||||
if (true === $result) {
|
||||
echo 'success';
|
||||
} else {
|
||||
echo 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\logic\PcLogic;
|
||||
use think\response\Json;
|
||||
|
||||
|
||||
/**
|
||||
* PC
|
||||
* Class PcController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class PcController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['index', 'config', 'infoCenter', 'articleDetail'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 首页数据
|
||||
* @return Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:15
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$result = PcLogic::getIndexData();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 全局配置
|
||||
* @return Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:41
|
||||
*/
|
||||
public function config()
|
||||
{
|
||||
$result = PcLogic::getConfigData();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 资讯中心
|
||||
* @return Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/10/19 16:55
|
||||
*/
|
||||
public function infoCenter()
|
||||
{
|
||||
$result = PcLogic::getInfoCenter();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文章详情
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 15:18
|
||||
*/
|
||||
public function articleDetail()
|
||||
{
|
||||
$id = $this->request->get('id/d', 0);
|
||||
$source = $this->request->get('source/s', 'default');
|
||||
$result = PcLogic::getArticleDetail($this->userId, $id, $source);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Work\Application;
|
||||
use EasyWeChat\Work\Message;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信「客户联系」事件回调(接收事件服务器)
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92130
|
||||
*
|
||||
* ⚠️ 关于"员工↔客户消息内容"接收:
|
||||
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
|
||||
* 这里只处理客户关系事件,以及 customer_acquisition 回调中的累计收消息次数;不保存消息正文。
|
||||
* 实时消息接收走「会话内容存档」独立通道:
|
||||
* - 命令: php think qywx:sync-msg-archive
|
||||
* - 服务: app\common\service\wechat\QywxMsgArchiveService
|
||||
* - SDK: app\common\service\wechat\WeComFinanceSdkClient (FFI 调 libWeWorkFinanceSdk_C.so)
|
||||
* 配置项:pay.wechat_work.msgaudit_*。
|
||||
*/
|
||||
class QywxExternalContactCallbackController extends BaseApiController
|
||||
{
|
||||
/** 免登录:企微服务器回调 */
|
||||
public array $notNeedLogin = ['notify'];
|
||||
|
||||
public function notify()
|
||||
{
|
||||
$corpId = (string) config('pay.wechat_work.corp_id', '');
|
||||
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
|
||||
$acquisitionSecret = (string) config('qywx_customer_acquisition.secret', '');
|
||||
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
|
||||
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret,
|
||||
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
|
||||
$secret = $customerSecret !== ''
|
||||
? $customerSecret
|
||||
: ($acquisitionSecret !== '' ? $acquisitionSecret : $payContactSecret);
|
||||
$token = (string) config('pay.wechat_work.contact_callback_token', '');
|
||||
$aesKey = (string) config('pay.wechat_work.contact_callback_aes_key', '');
|
||||
|
||||
if ($corpId === '' || $secret === '' || $token === '' || $aesKey === '') {
|
||||
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或获客助手应用 secret) / contact_callback_token / contact_callback_aes_key');
|
||||
|
||||
return response('config error', 503, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
|
||||
try {
|
||||
$app = new Application([
|
||||
'corp_id' => $corpId,
|
||||
'secret' => $secret,
|
||||
'token' => $token,
|
||||
'aes_key' => $aesKey,
|
||||
]);
|
||||
$server = $app->getServer();
|
||||
|
||||
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
|
||||
try {
|
||||
$this->handleChangeExternalContact($message);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('qywx external contact callback: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->addEventListener('customer_acquisition', function (Message $message, \Closure $next) {
|
||||
try {
|
||||
(new QywxCustomerAcquisitionCustomerService())->handleCallback($message->toArray());
|
||||
} catch (\Throwable $e) {
|
||||
// 服务已将失败事件与 next_retry 落库,定时命令会在 ChatKey 30 分钟内继续重试。
|
||||
Log::error('qywx customer acquisition callback: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$psr = $server->serve();
|
||||
$body = $psr->getBody();
|
||||
$body->rewind();
|
||||
$content = $body->getContents();
|
||||
|
||||
$headers = [];
|
||||
$contentType = $psr->getHeaderLine('Content-Type');
|
||||
if ($contentType !== '') {
|
||||
$headers['Content-Type'] = $contentType;
|
||||
}
|
||||
|
||||
return response($content, 200, $headers);
|
||||
} catch (BadRequestException $e) {
|
||||
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
|
||||
|
||||
return response('bad request', 400, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('qywx external contact callback: serve failed ' . $e->getMessage(), ['exception' => $e]);
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Message $message
|
||||
*/
|
||||
private function handleChangeExternalContact($message): void
|
||||
{
|
||||
$changeType = (string) ($message['ChangeType'] ?? '');
|
||||
$extId = trim((string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''));
|
||||
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? ''));
|
||||
$state = (string) ($message['State'] ?? '');
|
||||
$welcomeCode = (string) ($message['WelcomeCode'] ?? '');
|
||||
$failReason = (string) ($message['FailReason'] ?? '');
|
||||
$eventTime = (int) ($message['CreateTime'] ?? 0);
|
||||
|
||||
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
|
||||
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
|
||||
CustomerLogic::recordExternalContactEvent([
|
||||
'change_type' => $changeType,
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $extId,
|
||||
'state' => $state,
|
||||
'fail_reason' => $failReason,
|
||||
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
|
||||
'event_time' => $eventTime,
|
||||
'raw' => $message,
|
||||
]);
|
||||
|
||||
if ($extId === '') {
|
||||
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 关键字段直接拼到消息里,便于在 ThinkPHP file 日志格式下一眼定位 ChangeType 分布
|
||||
Log::info(sprintf(
|
||||
'qywx external contact callback type=%s user=%s ext=%s state=%s welcome=%s fail=%s',
|
||||
$changeType !== '' ? $changeType : '-',
|
||||
$userId !== '' ? $userId : '-',
|
||||
$extId,
|
||||
$state !== '' ? $state : '-',
|
||||
$welcomeCode !== '' ? '***' : '-',
|
||||
$failReason !== '' ? $failReason : '-'
|
||||
));
|
||||
|
||||
if ($changeType === 'del_external_contact') {
|
||||
CustomerLogic::softDeleteExternalContactRow($extId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'add_half_external_contact') {
|
||||
// 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类,
|
||||
// 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。
|
||||
Log::info(sprintf('qywx external contact callback: half add,跳过落库 ext=%s user=%s', $extId, $userId));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'transfer_fail') {
|
||||
// 转接失败(customer_refused 等):客户并未成功归属新员工,旧跟进人保持不变;
|
||||
// 此时 /externalcontact/get 多半返回 84061「not external contact」,继续拉详情只会刷无意义 warning。
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'del_follow_user') {
|
||||
// 某员工不再跟进该客户:只需把本地 follow_users 里对应 userid 移除;
|
||||
// 若已无跟进人则软删;不再回调 /externalcontact/get(最后一个跟进人被删时会稳定返回 84061)。
|
||||
if ($userId !== '') {
|
||||
CustomerLogic::removeFollowUserFromLocal($extId, $userId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
|
||||
CustomerLogic::upsertSingleExternalContactFromApi($extId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionRedirectService;
|
||||
use app\common\service\qywx\QywxPromotionWidgetService;
|
||||
|
||||
/** 企业微信获客助手公开端点:JS 与随机跳转。 */
|
||||
class QywxPromotionPublicController extends BaseApiController
|
||||
{
|
||||
/** 公开安装代码与随机跳转不依赖前台用户登录。 */
|
||||
public array $notNeedLogin = ['script', 'redirect'];
|
||||
|
||||
public function script(string $key)
|
||||
{
|
||||
$pool = QywxPromotionRedirectService::publicPoolConfig($key);
|
||||
if ($pool === null) {
|
||||
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
|
||||
}
|
||||
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
|
||||
$goUrl = '/api/qywx-promotion/go/' . $key;
|
||||
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
|
||||
$javascript = QywxPromotionWidgetService::renderScript(
|
||||
$key,
|
||||
$goUrl,
|
||||
$config,
|
||||
(int) ($pool['status'] ?? 0) === 1
|
||||
);
|
||||
|
||||
return response($javascript, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=60',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
public function redirect(string $key)
|
||||
{
|
||||
$picked = QywxPromotionRedirectService::pick($key, [
|
||||
'source_url' => (string) $this->request->get('from', ''),
|
||||
'referer' => (string) $this->request->header('referer', ''),
|
||||
'user_agent' => (string) $this->request->header('user-agent', ''),
|
||||
'ip' => (string) $this->request->ip(),
|
||||
]);
|
||||
if (!$picked) {
|
||||
return response('当前暂无可用的企业微信获客助手链接,请稍后再试。', 503, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect($picked['url'], 302)->header([
|
||||
'Cache-Control' => 'no-store',
|
||||
'Referrer-Policy' => 'no-referrer',
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\lists\recharge\RechargeLists;
|
||||
use app\api\logic\RechargeLogic;
|
||||
use app\api\validate\RechargeValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 充值控制器
|
||||
* Class RechargeController
|
||||
* @package app\shopapi\controller
|
||||
*/
|
||||
class RechargeController extends BaseApiController
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 获取充值列表
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/23 18:55
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new RechargeLists());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 充值
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/23 18:56
|
||||
*/
|
||||
public function recharge()
|
||||
{
|
||||
$params = (new RechargeValidate())->post()->goCheck('recharge', [
|
||||
'user_id' => $this->userId,
|
||||
'terminal' => $this->userInfo['terminal'],
|
||||
]);
|
||||
$result = RechargeLogic::recharge($params);
|
||||
if (false === $result) {
|
||||
return $this->fail(RechargeLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 充值配置
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 16:56
|
||||
*/
|
||||
public function config()
|
||||
{
|
||||
return $this->data(RechargeLogic::config($this->userId));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\logic\SearchLogic;
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
* Class HotSearchController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class SearchController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['hotLists'];
|
||||
|
||||
/**
|
||||
* @notes 热门搜素
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/22 10:14
|
||||
*/
|
||||
public function hotLists()
|
||||
{
|
||||
return $this->data(SearchLogic::hotLists());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\logic\SmsLogic;
|
||||
use app\api\validate\SendSmsValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 短信
|
||||
* Class SmsController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class SmsController extends BaseApiController
|
||||
{
|
||||
|
||||
public array $notNeedLogin = ['sendCode'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 发送短信验证码
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/15 16:17
|
||||
*/
|
||||
public function sendCode()
|
||||
{
|
||||
$params = (new SendSmsValidate())->post()->goCheck();
|
||||
$result = SmsLogic::sendCode($params);
|
||||
if (true === $result) {
|
||||
return $this->success('发送成功');
|
||||
}
|
||||
return $this->fail(SmsLogic::getError());
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 腾讯云 TRTC 云端录制 HTTP 回调(需在控制台填写本接口完整 URL)
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\model\tcm\CallRecord;
|
||||
use app\common\service\FileService;
|
||||
use think\facade\Log;
|
||||
|
||||
class TrtcController extends BaseApiController
|
||||
{
|
||||
/** 免登录:腾讯云服务器回调 */
|
||||
public array $notNeedLogin = ['recordingNotify'];
|
||||
|
||||
/**
|
||||
* 事件类型常量(EventGroupId=3 云端录制)
|
||||
* @see https://cloud.tencent.com/document/product/647/81113
|
||||
*/
|
||||
private const ET_RECORDER_START = 301;
|
||||
private const ET_RECORDER_STOP = 302;
|
||||
private const ET_UPLOAD_START = 303;
|
||||
private const ET_FILE_INFO = 304;
|
||||
private const ET_UPLOAD_STOP = 305;
|
||||
private const ET_FILE_SLICE = 307;
|
||||
private const ET_MP4_STOP = 310; // COS MP4 上传完成
|
||||
private const ET_VOD_COMMIT = 311; // VOD 上传完成
|
||||
private const ET_VOD_STOP = 312;
|
||||
|
||||
private const PROGRESS_EVENTS = [
|
||||
self::ET_RECORDER_START,
|
||||
self::ET_RECORDER_STOP,
|
||||
self::ET_UPLOAD_START,
|
||||
self::ET_FILE_INFO,
|
||||
self::ET_UPLOAD_STOP,
|
||||
self::ET_FILE_SLICE,
|
||||
self::ET_VOD_STOP,
|
||||
306, 308, 309,
|
||||
];
|
||||
|
||||
public function recordingNotify()
|
||||
{
|
||||
$token = (string)config('trtc.recording_callback_token', '');
|
||||
if ($token !== '') {
|
||||
$q = (string)($this->request->param('token', ''));
|
||||
if (!hash_equals($token, $q)) {
|
||||
return json(['code' => -1, 'msg' => 'forbidden']);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $this->request->getContent();
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
Log::warning('TRTC recording callback: invalid json', ['raw' => substr($raw, 0, 500)]);
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
$eventType = (int)($json['EventType'] ?? 0);
|
||||
$roomId = $this->extractRoomId($json);
|
||||
$taskId = trim((string)data_get($json, 'EventInfo.TaskId', ''));
|
||||
$payload = data_get($json, 'EventInfo.Payload');
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
|
||||
Log::info('TRTC callback recv', [
|
||||
'EventType' => $eventType,
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'PayloadKeys' => implode(',', array_keys($payload)),
|
||||
]);
|
||||
|
||||
// 0. 提前查找 call_record(COS 310 拼 URL 需要它的 diagnosis_id + id 来还原前缀)
|
||||
$record = $this->resolveCallRecordForNotify($roomId, $taskId);
|
||||
|
||||
// 1. 尝试提取 HTTP URL(VOD 311 等)
|
||||
$urls = $this->extractRecordingUrls($json);
|
||||
|
||||
// 2. COS 存储:EventType=310 MP4 上传完成,从文件名拼接 COS URL
|
||||
if ($urls === [] && $eventType === self::ET_MP4_STOP) {
|
||||
$cosPrefix = $this->resolveRecordingCosPrefix($record);
|
||||
$urls = $this->buildCosUrlsFromPayload($payload, $taskId, $cosPrefix);
|
||||
if ($urls === []) {
|
||||
Log::warning('TRTC 310 (COS MP4) 未解析到文件', [
|
||||
'Status' => $payload['Status'] ?? null,
|
||||
'FileList' => json_encode($payload['FileList'] ?? null, JSON_UNESCAPED_UNICODE),
|
||||
'FileMessage' => json_encode($payload['FileMessage'] ?? null, JSON_UNESCAPED_UNICODE),
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
'cosPrefix' => $cosPrefix,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. COS HLS 兜底:305(上传结束) 时用同样的前缀
|
||||
if ($urls === [] && $eventType === self::ET_UPLOAD_STOP) {
|
||||
$cosPrefix = $this->resolveRecordingCosPrefix($record);
|
||||
$hlsUrls = $this->buildCosHlsUrlFromPayload($payload, $taskId, $roomId, $cosPrefix);
|
||||
if ($hlsUrls !== []) {
|
||||
$urls = $hlsUrls;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 311 但无 URL(Status!=0 或字段变更)
|
||||
if ($urls === [] && $eventType === self::ET_VOD_COMMIT) {
|
||||
Log::warning('TRTC 311 (VOD) 未解析到 VideoUrl', [
|
||||
'Status' => $payload['Status'] ?? null,
|
||||
'Errmsg' => $payload['Errmsg'] ?? $payload['ErrMsg'] ?? null,
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. 无 URL:进度/状态事件,正常跳过
|
||||
if ($urls === []) {
|
||||
if (!in_array($eventType, self::PROGRESS_EVENTS, true)) {
|
||||
Log::info('TRTC callback: no url', [
|
||||
'EventType' => $eventType,
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
]);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
// 5. 写入 call_record
|
||||
if (!$record) {
|
||||
Log::warning('TRTC recording: no call_record', [
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'EventType' => $eventType,
|
||||
'urls' => json_encode($urls, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
$prev = [];
|
||||
if (!empty($record->recording_urls)) {
|
||||
$prev = json_decode((string)$record->recording_urls, true);
|
||||
if (!is_array($prev)) {
|
||||
$prev = [];
|
||||
}
|
||||
}
|
||||
$merged = array_values(array_unique(array_merge($prev, $urls)));
|
||||
$merged = $this->mirrorRecordingUrlsIfEnabled($merged);
|
||||
|
||||
$record->save([
|
||||
'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE),
|
||||
'recording_status' => 2,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
Log::info('TRTC recording saved', [
|
||||
'id' => $record->id,
|
||||
'EventType' => $eventType,
|
||||
'roomId' => $roomId,
|
||||
'newUrls' => count($urls),
|
||||
'totalUrls' => count($merged),
|
||||
]);
|
||||
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 前缀还原 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 还原 CreateCloudRecording 时使用的 FileNamePrefix。
|
||||
* 录制启动时前缀为 mix_{diagnosisId}_{callRecordId},回调时需还原。
|
||||
*/
|
||||
private function resolveRecordingCosPrefix(?CallRecord $record): string
|
||||
{
|
||||
if ($record && !empty($record->diagnosis_id) && !empty($record->id)) {
|
||||
return 'mix_' . (int)$record->diagnosis_id . '_' . (int)$record->id;
|
||||
}
|
||||
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 配置读取(config() 优先,env() 兜底,兼容 config 文件未同步部署) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function cosConfig(string $key, string $default = ''): string
|
||||
{
|
||||
$val = trim((string)config("trtc.{$key}", ''));
|
||||
if ($val !== '') {
|
||||
return $val;
|
||||
}
|
||||
return trim((string)env("trtc.{$key}", $default));
|
||||
}
|
||||
|
||||
private function cosBucket(): string
|
||||
{
|
||||
return $this->cosConfig('recording_cos_bucket');
|
||||
}
|
||||
|
||||
private function cosRegion(): string
|
||||
{
|
||||
$r = $this->cosConfig('recording_cos_region');
|
||||
return $r !== '' ? $r : $this->cosConfig('recording_api_region', 'ap-guangzhou');
|
||||
}
|
||||
|
||||
private function cosPrefix(): string
|
||||
{
|
||||
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
|
||||
}
|
||||
|
||||
private function cosPrefixParts(): array
|
||||
{
|
||||
$raw = $this->cosPrefix();
|
||||
return $raw !== '' ? explode('/', rtrim($raw, '/')) : [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 文件 URL 拼接 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* EventType=310 的 Payload 中提取文件名,拼接完整 COS 下载 URL。
|
||||
*
|
||||
* COS 路径格式:{FileNamePrefix}/{TaskId}/{FileName}
|
||||
* URL:https://{Bucket}.cos.{Region}.myqcloud.com/{path}
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildCosUrlsFromPayload(array $payload, string $taskId, string $prefix): array
|
||||
{
|
||||
$status = (int)($payload['Status'] ?? -1);
|
||||
if ($status === 2 || $status === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fileNames = [];
|
||||
|
||||
if (!empty($payload['FileMessage']) && is_array($payload['FileMessage'])) {
|
||||
foreach ($payload['FileMessage'] as $fm) {
|
||||
if (is_array($fm) && !empty($fm['FileName'])) {
|
||||
$fileNames[] = trim((string)$fm['FileName']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileNames === [] && !empty($payload['FileList'])) {
|
||||
$fl = $payload['FileList'];
|
||||
if (is_array($fl)) {
|
||||
foreach ($fl as $f) {
|
||||
if (is_string($f) && trim($f) !== '') {
|
||||
$fileNames[] = trim($f);
|
||||
}
|
||||
}
|
||||
} elseif (is_string($fl) && trim($fl) !== '') {
|
||||
$fileNames[] = trim($fl);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileNames === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$bucket = $this->cosBucket();
|
||||
$region = $this->cosRegion();
|
||||
|
||||
if ($bucket === '') {
|
||||
Log::warning('TRTC 310: COS bucket 未配置,无法拼接下载 URL', ['files' => implode(', ', $fileNames)]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
|
||||
$baseUrl = "https://{$bucket}.cos.{$region}.myqcloud.com";
|
||||
|
||||
$urls = [];
|
||||
foreach ($fileNames as $fn) {
|
||||
$parts = array_merge($prefixParts, [$taskId, $fn]);
|
||||
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
|
||||
$urls[] = $baseUrl . '/' . $path;
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* COS HLS 兜底:OutputFormat=0(hls) 时不会有 310 事件。
|
||||
* 305(UPLOAD_STOP) 后用录制的 m3u8 文件名拼 COS URL。
|
||||
*/
|
||||
private function buildCosHlsUrlFromPayload(array $payload, string $taskId, string $roomId, string $prefix): array
|
||||
{
|
||||
$status = (int)($payload['Status'] ?? -1);
|
||||
if ($status !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$bucket = $this->cosBucket();
|
||||
if ($bucket === '') {
|
||||
return [];
|
||||
}
|
||||
$region = $this->cosRegion();
|
||||
|
||||
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
|
||||
|
||||
$sdkAppId = (int)(config('trtc.sdkAppId', 0) ?: env('trtc.sdk_app_id', 0));
|
||||
$m3u8Name = "{$sdkAppId}_{$roomId}.m3u8";
|
||||
|
||||
$parts = array_merge($prefixParts, [$taskId, $m3u8Name]);
|
||||
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
|
||||
$url = "https://{$bucket}.cos.{$region}.myqcloud.com/{$path}";
|
||||
|
||||
Log::info('TRTC 305 HLS fallback', [
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return [$url];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 字段提取 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function extractRoomId(array $data): string
|
||||
{
|
||||
$candidates = [
|
||||
data_get($data, 'EventInfo.RoomId'),
|
||||
data_get($data, 'EventInfo.RoomIdStr'),
|
||||
data_get($data, 'EventInfo.StrRoomId'),
|
||||
data_get($data, 'EventInfo.Payload.RoomId'),
|
||||
data_get($data, 'EventInfo.Payload.RoomIdStr'),
|
||||
data_get($data, 'RoomId'),
|
||||
data_get($data, 'room_id'),
|
||||
];
|
||||
foreach ($candidates as $v) {
|
||||
if ($v !== null && $v !== '') {
|
||||
$s = trim((string)$v);
|
||||
if ($s !== '' && $s !== '0') {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolveCallRecordForNotify(string $roomId, string $taskId): ?CallRecord
|
||||
{
|
||||
if ($roomId !== '') {
|
||||
$record = CallRecord::where('room_id', $roomId)->order('id', 'desc')->find();
|
||||
if ($record) {
|
||||
return $record;
|
||||
}
|
||||
if (ctype_digit($roomId)) {
|
||||
$norm = (string)(int)$roomId;
|
||||
$record = CallRecord::where('room_id', $norm)->order('id', 'desc')->find();
|
||||
if ($record) {
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($taskId !== '') {
|
||||
$record = CallRecord::where('cloud_recording_task_id', $taskId)->order('id', 'desc')->find();
|
||||
if ($record) {
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从回调 JSON 中递归提取 HTTP URL(适用于 VOD 311 等含 VideoUrl/MediaUrl 的事件)
|
||||
*/
|
||||
private function extractRecordingUrls(array $data): array
|
||||
{
|
||||
$urls = [];
|
||||
$this->walkForUrls($data, $urls);
|
||||
|
||||
return array_values(array_unique(array_filter($urls)));
|
||||
}
|
||||
|
||||
private function walkForUrls($node, array &$urls): void
|
||||
{
|
||||
if (!is_array($node)) {
|
||||
return;
|
||||
}
|
||||
foreach ($node as $key => $val) {
|
||||
$keyLower = is_string($key) ? strtolower($key) : '';
|
||||
if (in_array($keyLower, ['videourl', 'fileurl', 'mediaurl', 'url', 'streamurl', 'playurl'], true) && is_string($val)) {
|
||||
$v = trim($val);
|
||||
if ($v !== '' && preg_match('#^https?://#i', $v) === 1) {
|
||||
$urls[] = $v;
|
||||
}
|
||||
}
|
||||
if (is_array($val)) {
|
||||
$this->walkForUrls($val, $urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 可选:镜像录制文件到本服务器 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function mirrorRecordingUrlsIfEnabled(array $urls): array
|
||||
{
|
||||
if (!(int)config('trtc.recording_mirror_to_storage', 0)) {
|
||||
return $urls;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($urls as $u) {
|
||||
$u = trim((string)$u);
|
||||
if ($u === '' || strncmp($u, 'http', 4) !== 0) {
|
||||
$out[] = $u;
|
||||
continue;
|
||||
}
|
||||
$local = $this->downloadRecordingToPublic($u);
|
||||
$out[] = $local !== null ? FileService::getFileUrl($local) : $u;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function downloadRecordingToPublic(string $url): ?string
|
||||
{
|
||||
$dirRel = 'uploads/video/trtc_recording/' . date('Ymd');
|
||||
$dirAbs = public_path() . $dirRel;
|
||||
if (!is_dir($dirAbs) && !mkdir($dirAbs, 0755, true) && !is_dir($dirAbs)) {
|
||||
Log::warning('TRTC mirror: mkdir failed', ['dir' => $dirAbs]);
|
||||
|
||||
return null;
|
||||
}
|
||||
$ext = '.mp4';
|
||||
if (preg_match('/\.([a-z0-9]+)(\?|#|$)/i', $url, $m)) {
|
||||
$ext = '.' . strtolower($m[1]);
|
||||
}
|
||||
$name = 'trtc_' . date('His') . '_' . bin2hex(random_bytes(4)) . $ext;
|
||||
$target = $dirAbs . DIRECTORY_SEPARATOR . $name;
|
||||
$ctx = stream_context_create([
|
||||
'http' => ['timeout' => 600],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
try {
|
||||
$src = @fopen($url, 'rb', false, $ctx);
|
||||
if ($src === false) {
|
||||
return null;
|
||||
}
|
||||
$dst = @fopen($target, 'wb');
|
||||
if ($dst === false) {
|
||||
fclose($src);
|
||||
|
||||
return null;
|
||||
}
|
||||
stream_copy_to_stream($src, $dst);
|
||||
fclose($src);
|
||||
fclose($dst);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('TRTC mirror download failed: ' . $e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
if (!is_file($target) || filesize($target) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace('\\', '/', $dirRel . '/' . $name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\enum\FileEnum;
|
||||
use app\common\service\UploadService;
|
||||
use Exception;
|
||||
use think\response\Json;
|
||||
|
||||
|
||||
/** 上传文件
|
||||
* Class UploadController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class UploadController extends BaseApiController
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 上传图片
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 18:11
|
||||
*/
|
||||
public function image()
|
||||
{
|
||||
try {
|
||||
$result = UploadService::image(0, $this->userId,FileEnum::SOURCE_USER);
|
||||
return $this->success('上传成功', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\api\controller;
|
||||
|
||||
|
||||
use app\api\logic\UserLogic;
|
||||
use app\api\validate\PasswordValidate;
|
||||
use app\api\validate\SetUserInfoValidate;
|
||||
use app\api\validate\UserValidate;
|
||||
|
||||
/**
|
||||
* 用户控制器
|
||||
* Class UserController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class UserController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['resetPassword'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取个人中心
|
||||
* @return \think\response\Json
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:19
|
||||
*/
|
||||
public function center()
|
||||
{
|
||||
$data = UserLogic::center($this->userInfo);
|
||||
return $this->success('', $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取个人信息
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:46
|
||||
*/
|
||||
public function info()
|
||||
{
|
||||
|
||||
$result = UserLogic::info($this->userId);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 重置密码
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:06
|
||||
*/
|
||||
public function resetPassword()
|
||||
{
|
||||
$params = (new PasswordValidate())->post()->goCheck('resetPassword');
|
||||
$result = UserLogic::resetPassword($params);
|
||||
if (true === $result) {
|
||||
return $this->success('操作成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(UserLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 修改密码
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:16
|
||||
*/
|
||||
public function changePassword()
|
||||
{
|
||||
$params = (new PasswordValidate())->post()->goCheck('changePassword');
|
||||
$result = UserLogic::changePassword($params, $this->userId);
|
||||
if (true === $result) {
|
||||
return $this->success('操作成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(UserLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取小程序手机号
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 16:46
|
||||
*/
|
||||
public function getMobileByMnp()
|
||||
{
|
||||
$params = (new UserValidate())->post()->goCheck('getMobileByMnp');
|
||||
$params['user_id'] = $this->userId;
|
||||
$result = UserLogic::getMobileByMnp($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(UserLogic::getError());
|
||||
}
|
||||
return $this->success('绑定成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑用户信息
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 17:01
|
||||
*/
|
||||
public function setInfo()
|
||||
{
|
||||
$params = (new SetUserInfoValidate())->post()->goCheck();
|
||||
$result = UserLogic::setInfo($this->userId, $params);
|
||||
if (false === $result) {
|
||||
return $this->fail(UserLogic::getError());
|
||||
}
|
||||
return $this->success('操作成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 绑定/变更 手机号
|
||||
* @return \think\response\Json
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 17:29
|
||||
*/
|
||||
public function bindMobile()
|
||||
{
|
||||
$params = (new UserValidate())->post()->goCheck('bindMobile');
|
||||
$params['user_id'] = $this->userId;
|
||||
$result = UserLogic::bindMobile($params);
|
||||
if($result) {
|
||||
return $this->success('绑定成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(UserLogic::getError());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\logic\WechatLogic;
|
||||
use app\api\validate\WechatValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 微信
|
||||
* Class WechatController
|
||||
* @package app\api\controller
|
||||
*/
|
||||
class WechatController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['jsConfig'];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 微信JSSDK授权接口
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 11:39
|
||||
*/
|
||||
public function jsConfig()
|
||||
{
|
||||
$params = (new WechatValidate())->goCheck('jsConfig');
|
||||
$result = WechatLogic::jsConfig($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(WechatLogic::getError(), [], 0, 0);
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
namespace app\api\controller\asset;
|
||||
|
||||
use app\api\controller\BaseApiController;
|
||||
use app\common\model\AssetUser;
|
||||
use app\common\model\AssetResource;
|
||||
use app\common\model\AssetUserResource;
|
||||
use think\facade\Db;
|
||||
|
||||
class AssetAppController extends BaseApiController
|
||||
{
|
||||
// 所有接口都免框架登录验证(使用独立 asset_token 体系,在方法内自行校验)
|
||||
public array $notNeedLogin = ['login', 'getResourceList', 'changePassword', 'recordDownload'];
|
||||
|
||||
/** token 有效期:7 天 */
|
||||
const TOKEN_EXPIRE = 86400 * 7;
|
||||
|
||||
/**
|
||||
* @notes 通过 token 获取当前用户(不检查 status,仅查 token 有效性)
|
||||
*/
|
||||
private function getAssetUserRaw(): ?AssetUser
|
||||
{
|
||||
$token = $this->request->header('token');
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 方式1: 从数据库 token 字段查找
|
||||
try {
|
||||
$user = AssetUser::where('token', $token)
|
||||
->where('token_expire_time', '>', time())
|
||||
->find();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// token 字段不存在时忽略,走 cache 兜底
|
||||
}
|
||||
|
||||
// 方式2: 从 file cache 查找
|
||||
$userId = cache('asset_token_' . $token);
|
||||
if ($userId) {
|
||||
$user = AssetUser::where('id', $userId)->find();
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取当前有效用户(status=1 才返回)
|
||||
*/
|
||||
private function getAssetUser(): ?AssetUser
|
||||
{
|
||||
$user = $this->getAssetUserRaw();
|
||||
if ($user && $user->status == 1) {
|
||||
return $user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 小程序端登录
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$phone = $this->request->post('phone');
|
||||
$password = $this->request->post('password');
|
||||
|
||||
if (empty($phone) || empty($password)) {
|
||||
return $this->fail('手机号或密码不能为空');
|
||||
}
|
||||
|
||||
$user = AssetUser::where('phone', $phone)->find();
|
||||
if (!$user) {
|
||||
return $this->fail('账号不存在');
|
||||
}
|
||||
|
||||
if ($user->status != 1) {
|
||||
return $this->fail('账号已被禁用');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user->password)) {
|
||||
return $this->fail('密码错误');
|
||||
}
|
||||
|
||||
// 生成 token 并双写(DB + cache 兜底)
|
||||
$token = md5($user->id . time() . uniqid('asset', true));
|
||||
|
||||
// 写入 file cache(始终可用)
|
||||
cache('asset_token_' . $token, $user->id, self::TOKEN_EXPIRE);
|
||||
|
||||
// 尝试写入数据库(需要已执行 SQL 迁移)
|
||||
try {
|
||||
$user->token = $token;
|
||||
$user->token_expire_time = time() + self::TOKEN_EXPIRE;
|
||||
$user->save();
|
||||
} catch (\Throwable $e) {
|
||||
// token 字段不存在时忽略,cache 已经写入
|
||||
}
|
||||
|
||||
return $this->success('登录成功', [
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'phone' => $user->phone
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取用户关联的资源列表 (或公开资源)
|
||||
*/
|
||||
public function getResourceList()
|
||||
{
|
||||
$token = $this->request->header('token');
|
||||
$rawUser = !empty($token) ? $this->getAssetUserRaw() : null;
|
||||
$user = ($rawUser && $rawUser->status == 1) ? $rawUser : null;
|
||||
$isDisabled = ($rawUser && $rawUser->status != 1); // 用户存在但被禁用
|
||||
$tokenInvalid = (!empty($token) && !$rawUser); // token 过期或无效
|
||||
|
||||
$type = $this->request->get('type', 1);
|
||||
$pageNo = $this->request->get('page_no', 1);
|
||||
$pageSize = $this->request->get('page_size', 20);
|
||||
$days = (int)$this->request->get('days', 0);
|
||||
$usageStatus = (int)$this->request->get('usage_status', 0); // 0=全部, 1=未使用, 2=已使用
|
||||
|
||||
$query = AssetResource::where('type', $type);
|
||||
$usedResourceIds = [];
|
||||
|
||||
if ($user) {
|
||||
// 登录用户:自己的专属资源 + 所有公开资源
|
||||
$exclusiveIds = AssetUserResource::where('user_id', $user->id)->column('resource_id');
|
||||
$associatedResourceIds = AssetUserResource::column('resource_id');
|
||||
|
||||
$query = $query->where(function ($q) use ($exclusiveIds, $associatedResourceIds) {
|
||||
if (!empty($exclusiveIds)) {
|
||||
$q->whereIn('id', $exclusiveIds);
|
||||
}
|
||||
if (!empty($associatedResourceIds)) {
|
||||
if (!empty($exclusiveIds)) {
|
||||
$q->whereOr('id', 'not in', $associatedResourceIds);
|
||||
} else {
|
||||
$q->whereNotIn('id', $associatedResourceIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取该用户的使用记录
|
||||
$usedResourceIds = \think\facade\Db::name('asset_resource_usage')
|
||||
->where('user_id', $user->id)
|
||||
->column('resource_id');
|
||||
|
||||
// 使用状态过滤
|
||||
if ($usageStatus === 1) { // 未使用
|
||||
if (!empty($usedResourceIds)) {
|
||||
$query = $query->whereNotIn('id', $usedResourceIds);
|
||||
}
|
||||
} elseif ($usageStatus === 2) { // 已使用
|
||||
if (empty($usedResourceIds)) {
|
||||
return $this->data([
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'is_disabled' => $isDisabled,
|
||||
'token_invalid' => $tokenInvalid,
|
||||
]);
|
||||
}
|
||||
$query = $query->whereIn('id', $usedResourceIds);
|
||||
}
|
||||
} else {
|
||||
// 游客(未登录):只看公开资源(没关联给任何用户的资源)
|
||||
$associatedResourceIds = AssetUserResource::column('resource_id');
|
||||
if (!empty($associatedResourceIds)) {
|
||||
$query = $query->whereNotIn('id', $associatedResourceIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 时间过滤
|
||||
if ($days > 0) {
|
||||
$startTime = strtotime("-{$days} days", strtotime(date('Y-m-d')));
|
||||
$query = $query->where('create_time', '>=', $startTime);
|
||||
}
|
||||
|
||||
$count = (clone $query)->count();
|
||||
$lists = (clone $query)
|
||||
->order('id', 'desc')
|
||||
->page($pageNo, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 附加 is_used 字段
|
||||
foreach ($lists as &$item) {
|
||||
$item['is_used'] = in_array($item['id'], $usedResourceIds);
|
||||
}
|
||||
|
||||
return $this->data([
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'is_disabled' => $isDisabled, // 用户被禁用
|
||||
'token_invalid' => $tokenInvalid, // token 过期或无效
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 记录资源下载/使用
|
||||
*/
|
||||
public function recordDownload()
|
||||
{
|
||||
$user = $this->getAssetUser();
|
||||
if (!$user) {
|
||||
return $this->fail('请先登录', [], -1);
|
||||
}
|
||||
|
||||
$resourceId = $this->request->post('resource_id');
|
||||
if (empty($resourceId)) {
|
||||
return $this->fail('参数缺失');
|
||||
}
|
||||
|
||||
// 检查资源是否存在
|
||||
$resource = AssetResource::find($resourceId);
|
||||
if (!$resource) {
|
||||
return $this->fail('资源不存在');
|
||||
}
|
||||
|
||||
// 检查是否有关联权限 (或者是公开资源)
|
||||
$isAssociated = AssetUserResource::where('user_id', $user->id)->where('resource_id', $resourceId)->find();
|
||||
$isPublic = !AssetUserResource::where('resource_id', $resourceId)->find();
|
||||
|
||||
if (!$isAssociated && !$isPublic) {
|
||||
return $this->fail('无权操作此资源');
|
||||
}
|
||||
|
||||
// 记录下载
|
||||
$exists = \think\facade\Db::name('asset_resource_usage')
|
||||
->where('user_id', $user->id)
|
||||
->where('resource_id', $resourceId)
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
\think\facade\Db::name('asset_resource_usage')->insert([
|
||||
'user_id' => $user->id,
|
||||
'resource_id' => $resourceId,
|
||||
'create_time' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success('记录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 修改密码
|
||||
*/
|
||||
public function changePassword()
|
||||
{
|
||||
$user = $this->getAssetUser();
|
||||
if (!$user) {
|
||||
return $this->fail('登录已过期,请重新登录', [], -1);
|
||||
}
|
||||
|
||||
$oldPassword = $this->request->post('old_password');
|
||||
$password = $this->request->post('password');
|
||||
|
||||
if (empty($oldPassword)) {
|
||||
return $this->fail('请输入原密码');
|
||||
}
|
||||
if (empty($password)) {
|
||||
return $this->fail('请输入新密码');
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
return $this->fail('新密码至少6位');
|
||||
}
|
||||
|
||||
if (!password_verify($oldPassword, $user->password)) {
|
||||
return $this->fail('原密码不正确');
|
||||
}
|
||||
|
||||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
$user->save();
|
||||
|
||||
return $this->success('密码修改成功');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user