first commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?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
|
||||
// +----------------------------------------------------------------------
|
||||
return [
|
||||
'middleware' => [
|
||||
app\api\http\middleware\InitMiddleware::class, // 初始化
|
||||
app\api\http\middleware\LoginMiddleware::class, // 登录验证
|
||||
],
|
||||
];
|
||||
@@ -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('密码修改成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\api\http\middleware;
|
||||
|
||||
|
||||
use app\common\exception\ControllerExtendException;
|
||||
use app\api\controller\BaseApiController;
|
||||
use think\exception\ClassNotFoundException;
|
||||
use think\exception\HttpException;
|
||||
|
||||
|
||||
class InitMiddleware
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 初始化
|
||||
* @param $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws ControllerExtendException
|
||||
* @author 段誉
|
||||
* @date 2022/9/6 18:17
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
//获取控制器
|
||||
try {
|
||||
$controller = str_replace('.', '\\', $request->controller());
|
||||
$controller = '\\app\\api\\controller\\' . $controller . 'Controller';
|
||||
$controllerClass = invoke($controller);
|
||||
if (($controllerClass instanceof BaseApiController) === false) {
|
||||
throw new ControllerExtendException($controller, '404');
|
||||
}
|
||||
} catch (ClassNotFoundException $e) {
|
||||
throw new HttpException(404, 'controller not exists:' . $e->getClass());
|
||||
}
|
||||
//创建控制器对象
|
||||
$request->controllerObject = invoke($controller);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\api\http\middleware;
|
||||
|
||||
|
||||
use app\common\cache\UserTokenCache;
|
||||
use app\common\service\JsonService;
|
||||
use app\api\service\UserTokenService;
|
||||
use think\facade\Config;
|
||||
|
||||
class LoginMiddleware
|
||||
{
|
||||
/**
|
||||
* @notes 登录验证
|
||||
* @param $request
|
||||
* @param \Closure $next
|
||||
* @return mixed|\think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/1 17:33
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = $request->header('token');
|
||||
//判断接口是否免登录
|
||||
$isNotNeedLogin = $request->controllerObject->isNotNeedLogin();
|
||||
|
||||
//不直接判断$isNotNeedLogin结果,使不需要登录的接口通过,为了兼容某些接口可以登录或不登录访问
|
||||
if (empty($token) && !$isNotNeedLogin) {
|
||||
//没有token并且该地址需要登录才能访问, 指定show为0,前端不弹出此报错
|
||||
return JsonService::fail('请求参数缺token', [], 0, 0);
|
||||
}
|
||||
|
||||
$userInfo = (new UserTokenCache())->getUserInfo($token);
|
||||
|
||||
if (empty($userInfo) && !$isNotNeedLogin) {
|
||||
//token过期无效并且该地址需要登录才能访问
|
||||
return JsonService::fail('登录超时,请重新登录', [], -1, 0);
|
||||
}
|
||||
|
||||
//token临近过期,自动续期
|
||||
if ($userInfo) {
|
||||
//获取临近过期自动续期时长
|
||||
$beExpireDuration = Config::get('project.user_token.be_expire_duration');
|
||||
//token续期
|
||||
if (time() > ($userInfo['expire_time'] - $beExpireDuration)) {
|
||||
$result = UserTokenService::overtimeToken($token);
|
||||
//续期失败(数据表被删除导致)
|
||||
if (empty($result)) {
|
||||
return JsonService::fail('登录过期', [], -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//给request赋值,用于控制器
|
||||
$request->userInfo = $userInfo;
|
||||
$request->userId = $userInfo['user_id'] ?? 0;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?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\lists;
|
||||
|
||||
use app\common\enum\user\AccountLogEnum;
|
||||
use app\common\model\user\UserAccountLog;
|
||||
|
||||
|
||||
/**
|
||||
* 账户流水列表
|
||||
* Class AccountLogLists
|
||||
* @package app\shopapi\lists
|
||||
*/
|
||||
class AccountLogLists extends BaseApiDataLists
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 搜索条件
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 14:43
|
||||
*/
|
||||
public function queryWhere()
|
||||
{
|
||||
// 指定用户
|
||||
$where[] = ['user_id', '=', $this->userId];
|
||||
|
||||
// 用户月明细
|
||||
if (isset($this->params['type']) && $this->params['type'] == 'um') {
|
||||
$where[] = ['change_type', 'in', AccountLogEnum::getUserMoneyChangeType()];
|
||||
}
|
||||
|
||||
// 变动类型
|
||||
if (!empty($this->params['action'])) {
|
||||
$where[] = ['action', '=', $this->params['action']];
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 14:43
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$field = 'change_type,change_amount,action,create_time,remark';
|
||||
$lists = UserAccountLog::field($field)
|
||||
->where($this->queryWhere())
|
||||
->order('id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$item['type_desc'] = AccountLogEnum::getChangeTypeDesc($item['change_type']);
|
||||
$symbol = $item['action'] == AccountLogEnum::DEC ? '-' : '+';
|
||||
$item['change_amount_desc'] = $symbol . $item['change_amount'];
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取数量
|
||||
* @return int
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 14:44
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return UserAccountLog::where($this->queryWhere())->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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\lists;
|
||||
|
||||
use app\common\lists\BaseDataLists;
|
||||
|
||||
abstract class BaseApiDataLists extends BaseDataLists
|
||||
{
|
||||
protected array $userInfo = [];
|
||||
protected int $userId = 0;
|
||||
|
||||
public string $export;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
if (isset($this->request->userInfo) && $this->request->userInfo) {
|
||||
$this->userInfo = $this->request->userInfo;
|
||||
$this->userId = $this->request->userId;
|
||||
}
|
||||
$this->export = $this->request->get('export', '');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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\lists\article;
|
||||
|
||||
use app\api\lists\BaseApiDataLists;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\model\article\Article;
|
||||
|
||||
/**
|
||||
* 文章收藏列表
|
||||
* Class ArticleCollectLists
|
||||
* @package app\api\lists\article
|
||||
*/
|
||||
class ArticleCollectLists extends BaseApiDataLists
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 获取收藏列表
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 16:29
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$field = "c.id,c.article_id,a.title,a.image,a.desc,a.is_show,
|
||||
a.click_virtual, a.click_actual,a.create_time, c.create_time as collect_time";
|
||||
|
||||
$lists = (new Article())->alias('a')
|
||||
->join('article_collect c', 'c.article_id = a.id')
|
||||
->field($field)
|
||||
->where([
|
||||
'c.user_id' => $this->userId,
|
||||
'c.status' => YesNoEnum::YES,
|
||||
'a.is_show' => YesNoEnum::YES,
|
||||
])
|
||||
->order(['sort' => 'desc', 'c.id' => 'desc'])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->append(['click'])
|
||||
->hidden(['click_virtual', 'click_actual'])
|
||||
->select()->toArray();
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$item['collect_time'] = date('Y-m-d H:i', $item['collect_time']);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取收藏数量
|
||||
* @return int
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 16:29
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return (new Article())->alias('a')
|
||||
->join('article_collect c', 'c.article_id = a.id')
|
||||
->where([
|
||||
'c.user_id' => $this->userId,
|
||||
'c.status' => YesNoEnum::YES,
|
||||
'a.is_show' => YesNoEnum::YES,
|
||||
])
|
||||
->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?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\lists\article;
|
||||
|
||||
use app\api\lists\BaseApiDataLists;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\article\Article;
|
||||
use app\common\model\article\ArticleCollect;
|
||||
|
||||
|
||||
/**
|
||||
* 文章列表
|
||||
* Class ArticleLists
|
||||
* @package app\api\lists\article
|
||||
*/
|
||||
class ArticleLists extends BaseApiDataLists implements ListsSearchInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 搜索条件
|
||||
* @return \string[][]
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:54
|
||||
*/
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['cid']
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 自定查询条件
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/10/25 16:53
|
||||
*/
|
||||
public function queryWhere()
|
||||
{
|
||||
$where[] = ['is_show', '=', 1];
|
||||
if (!empty($this->params['keyword'])) {
|
||||
$where[] = ['title', 'like', '%' . $this->params['keyword'] . '%'];
|
||||
}
|
||||
return $where;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文章列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:55
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$orderRaw = 'sort desc, id desc';
|
||||
$sortType = $this->params['sort'] ?? 'default';
|
||||
// 最新排序
|
||||
if ($sortType == 'new') {
|
||||
$orderRaw = 'id desc';
|
||||
}
|
||||
// 最热排序
|
||||
if ($sortType == 'hot') {
|
||||
$orderRaw = 'click_actual + click_virtual desc, id desc';
|
||||
}
|
||||
|
||||
$field = 'id,cid,title,desc,image,click_virtual,click_actual,create_time';
|
||||
$result = Article::field($field)
|
||||
->where($this->queryWhere())
|
||||
->where($this->searchWhere)
|
||||
->orderRaw($orderRaw)
|
||||
->append(['click'])
|
||||
->hidden(['click_virtual', 'click_actual'])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()->toArray();
|
||||
|
||||
$articleIds = array_column($result, 'id');
|
||||
|
||||
$collectIds = ArticleCollect::where(['user_id' => $this->userId, 'status' => YesNoEnum::YES])
|
||||
->whereIn('article_id', $articleIds)
|
||||
->column('article_id');
|
||||
|
||||
foreach ($result as &$item) {
|
||||
$item['collect'] = in_array($item['id'], $collectIds);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文章数量
|
||||
* @return int
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:55
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return Article::where($this->searchWhere)
|
||||
->where($this->queryWhere())
|
||||
->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?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\lists\recharge;
|
||||
|
||||
use app\api\lists\BaseApiDataLists;
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\model\recharge\RechargeOrder;
|
||||
|
||||
|
||||
/**
|
||||
* 充值记录列表
|
||||
* Class RechargeLists
|
||||
* @package app\api\lists\recharge
|
||||
*/
|
||||
class RechargeLists extends BaseApiDataLists
|
||||
{
|
||||
/**
|
||||
* @notes 获取列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2023/2/23 18:43
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$lists = RechargeOrder::field('order_amount,create_time')
|
||||
->where([
|
||||
'user_id' => $this->userId,
|
||||
'pay_status' => PayEnum::ISPAID
|
||||
])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach($lists as &$item) {
|
||||
$item['tips'] = '充值' . format_amount($item['order_amount']) . '元';
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取数量
|
||||
* @return int
|
||||
* @author 段誉
|
||||
* @date 2023/2/23 18:43
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return RechargeOrder::where([
|
||||
'user_id' => $this->userId,
|
||||
'pay_status' => PayEnum::ISPAID
|
||||
])
|
||||
->count();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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\logic;
|
||||
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\article\Article;
|
||||
use app\common\model\article\ArticleCate;
|
||||
use app\common\model\article\ArticleCollect;
|
||||
|
||||
|
||||
/**
|
||||
* 文章逻辑
|
||||
* Class ArticleLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class ArticleLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 文章详情
|
||||
* @param $articleId
|
||||
* @param $userId
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 17:09
|
||||
*/
|
||||
public static function detail($articleId, $userId)
|
||||
{
|
||||
// 文章详情
|
||||
$article = Article::getArticleDetailArr($articleId);
|
||||
// 关注状态
|
||||
$article['collect'] = ArticleCollect::isCollectArticle($userId, $articleId);
|
||||
|
||||
return $article;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 加入收藏
|
||||
* @param $userId
|
||||
* @param $articleId
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 16:52
|
||||
*/
|
||||
public static function addCollect($articleId, $userId)
|
||||
{
|
||||
$where = ['user_id' => $userId, 'article_id' => $articleId];
|
||||
$collect = ArticleCollect::where($where)->findOrEmpty();
|
||||
if ($collect->isEmpty()) {
|
||||
ArticleCollect::create([
|
||||
'user_id' => $userId,
|
||||
'article_id' => $articleId,
|
||||
'status' => YesNoEnum::YES
|
||||
]);
|
||||
} else {
|
||||
ArticleCollect::update([
|
||||
'id' => $collect['id'],
|
||||
'status' => YesNoEnum::YES
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 取消收藏
|
||||
* @param $articleId
|
||||
* @param $userId
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 16:59
|
||||
*/
|
||||
public static function cancelCollect($articleId, $userId)
|
||||
{
|
||||
ArticleCollect::update(['status' => YesNoEnum::NO], [
|
||||
'user_id' => $userId,
|
||||
'article_id' => $articleId,
|
||||
'status' => YesNoEnum::YES
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文章分类
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/23 14:11
|
||||
*/
|
||||
public static function cate()
|
||||
{
|
||||
return ArticleCate::field('id,name')
|
||||
->where('is_show', '=', 1)
|
||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 聊天打开通知逻辑(患者打开会话时通知医生)
|
||||
* 使用缓存存储,支持多消息队列
|
||||
*/
|
||||
class ChatNotifyLogic extends BaseLogic
|
||||
{
|
||||
const CACHE_PREFIX = 'chat_open_notify:';
|
||||
const CACHE_TTL = 86400; // 24小时
|
||||
const MAX_PER_DOCTOR = 50; // 每个医生最多保留条数
|
||||
|
||||
/**
|
||||
* 添加患者打开会话通知
|
||||
* @param int $doctorId 医生ID (admin_id)
|
||||
* @param string $patientId 患者/诊单ID
|
||||
* @param string $patientName 患者姓名
|
||||
* @return bool
|
||||
*/
|
||||
public static function addNotify(int $doctorId, string $patientId, string $patientName): bool
|
||||
{
|
||||
$key = self::CACHE_PREFIX . $doctorId;
|
||||
$item = [
|
||||
'id' => uniqid('', true),
|
||||
'type' => 'patient_opened_chat',
|
||||
'doctor_id' => $doctorId,
|
||||
'patient_id' => $patientId,
|
||||
'patient_name' => $patientName,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$list = Cache::get($key) ?: [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
array_unshift($list, $item);
|
||||
$list = array_slice($list, 0, self::MAX_PER_DOCTOR);
|
||||
Cache::set($key, $list, self::CACHE_TTL);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者离开会话页/诊室时通知医生端(管理后台轮询)
|
||||
*/
|
||||
public static function addPatientLeftNotify(int $doctorId, string $patientId, string $patientName): bool
|
||||
{
|
||||
$key = self::CACHE_PREFIX . $doctorId;
|
||||
$item = [
|
||||
'id' => uniqid('', true),
|
||||
'type' => 'patient_left_chat',
|
||||
'doctor_id' => $doctorId,
|
||||
'patient_id' => $patientId,
|
||||
'patient_name' => $patientName,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$list = Cache::get($key) ?: [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
array_unshift($list, $item);
|
||||
$list = array_slice($list, 0, self::MAX_PER_DOCTOR);
|
||||
Cache::set($key, $list, self::CACHE_TTL);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加面诊结束通知(医生完成接诊后通知对应医助)
|
||||
* @param int $assistantId 医助ID (admin_id)
|
||||
* @param string $patientName 患者姓名
|
||||
* @param string $doctorName 医生姓名
|
||||
* @param int $diagnosisId 诊单ID
|
||||
* @return bool
|
||||
*/
|
||||
public static function addConsultationCompleteNotify(int $assistantId, string $patientName, string $doctorName, int $diagnosisId = 0): bool
|
||||
{
|
||||
$key = self::CACHE_PREFIX . $assistantId;
|
||||
$item = [
|
||||
'id' => uniqid('', true),
|
||||
'type' => 'consultation_complete',
|
||||
'assistant_id' => $assistantId,
|
||||
'patient_name' => $patientName,
|
||||
'doctor_name' => $doctorName,
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$list = Cache::get($key) ?: [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
array_unshift($list, $item);
|
||||
$list = array_slice($list, 0, self::MAX_PER_DOCTOR);
|
||||
Cache::set($key, $list, self::CACHE_TTL);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读通知(医生/医助通用,按 admin_id 获取)
|
||||
* @param int $adminId 管理员ID (医生或医助)
|
||||
* @param bool $consume 是否消费(移除)已获取的
|
||||
* @return array
|
||||
*/
|
||||
public static function getNotifies(int $adminId, bool $consume = true): array
|
||||
{
|
||||
$key = self::CACHE_PREFIX . $adminId;
|
||||
$list = Cache::get($key) ?: [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
if ($consume && !empty($list)) {
|
||||
Cache::delete($key);
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Roster;
|
||||
use app\common\model\doctor\Appointment;
|
||||
|
||||
/**
|
||||
* 医生逻辑层
|
||||
* Class DoctorLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class DoctorLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* @notes 获取医生列表
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
public static function getDoctorList($params = [])
|
||||
{
|
||||
$page_no = $params['page_no'] ?? 1;
|
||||
$page_size = $params['page_size'] ?? 10;
|
||||
$keyword = $params['keyword'] ?? '';
|
||||
|
||||
$offset = ($page_no - 1) * $page_size;
|
||||
|
||||
// 通过中间表 zyt_admin_role 查询 role_id = 1 的医生
|
||||
$adminIds = AdminRole::where('role_id', 1)->column('admin_id');
|
||||
|
||||
if (empty($adminIds)) {
|
||||
return [
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $page_no,
|
||||
'page_size' => $page_size
|
||||
];
|
||||
}
|
||||
|
||||
$query = Admin::whereIn('id', $adminIds)
|
||||
->where('disable', 0);
|
||||
|
||||
// 搜索关键词
|
||||
if (!empty($keyword)) {
|
||||
$query->where('name|account', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
$lists = $query->field(['id', 'name', 'account', 'avatar', 'title','specialty'])
|
||||
->limit($offset, $page_size)
|
||||
->order('id asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 处理医生数据
|
||||
$doctors = [];
|
||||
foreach ($lists as $doctor) {
|
||||
$doctors[] = [
|
||||
'id' => $doctor['id'],
|
||||
'name' => $doctor['name'],
|
||||
'account' => $doctor['account'],
|
||||
'avatar' => $doctor['avatar'] ?? '',
|
||||
'specialty' => $doctor['specialty'], // 可根据需要扩展
|
||||
'title' => $doctor['title'], // 可根据需要扩展
|
||||
'rating' => '9.8', // 可根据需要从其他表获取
|
||||
];
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
$countQuery = Admin::whereIn('id', $adminIds)
|
||||
->where('disable', 0);
|
||||
|
||||
if (!empty($keyword)) {
|
||||
$countQuery->where('name|account', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
$count = $countQuery->count();
|
||||
|
||||
return [
|
||||
'lists' => $doctors,
|
||||
'count' => $count,
|
||||
'page_no' => $page_no,
|
||||
'page_size' => $page_size
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生详情(含完整档案信息)
|
||||
* @param int $doctorId
|
||||
* @return array|null
|
||||
*/
|
||||
public static function getDoctorDetail($doctorId)
|
||||
{
|
||||
// 检查该医生是否有医生角色(role_id = 1)
|
||||
$hasRole = AdminRole::where('admin_id', $doctorId)
|
||||
->where('role_id', 1)
|
||||
->count() > 0;
|
||||
|
||||
if (!$hasRole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doctor = Admin::where('id', $doctorId)
|
||||
->where('disable', 0)
|
||||
->field(['id', 'name', 'account','experience', 'title','qualification_images','avatar','specialty','license_no','qualification_images','enable_image_consult','enable_video_consult','enable_charge'])
|
||||
->find();
|
||||
|
||||
if (!$doctor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doctorData = $doctor->toArray();
|
||||
|
||||
// 统计患者数(已完成预约,去重)
|
||||
$patientIds = Appointment::where('doctor_id', $doctorId)
|
||||
->where('status', 3)
|
||||
->column('patient_id');
|
||||
$patientCount = count(array_unique(array_filter($patientIds)));
|
||||
|
||||
return [
|
||||
'id' => $doctorData['id'],
|
||||
'name' => $doctorData['name'],
|
||||
'account' => $doctorData['account'],
|
||||
'avatar' => $doctorData['avatar'] ?? '',
|
||||
'mobile' => $doctorData['mobile'] ?? '',
|
||||
'email' => $doctorData['email'] ?? '',
|
||||
'license_no' => $doctorData['license_no'] ?? '',
|
||||
'qualification_images' => $doctorData['qualification_images'] ?? '',
|
||||
'enable_image_consult' => $doctorData['enable_image_consult'] ?? 1,
|
||||
'enable_video_consult' => $doctorData['enable_video_consult'] ?? 1,
|
||||
'enable_charge' => $doctorData['enable_charge'] ?? 0,
|
||||
'specialty' => $doctorData['specialty']??'中医',
|
||||
'title' => $doctorData['title'],
|
||||
'hospital' => '甄养堂互联网医院',
|
||||
'experience' => $doctorData['experience']??'10+年临床经验',
|
||||
'rating' => '4.9',
|
||||
'patient_count' => $patientCount,
|
||||
'papers' => 15,
|
||||
'about' => $doctorData['specialty'] ?? '擅长运用传统中医理论与现代诊断相结合,专注于慢性炎症及呼吸系统健康的调理。精通草本方剂,致力于为患者提供个性化的整体康复方案。',
|
||||
'expertise' => [
|
||||
['icon' => 'psychiatry', 'title' => '草本药方', 'desc' => '为整体康复提供量身定制的草本处方。'],
|
||||
['icon' => 'eco', 'title' => '草本药方', 'desc' => '为整体康复提供量身定制的草本处方。'],
|
||||
['icon' => 'vital_signs', 'title' => '切脉诊断', 'desc' => '运用传统方法对全身平衡和器官健康进行深度触诊评估。', 'wide' => true],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生评价列表
|
||||
* @param int $doctorId
|
||||
* @param int $pageNo
|
||||
* @param int $pageSize
|
||||
* @return array
|
||||
*/
|
||||
public static function getDoctorReviews($doctorId, $pageNo = 1, $pageSize = 10)
|
||||
{
|
||||
$hasRole = AdminRole::where('admin_id', $doctorId)
|
||||
->where('role_id', 1)
|
||||
->count() > 0;
|
||||
|
||||
if (!$hasRole) {
|
||||
return ['lists' => [], 'count' => 0];
|
||||
}
|
||||
|
||||
// 默认评价数据(后续可扩展为独立评价表)
|
||||
$defaultReviews = [
|
||||
[
|
||||
'id' => 1,
|
||||
'patient_initials' => 'JS',
|
||||
'patient_name' => 'James S.',
|
||||
'rating' => 5,
|
||||
'content' => '对我慢性疲劳的治疗改变了我的生活。他在建议方剂之前足足听我倾诉了45分钟。真是一位大师级的人物。',
|
||||
'create_time' => '2天前',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'patient_initials' => 'ML',
|
||||
'patient_name' => 'Mei Ling',
|
||||
'rating' => 5,
|
||||
'content' => '非常专业,知识渊博。针灸疗程显著缓解了我的偏头痛。非常推荐这家医院。',
|
||||
'create_time' => '1周前',
|
||||
],
|
||||
];
|
||||
|
||||
$offset = ($pageNo - 1) * $pageSize;
|
||||
$lists = array_slice($defaultReviews, $offset, $pageSize);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => count($defaultReviews),
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取医生排班
|
||||
* @param int $doctorId
|
||||
* @param string $date
|
||||
* @return array
|
||||
*/
|
||||
public static function getDoctorRoster($doctorId, $date)
|
||||
{
|
||||
// 检查该医生是否有医生角色
|
||||
$hasRole = AdminRole::where('admin_id', $doctorId)
|
||||
->where('role_id', 1)
|
||||
->count() > 0;
|
||||
|
||||
if (!$hasRole) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rosters = Roster::where('doctor_id', $doctorId)
|
||||
->where('date', $date)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $rosters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?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\logic;
|
||||
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\article\Article;
|
||||
use app\common\model\decorate\DecoratePage;
|
||||
use app\common\model\decorate\DecorateTabbar;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\FileService;
|
||||
|
||||
|
||||
/**
|
||||
* index
|
||||
* Class IndexLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class IndexLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 首页数据
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:15
|
||||
*/
|
||||
public static function getIndexData()
|
||||
{
|
||||
// 装修配置
|
||||
$decoratePage = DecoratePage::findOrEmpty(1);
|
||||
|
||||
// 首页文章
|
||||
$field = [
|
||||
'id', 'title', 'desc', 'abstract', 'image',
|
||||
'author', 'click_actual', 'click_virtual', 'create_time'
|
||||
];
|
||||
|
||||
$article = Article::field($field)
|
||||
->where(['is_show' => 1])
|
||||
->order(['id' => 'desc'])
|
||||
->limit(20)->append(['click'])
|
||||
->hidden(['click_actual', 'click_virtual'])
|
||||
->select()->toArray();
|
||||
|
||||
return [
|
||||
'page' => $decoratePage,
|
||||
'article' => $article
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取政策协议
|
||||
* @param string $type
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 20:00
|
||||
*/
|
||||
public static function getPolicyByType(string $type)
|
||||
{
|
||||
return [
|
||||
'title' => ConfigService::get('agreement', $type . '_title', ''),
|
||||
'content' => get_file_domain(ConfigService::get('agreement', $type . '_content', '')),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 装修信息
|
||||
* @param $id
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 18:37
|
||||
*/
|
||||
public static function getDecorate($id)
|
||||
{
|
||||
return DecoratePage::field(['type', 'name', 'data', 'meta'])
|
||||
->findOrEmpty($id)->toArray();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取配置
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:38
|
||||
*/
|
||||
public static function getConfigData()
|
||||
{
|
||||
// 底部导航
|
||||
$tabbar = DecorateTabbar::getTabbarLists();
|
||||
// 导航颜色
|
||||
$style = ConfigService::get('tabbar', 'style', config('project.decorate.tabbar_style'));
|
||||
// 登录配置
|
||||
$loginConfig = [
|
||||
// 登录方式
|
||||
'login_way' => ConfigService::get('login', 'login_way', config('project.login.login_way')),
|
||||
// 注册强制绑定手机
|
||||
'coerce_mobile' => ConfigService::get('login', 'coerce_mobile', config('project.login.coerce_mobile')),
|
||||
// 政策协议
|
||||
'login_agreement' => ConfigService::get('login', 'login_agreement', config('project.login.login_agreement')),
|
||||
// 第三方登录 开关
|
||||
'third_auth' => ConfigService::get('login', 'third_auth', config('project.login.third_auth')),
|
||||
// 微信授权登录
|
||||
'wechat_auth' => ConfigService::get('login', 'wechat_auth', config('project.login.wechat_auth')),
|
||||
// qq授权登录
|
||||
'qq_auth' => ConfigService::get('login', 'qq_auth', config('project.login.qq_auth')),
|
||||
];
|
||||
// 网址信息
|
||||
$website = [
|
||||
'h5_favicon' => FileService::getFileUrl(ConfigService::get('website', 'h5_favicon')),
|
||||
'shop_name' => ConfigService::get('website', 'shop_name'),
|
||||
'shop_logo' => FileService::getFileUrl(ConfigService::get('website', 'shop_logo')),
|
||||
];
|
||||
// H5配置
|
||||
$webPage = [
|
||||
// 渠道状态 0-关闭 1-开启
|
||||
'status' => ConfigService::get('web_page', 'status', 1),
|
||||
// 关闭后渠道后访问页面 0-空页面 1-自定义链接
|
||||
'page_status' => ConfigService::get('web_page', 'page_status', 0),
|
||||
// 自定义链接
|
||||
'page_url' => ConfigService::get('web_page', 'page_url', ''),
|
||||
'url' => request()->domain() . '/mobile'
|
||||
];
|
||||
|
||||
// 备案信息
|
||||
$copyright = ConfigService::get('copyright', 'config', []);
|
||||
|
||||
return [
|
||||
'domain' => FileService::getFileUrl(),
|
||||
'style' => $style,
|
||||
'tabbar' => $tabbar,
|
||||
'login' => $loginConfig,
|
||||
'website' => $website,
|
||||
'webPage' => $webPage,
|
||||
'version'=> config('project.version'),
|
||||
'copyright' => $copyright,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
<?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\logic;
|
||||
|
||||
use app\common\cache\WebScanLoginCache;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\api\service\{UserTokenService, WechatUserService};
|
||||
use app\common\enum\{LoginEnum, user\UserTerminalEnum, YesNoEnum};
|
||||
use app\common\service\{
|
||||
ConfigService,
|
||||
FileService,
|
||||
wechat\WeChatConfigService,
|
||||
wechat\WeChatMnpService,
|
||||
wechat\WeChatOaService,
|
||||
wechat\WeChatRequestService
|
||||
};
|
||||
use app\common\model\user\{User, UserAuth};
|
||||
use think\facade\{Db, Config};
|
||||
|
||||
/**
|
||||
* 登录逻辑
|
||||
* Class LoginLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class LoginLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 账号密码注册
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/7 15:37
|
||||
*/
|
||||
public static function register(array $params)
|
||||
{
|
||||
try {
|
||||
$userSn = User::createUserSn();
|
||||
$passwordSalt = Config::get('project.unique_identification');
|
||||
$password = create_password($params['password'], $passwordSalt);
|
||||
$avatar = ConfigService::get('default_image', 'user_avatar');
|
||||
|
||||
User::create([
|
||||
'sn' => $userSn,
|
||||
'avatar' => $avatar,
|
||||
'nickname' => '用户' . $userSn,
|
||||
'account' => $params['account'],
|
||||
'password' => $password,
|
||||
'channel' => $params['channel'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 账号/手机号登录,手机号验证码
|
||||
* @param $params
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2022/9/6 19:26
|
||||
*/
|
||||
public static function login($params)
|
||||
{
|
||||
try {
|
||||
// 账号/手机号 密码登录
|
||||
$where = ['account|mobile' => $params['account']];
|
||||
if ($params['scene'] == LoginEnum::MOBILE_CAPTCHA) {
|
||||
//手机验证码登录
|
||||
$where = ['mobile' => $params['account']];
|
||||
}
|
||||
|
||||
$user = User::where($where)->findOrEmpty();
|
||||
if ($user->isEmpty()) {
|
||||
throw new \Exception('用户不存在');
|
||||
}
|
||||
|
||||
//更新登录信息
|
||||
$user->login_time = time();
|
||||
$user->login_ip = request()->ip();
|
||||
$user->save();
|
||||
|
||||
//设置token
|
||||
$userInfo = UserTokenService::setToken($user->id, $params['terminal']);
|
||||
|
||||
//返回登录信息
|
||||
$avatar = $user->avatar ?: Config::get('project.default_image.user_avatar');
|
||||
$avatar = FileService::getFileUrl($avatar);
|
||||
|
||||
return [
|
||||
'nickname' => $userInfo['nickname'],
|
||||
'sn' => $userInfo['sn'],
|
||||
'mobile' => $userInfo['mobile'],
|
||||
'avatar' => $avatar,
|
||||
'token' => $userInfo['token'],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 退出登录
|
||||
* @param $userInfo
|
||||
* @return bool
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 17:56
|
||||
*/
|
||||
public static function logout($userInfo)
|
||||
{
|
||||
//token不存在,不注销
|
||||
if (!isset($userInfo['token'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//设置token过期
|
||||
return UserTokenService::expireToken($userInfo['token']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取微信请求code的链接
|
||||
* @param string $url
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:47
|
||||
*/
|
||||
public static function codeUrl(string $url)
|
||||
{
|
||||
return (new WeChatOaService())->getCodeUrl($url);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号登录
|
||||
* @param array $params
|
||||
* @return array|false
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:47
|
||||
*/
|
||||
public static function oaLogin(array $params)
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
//通过code获取微信 openid
|
||||
$response = (new WeChatOaService())->getOaResByCode($params['code']);
|
||||
$userServer = new WechatUserService($response, UserTerminalEnum::WECHAT_OA);
|
||||
$userInfo = $userServer->getResopnseByUserInfo()->authUserLogin()->getUserInfo();
|
||||
|
||||
// 更新登录信息
|
||||
self::updateLoginInfo($userInfo['id']);
|
||||
|
||||
Db::commit();
|
||||
return $userInfo;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序-静默登录
|
||||
* @param array $params
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:47
|
||||
*/
|
||||
public static function silentLogin(array $params)
|
||||
{
|
||||
try {
|
||||
//通过code获取微信 openid
|
||||
$response = (new WeChatMnpService())->getMnpResByCode($params['code']);
|
||||
$userServer = new WechatUserService($response, UserTerminalEnum::WECHAT_MMP);
|
||||
$userInfo = $userServer->getResopnseByUserInfo('silent')->getUserInfo();
|
||||
|
||||
if (!empty($userInfo)) {
|
||||
// 更新登录信息
|
||||
self::updateLoginInfo($userInfo['id']);
|
||||
}
|
||||
|
||||
return $userInfo;
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序-授权登录
|
||||
* @param array $params
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:47
|
||||
*/
|
||||
public static function mnpLogin(array $params)
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
//通过code获取微信 openid
|
||||
$response = (new WeChatMnpService())->getMnpResByCode($params['code']);
|
||||
$userServer = new WechatUserService($response, UserTerminalEnum::WECHAT_MMP);
|
||||
$userInfo = $userServer->getResopnseByUserInfo()->authUserLogin()->getUserInfo();
|
||||
|
||||
// 更新登录信息
|
||||
self::updateLoginInfo($userInfo['id']);
|
||||
|
||||
Db::commit();
|
||||
return $userInfo;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 更新登录信息
|
||||
* @param $userId
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:46
|
||||
*/
|
||||
public static function updateLoginInfo($userId)
|
||||
{
|
||||
$user = User::findOrEmpty($userId);
|
||||
if ($user->isEmpty()) {
|
||||
throw new \Exception('用户不存在');
|
||||
}
|
||||
|
||||
$time = time();
|
||||
$user->login_time = $time;
|
||||
$user->login_ip = request()->ip();
|
||||
$user->update_time = $time;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序端绑定微信
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:46
|
||||
*/
|
||||
public static function mnpAuthLogin(array $params)
|
||||
{
|
||||
try {
|
||||
//通过code获取微信openid
|
||||
$response = (new WeChatMnpService())->getMnpResByCode($params['code']);
|
||||
$response['user_id'] = $params['user_id'];
|
||||
$response['terminal'] = UserTerminalEnum::WECHAT_MMP;
|
||||
|
||||
return self::createAuth($response);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号端绑定微信
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:43
|
||||
*/
|
||||
public static function oaAuthLogin(array $params)
|
||||
{
|
||||
try {
|
||||
//通过code获取微信openid
|
||||
$response = (new WeChatOaService())->getOaResByCode($params['code']);
|
||||
$response['user_id'] = $params['user_id'];
|
||||
$response['terminal'] = UserTerminalEnum::WECHAT_OA;
|
||||
|
||||
return self::createAuth($response);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成授权记录
|
||||
* @param $response
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:43
|
||||
*/
|
||||
public static function createAuth($response)
|
||||
{
|
||||
//先检查openid是否有记录
|
||||
$isAuth = UserAuth::where('openid', '=', $response['openid'])->findOrEmpty();
|
||||
if (!$isAuth->isEmpty()) {
|
||||
throw new \Exception('该微信已被绑定');
|
||||
}
|
||||
|
||||
if (isset($response['unionid']) && !empty($response['unionid'])) {
|
||||
//在用unionid找记录,防止生成两个账号,同个unionid的问题
|
||||
$userAuth = UserAuth::where(['unionid' => $response['unionid']])
|
||||
->findOrEmpty();
|
||||
if (!$userAuth->isEmpty() && $userAuth->user_id != $response['user_id']) {
|
||||
throw new \Exception('该微信已被绑定');
|
||||
}
|
||||
}
|
||||
|
||||
//如果没有授权,直接生成一条微信授权记录
|
||||
UserAuth::create([
|
||||
'user_id' => $response['user_id'],
|
||||
'openid' => $response['openid'],
|
||||
'unionid' => $response['unionid'] ?? '',
|
||||
'terminal' => $response['terminal'],
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取扫码登录地址
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 18:23
|
||||
*/
|
||||
public static function getScanCode($redirectUri)
|
||||
{
|
||||
try {
|
||||
$config = WeChatConfigService::getOpConfig();
|
||||
$appId = $config['app_id'];
|
||||
$redirectUri = UrlEncode($redirectUri);
|
||||
|
||||
// 设置有效时间标记状态, 超时扫码不可登录
|
||||
$state = MD5(time().rand(10000, 99999));
|
||||
(new WebScanLoginCache())->setScanLoginState($state);
|
||||
|
||||
// 扫码地址
|
||||
$url = WeChatRequestService::getScanCodeUrl($appId, $redirectUri, $state);
|
||||
return ['url' => $url];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 网站扫码登录
|
||||
* @param $params
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2022/10/21 10:28
|
||||
*/
|
||||
public static function scanLogin($params)
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 通过code 获取 access_token,openid,unionid等信息
|
||||
$userAuth = WeChatRequestService::getUserAuthByCode($params['code']);
|
||||
|
||||
if (empty($userAuth['openid']) || empty($userAuth['access_token'])) {
|
||||
throw new \Exception('获取用户授权信息失败');
|
||||
}
|
||||
|
||||
// 获取微信用户信息
|
||||
$response = WeChatRequestService::getUserInfoByAuth($userAuth['access_token'], $userAuth['openid']);
|
||||
|
||||
// 生成用户或更新用户信息
|
||||
$userServer = new WechatUserService($response, UserTerminalEnum::PC);
|
||||
$userInfo = $userServer->getResopnseByUserInfo()->authUserLogin()->getUserInfo();
|
||||
|
||||
// 更新登录信息
|
||||
self::updateLoginInfo($userInfo['id']);
|
||||
|
||||
Db::commit();
|
||||
return $userInfo;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 更新用户信息
|
||||
* @param $params
|
||||
* @param $userId
|
||||
* @return User
|
||||
* @author 段誉
|
||||
* @date 2023/2/22 11:19
|
||||
*/
|
||||
public static function updateUser($params, $userId)
|
||||
{
|
||||
return User::where(['id' => $userId])->update([
|
||||
'nickname' => $params['nickname'],
|
||||
'avatar' => FileService::setFileUrl($params['avatar']),
|
||||
'is_new_user' => YesNoEnum::NO
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?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\logic;
|
||||
|
||||
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\article\Article;
|
||||
use app\common\model\article\ArticleCate;
|
||||
use app\common\model\article\ArticleCollect;
|
||||
use app\common\model\decorate\DecoratePage;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\FileService;
|
||||
|
||||
|
||||
/**
|
||||
* index
|
||||
* Class IndexLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class PcLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 首页数据
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:15
|
||||
*/
|
||||
public static function getIndexData()
|
||||
{
|
||||
// 装修配置
|
||||
$decoratePage = DecoratePage::findOrEmpty(4);
|
||||
// 最新资讯
|
||||
$newArticle = self::getLimitArticle('new', 7);
|
||||
// 全部资讯
|
||||
$allArticle = self::getLimitArticle('all', 5);
|
||||
// 热门资讯
|
||||
$hotArticle = self::getLimitArticle('hot', 8);
|
||||
|
||||
return [
|
||||
'page' => $decoratePage,
|
||||
'all' => $allArticle,
|
||||
'new' => $newArticle,
|
||||
'hot' => $hotArticle
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文章
|
||||
* @param string $sortType
|
||||
* @param int $limit
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/10/19 9:53
|
||||
*/
|
||||
public static function getLimitArticle(string $sortType, int $limit = 0, int $cate = 0, int $excludeId = 0)
|
||||
{
|
||||
// 查询字段
|
||||
$field = [
|
||||
'id', 'cid', 'title', 'desc', 'abstract', 'image',
|
||||
'author', 'click_actual', 'click_virtual', 'create_time'
|
||||
];
|
||||
|
||||
// 排序条件
|
||||
$orderRaw = 'sort desc, id desc';
|
||||
if ($sortType == 'new') {
|
||||
$orderRaw = 'id desc';
|
||||
}
|
||||
if ($sortType == 'hot') {
|
||||
$orderRaw = 'click_actual + click_virtual desc, id desc';
|
||||
}
|
||||
|
||||
// 查询条件
|
||||
$where[] = ['is_show', '=', YesNoEnum::YES];
|
||||
if (!empty($cate)) {
|
||||
$where[] = ['cid', '=', $cate];
|
||||
}
|
||||
if (!empty($excludeId)) {
|
||||
$where[] = ['id', '<>', $excludeId];
|
||||
}
|
||||
|
||||
$article = Article::field($field)
|
||||
->where($where)
|
||||
->append(['click'])
|
||||
->orderRaw($orderRaw)
|
||||
->hidden(['click_actual', 'click_virtual']);
|
||||
|
||||
if ($limit) {
|
||||
$article->limit($limit);
|
||||
}
|
||||
|
||||
return $article->select()->toArray();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取配置
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 19:38
|
||||
*/
|
||||
public static function getConfigData()
|
||||
{
|
||||
// 登录配置
|
||||
$loginConfig = [
|
||||
// 登录方式
|
||||
'login_way' => ConfigService::get('login', 'login_way', config('project.login.login_way')),
|
||||
// 注册强制绑定手机
|
||||
'coerce_mobile' => ConfigService::get('login', 'coerce_mobile', config('project.login.coerce_mobile')),
|
||||
// 政策协议
|
||||
'login_agreement' => ConfigService::get('login', 'login_agreement', config('project.login.login_agreement')),
|
||||
// 第三方登录 开关
|
||||
'third_auth' => ConfigService::get('login', 'third_auth', config('project.login.third_auth')),
|
||||
// 微信授权登录
|
||||
'wechat_auth' => ConfigService::get('login', 'wechat_auth', config('project.login.wechat_auth')),
|
||||
// qq授权登录
|
||||
'qq_auth' => ConfigService::get('login', 'qq_auth', config('project.login.qq_auth')),
|
||||
];
|
||||
|
||||
// 网站信息
|
||||
$website = [
|
||||
'shop_name' => ConfigService::get('website', 'shop_name'),
|
||||
'shop_logo' => FileService::getFileUrl(ConfigService::get('website', 'shop_logo')),
|
||||
'pc_logo' => FileService::getFileUrl(ConfigService::get('website', 'pc_logo')),
|
||||
'pc_title' => ConfigService::get('website', 'pc_title'),
|
||||
'pc_ico' => FileService::getFileUrl(ConfigService::get('website', 'pc_ico')),
|
||||
'pc_desc' => ConfigService::get('website', 'pc_desc'),
|
||||
'pc_keywords' => ConfigService::get('website', 'pc_keywords'),
|
||||
];
|
||||
|
||||
// 站点统计
|
||||
$siteStatistics = [
|
||||
'clarity_code' => ConfigService::get('siteStatistics', 'clarity_code'),
|
||||
];
|
||||
|
||||
// 备案信息
|
||||
$copyright = ConfigService::get('copyright', 'config', []);
|
||||
|
||||
// 公众号二维码
|
||||
$oaQrCode = ConfigService::get('oa_setting', 'qr_code', '');
|
||||
$oaQrCode = empty($oaQrCode) ? $oaQrCode : FileService::getFileUrl($oaQrCode);
|
||||
// 小程序二维码
|
||||
$mnpQrCode = ConfigService::get('mnp_setting', 'qr_code', '');
|
||||
$mnpQrCode = empty($mnpQrCode) ? $mnpQrCode : FileService::getFileUrl($mnpQrCode);
|
||||
|
||||
return [
|
||||
'domain' => FileService::getFileUrl(),
|
||||
'login' => $loginConfig,
|
||||
'website' => $website,
|
||||
'siteStatistics' => $siteStatistics,
|
||||
'version' => config('project.version'),
|
||||
'copyright' => $copyright,
|
||||
'admin_url' => request()->domain() . '/admin',
|
||||
'qrcode' => [
|
||||
'oa' => $oaQrCode,
|
||||
'mnp' => $mnpQrCode,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 资讯中心
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/10/19 16:55
|
||||
*/
|
||||
public static function getInfoCenter()
|
||||
{
|
||||
$data = ArticleCate::field(['id', 'name'])
|
||||
->with(['article' => function ($query) {
|
||||
$query->hidden(['content', 'click_virtual', 'click_actual'])
|
||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
||||
->append(['click'])
|
||||
->limit(10);
|
||||
}])
|
||||
->where(['is_show' => YesNoEnum::YES])
|
||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文章详情
|
||||
* @param $userId
|
||||
* @param $articleId
|
||||
* @param string $source
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 15:18
|
||||
*/
|
||||
public static function getArticleDetail($userId, $articleId, $source = 'default')
|
||||
{
|
||||
// 文章详情
|
||||
$detail = Article::getArticleDetailArr($articleId);
|
||||
|
||||
// 根据来源列表查找对应列表
|
||||
$nowIndex = 0;
|
||||
$lists = self::getLimitArticle($source, 0, $detail['cid']);
|
||||
foreach ($lists as $key => $item) {
|
||||
if ($item['id'] == $articleId) {
|
||||
$nowIndex = $key;
|
||||
}
|
||||
}
|
||||
// 上一篇
|
||||
$detail['last'] = $lists[$nowIndex - 1] ?? [];
|
||||
// 下一篇
|
||||
$detail['next'] = $lists[$nowIndex + 1] ?? [];
|
||||
|
||||
// 最新资讯
|
||||
$detail['new'] = self::getLimitArticle('new', 8, $detail['cid'], $detail['id']);
|
||||
// 关注状态
|
||||
$detail['collect'] = ArticleCollect::isCollectArticle($userId, $articleId);
|
||||
// 分类名
|
||||
$detail['cate_name'] = ArticleCate::where('id', $detail['cid'])->value('name');
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?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\logic;
|
||||
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\recharge\RechargeOrder;
|
||||
use app\common\model\user\User;
|
||||
use app\common\service\ConfigService;
|
||||
|
||||
|
||||
/**
|
||||
* 充值逻辑层
|
||||
* Class RechargeLogic
|
||||
* @package app\shopapi\logic
|
||||
*/
|
||||
class RechargeLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 充值
|
||||
* @param array $params
|
||||
* @return array|false
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 10:43
|
||||
*/
|
||||
public static function recharge(array $params)
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'sn' => generate_sn(RechargeOrder::class, 'sn'),
|
||||
'order_terminal' => $params['terminal'],
|
||||
'user_id' => $params['user_id'],
|
||||
'pay_status' => PayEnum::UNPAID,
|
||||
'order_amount' => $params['money'],
|
||||
];
|
||||
$order = RechargeOrder::create($data);
|
||||
|
||||
return [
|
||||
'order_id' => (int)$order['id'],
|
||||
'from' => 'recharge'
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 充值配置
|
||||
* @param $userId
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 16:56
|
||||
*/
|
||||
public static function config($userId)
|
||||
{
|
||||
$userMoney = User::where(['id' => $userId])->value('user_money');
|
||||
$minAmount = ConfigService::get('recharge', 'min_amount', 0);
|
||||
$status = ConfigService::get('recharge', 'status', 0);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'min_amount' => $minAmount,
|
||||
'user_money' => $userMoney,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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\logic;
|
||||
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\HotSearch;
|
||||
use app\common\service\ConfigService;
|
||||
|
||||
/**
|
||||
* 搜索逻辑
|
||||
* Class SearchLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class SearchLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 热搜列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/23 14:34
|
||||
*/
|
||||
public static function hotLists()
|
||||
{
|
||||
$data = HotSearch::field(['name', 'sort'])
|
||||
->order(['sort' => 'desc', 'id' => 'desc'])
|
||||
->select()->toArray();
|
||||
|
||||
return [
|
||||
// 功能状态 0-关闭 1-开启
|
||||
'status' => ConfigService::get('hot_search', 'status', 0),
|
||||
// 热门搜索数据
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?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\logic;
|
||||
|
||||
use app\common\enum\notice\NoticeEnum;
|
||||
use app\common\logic\BaseLogic;
|
||||
|
||||
|
||||
/**
|
||||
* 短信逻辑
|
||||
* Class SmsLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class SmsLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 发送验证码
|
||||
* @param $params
|
||||
* @return false|mixed
|
||||
* @author 段誉
|
||||
* @date 2022/9/15 16:17
|
||||
*/
|
||||
public static function sendCode($params)
|
||||
{
|
||||
try {
|
||||
$scene = NoticeEnum::getSceneByTag($params['scene']);
|
||||
if (empty($scene)) {
|
||||
throw new \Exception('场景值异常');
|
||||
}
|
||||
|
||||
$result = event('Notice', [
|
||||
'scene_id' => $scene,
|
||||
'params' => [
|
||||
'mobile' => $params['mobile'],
|
||||
'code' => mt_rand(1000, 9999),
|
||||
]
|
||||
]);
|
||||
|
||||
return $result[0];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?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\logic;
|
||||
|
||||
|
||||
use app\common\{enum\notice\NoticeEnum,
|
||||
enum\user\UserTerminalEnum,
|
||||
enum\YesNoEnum,
|
||||
logic\BaseLogic,
|
||||
model\user\User,
|
||||
model\user\UserAuth,
|
||||
service\FileService,
|
||||
service\sms\SmsDriver,
|
||||
service\wechat\WeChatMnpService};
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 会员逻辑层
|
||||
* Class UserLogic
|
||||
* @package app\shopapi\logic
|
||||
*/
|
||||
class UserLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 个人中心
|
||||
* @param array $userInfo
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:04
|
||||
*/
|
||||
public static function center(array $userInfo): array
|
||||
{
|
||||
$user = User::where(['id' => $userInfo['user_id']])
|
||||
->field('id,sn,sex,account,nickname,real_name,avatar,mobile,create_time,is_new_user,user_money,password')
|
||||
->findOrEmpty();
|
||||
|
||||
if (in_array($userInfo['terminal'], [UserTerminalEnum::WECHAT_MMP, UserTerminalEnum::WECHAT_OA])) {
|
||||
$auth = UserAuth::where(['user_id' => $userInfo['user_id'], 'terminal' => $userInfo['terminal']])->find();
|
||||
$user['is_auth'] = $auth ? YesNoEnum::YES : YesNoEnum::NO;
|
||||
}
|
||||
|
||||
$user['has_password'] = !empty($user['password']);
|
||||
$user->hidden(['password']);
|
||||
return $user->toArray();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 个人信息
|
||||
* @param $userId
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:45
|
||||
*/
|
||||
public static function info(int $userId)
|
||||
{
|
||||
|
||||
$user = User::where(['id' => $userId])
|
||||
->with('diagnosis')
|
||||
->field('id,sn,sex,account,password,nickname,real_name,avatar,mobile,age,create_time,user_money')
|
||||
->findOrEmpty();
|
||||
$user['has_password'] = !empty($user['password']);
|
||||
$user['has_auth'] = self::hasWechatAuth($userId);
|
||||
$user['version'] = config('project.version');
|
||||
$user->hidden(['password']);
|
||||
|
||||
// 字段映射,便于前端使用
|
||||
$result = $user->toArray();
|
||||
$result['phone'] = $result['mobile'] ?? '';
|
||||
$result['gender'] = $result['sex'] ?? 1;
|
||||
$result['patient_name'] = $result['real_name'] ?? '';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置用户信息
|
||||
* @param int $userId
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 16:53
|
||||
*/
|
||||
public static function setInfo(int $userId, array $params)
|
||||
{
|
||||
try {
|
||||
// 支持批量更新多个字段
|
||||
$updateData = ['id' => $userId];
|
||||
|
||||
// 字段映射
|
||||
$fieldMap = [
|
||||
'avatar' => 'avatar',
|
||||
'phone' => 'mobile',
|
||||
'nickname' => 'nickname',
|
||||
'sex' => 'sex',
|
||||
'age' => 'age',
|
||||
'patient_name' => 'real_name'
|
||||
];
|
||||
|
||||
// 处理头像
|
||||
if (isset($params['avatar']) && !empty($params['avatar'])) {
|
||||
$updateData['avatar'] = FileService::setFileUrl($params['avatar']);
|
||||
}
|
||||
|
||||
// 处理其他字段
|
||||
foreach ($fieldMap as $key => $field) {
|
||||
if ($key !== 'avatar' && isset($params[$key])) {
|
||||
$updateData[$field] = $params[$key];
|
||||
}
|
||||
}
|
||||
|
||||
User::update($updateData);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 是否有微信授权信息
|
||||
* @param $userId
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:36
|
||||
*/
|
||||
public static function hasWechatAuth(int $userId)
|
||||
{
|
||||
//是否有微信授权登录
|
||||
$terminal = [UserTerminalEnum::WECHAT_MMP, UserTerminalEnum::WECHAT_OA,UserTerminalEnum::PC];
|
||||
$auth = UserAuth::where(['user_id' => $userId])
|
||||
->whereIn('terminal', $terminal)
|
||||
->findOrEmpty();
|
||||
return !$auth->isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 重置登录密码
|
||||
* @param $params
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:06
|
||||
*/
|
||||
public static function resetPassword(array $params)
|
||||
{
|
||||
try {
|
||||
// 校验验证码
|
||||
$smsDriver = new SmsDriver();
|
||||
if (!$smsDriver->verify($params['mobile'], $params['code'], NoticeEnum::FIND_LOGIN_PASSWORD_CAPTCHA)) {
|
||||
throw new \Exception('验证码错误');
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
$passwordSalt = Config::get('project.unique_identification');
|
||||
$password = create_password($params['password'], $passwordSalt);
|
||||
|
||||
// 更新
|
||||
User::where('mobile', $params['mobile'])->update([
|
||||
'password' => $password
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 修稿密码
|
||||
* @param $params
|
||||
* @param $userId
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:13
|
||||
*/
|
||||
public static function changePassword(array $params, int $userId)
|
||||
{
|
||||
try {
|
||||
$user = User::findOrEmpty($userId);
|
||||
if ($user->isEmpty()) {
|
||||
throw new \Exception('用户不存在');
|
||||
}
|
||||
|
||||
// 密码盐
|
||||
$passwordSalt = Config::get('project.unique_identification');
|
||||
|
||||
if (!empty($user['password'])) {
|
||||
if (empty($params['old_password'])) {
|
||||
throw new \Exception('请填写旧密码');
|
||||
}
|
||||
$oldPassword = create_password($params['old_password'], $passwordSalt);
|
||||
if ($oldPassword != $user['password']) {
|
||||
throw new \Exception('原密码不正确');
|
||||
}
|
||||
}
|
||||
|
||||
// 保存密码
|
||||
$password = create_password($params['password'], $passwordSalt);
|
||||
$user->password = $password;
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取小程序手机号
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 11:49
|
||||
*/
|
||||
public static function getMobileByMnp(array $params)
|
||||
{
|
||||
try {
|
||||
$response = (new WeChatMnpService())->getUserPhoneNumber($params['code']);
|
||||
$phoneNumber = $response['phone_info']['purePhoneNumber'] ?? '';
|
||||
if (empty($phoneNumber)) {
|
||||
throw new \Exception('获取手机号码失败');
|
||||
}
|
||||
|
||||
$user = User::where([
|
||||
['mobile', '=', $phoneNumber],
|
||||
['id', '<>', $params['user_id']]
|
||||
])->findOrEmpty();
|
||||
|
||||
if (!$user->isEmpty()) {
|
||||
throw new \Exception('手机号已被其他账号绑定');
|
||||
}
|
||||
|
||||
// 绑定手机号
|
||||
$update = [
|
||||
'id' => $params['user_id'],
|
||||
'mobile' => $phoneNumber,
|
||||
];
|
||||
if (isset($params['sex']) && in_array((int) $params['sex'], [1, 2], true)) {
|
||||
$update['sex'] = (int) $params['sex'];
|
||||
}
|
||||
User::update($update);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 绑定手机号
|
||||
* @param $params
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 17:28
|
||||
*/
|
||||
public static function bindMobile(array $params)
|
||||
{
|
||||
try {
|
||||
// 变更手机号场景
|
||||
$sceneId = NoticeEnum::CHANGE_MOBILE_CAPTCHA;
|
||||
$where = [
|
||||
['id', '=', $params['user_id']],
|
||||
['mobile', '=', $params['mobile']]
|
||||
];
|
||||
|
||||
// 绑定手机号场景
|
||||
if ($params['type'] == 'bind') {
|
||||
$sceneId = NoticeEnum::BIND_MOBILE_CAPTCHA;
|
||||
$where = [
|
||||
['mobile', '=', $params['mobile']]
|
||||
];
|
||||
}
|
||||
|
||||
// 校验短信
|
||||
$checkSmsCode = (new SmsDriver())->verify($params['mobile'], $params['code'], $sceneId);
|
||||
if (!$checkSmsCode) {
|
||||
throw new \Exception('验证码错误');
|
||||
}
|
||||
|
||||
$user = User::where($where)->findOrEmpty();
|
||||
if (!$user->isEmpty()) {
|
||||
throw new \Exception('该手机号已被使用');
|
||||
}
|
||||
|
||||
User::update([
|
||||
'id' => $params['user_id'],
|
||||
'mobile' => $params['mobile'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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\logic;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\service\wechat\WeChatOaService;
|
||||
use EasyWeChat\Kernel\Exceptions\Exception;
|
||||
|
||||
/**
|
||||
* 微信
|
||||
* Class WechatLogic
|
||||
* @package app\api\logic
|
||||
*/
|
||||
class WechatLogic extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 微信JSSDK授权接口
|
||||
* @param $params
|
||||
* @return false|mixed[]
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 11:49
|
||||
*/
|
||||
public static function jsConfig($params)
|
||||
{
|
||||
try {
|
||||
$url = urldecode($params['url']);
|
||||
return (new WeChatOaService())->getJsConfig($url, [
|
||||
'onMenuShareTimeline',
|
||||
'onMenuShareAppMessage',
|
||||
'onMenuShareQQ',
|
||||
'onMenuShareWeibo',
|
||||
'onMenuShareQZone',
|
||||
'openLocation',
|
||||
'getLocation',
|
||||
'chooseWXPay',
|
||||
'updateAppMessageShareData',
|
||||
'updateTimelineShareData',
|
||||
'openAddress',
|
||||
'scanQRCode'
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
self::setError('获取jssdk失败:' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\AiChatService;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
|
||||
*/
|
||||
class DailyBloodCareAiLogic
|
||||
{
|
||||
private const CACHE_TTL = 86400;
|
||||
|
||||
private const MIN_RECORD_DAYS = 7;
|
||||
|
||||
private const AI_TIMEOUT_SEC = 14;
|
||||
|
||||
private const AI_STREAM_TIMEOUT_SEC = 22;
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,error?:string,data?:array}
|
||||
*/
|
||||
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return ['ok' => false, 'error' => '缺少就诊卡'];
|
||||
}
|
||||
|
||||
$context = self::buildCareContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
|
||||
}
|
||||
|
||||
$gate = self::ensureEnoughBloodDays($context['data']);
|
||||
if (!$gate['ok']) {
|
||||
return ['ok' => false, 'error' => $gate['error']];
|
||||
}
|
||||
|
||||
$analysis = self::buildAnalysisPayload($context['data']);
|
||||
$cacheKey = self::careCacheKey($diagnosisId);
|
||||
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['summary'])) {
|
||||
$cached['cached'] = true;
|
||||
$cached['analysis'] = $analysis;
|
||||
return ['ok' => true, 'data' => $cached];
|
||||
}
|
||||
}
|
||||
|
||||
$data = null;
|
||||
if (AiChatService::isEnabled()) {
|
||||
$raw = self::aiDailyCarePlan($context['data'], $refresh);
|
||||
if (is_array($raw) && !empty($raw['summary'])) {
|
||||
$data = self::finalizeCarePlan($raw);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
$data = GiKnowledge::fallbackBloodCarePlan(
|
||||
$context['data']['stats_7d'] ?? [],
|
||||
$context['data']['stats_30d'] ?? [],
|
||||
$context['data']
|
||||
);
|
||||
}
|
||||
|
||||
$data['date'] = date('Y-m-d');
|
||||
$data['cached'] = false;
|
||||
$data['analysis'] = $analysis;
|
||||
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||
|
||||
return ['ok' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, array<string, mixed>):void $emit
|
||||
*/
|
||||
public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
$emit('error', ['message' => '缺少就诊卡']);
|
||||
return;
|
||||
}
|
||||
|
||||
$context = self::buildCareContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
|
||||
return;
|
||||
}
|
||||
|
||||
$gate = self::ensureEnoughBloodDays($context['data']);
|
||||
if (!$gate['ok']) {
|
||||
$emit('error', ['message' => $gate['error']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$analysis = self::buildAnalysisPayload($context['data']);
|
||||
$emit('analysis', ['analysis' => $analysis]);
|
||||
|
||||
$cacheKey = self::careCacheKey($diagnosisId);
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['summary'])) {
|
||||
$cached['cached'] = true;
|
||||
$cached['analysis'] = $analysis;
|
||||
$emit('done', $cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$data = null;
|
||||
$fullText = '';
|
||||
|
||||
if (AiChatService::isEnabled()) {
|
||||
[$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh);
|
||||
$streamRes = AiChatService::streamChat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], static function (string $delta) use (&$fullText, $emit): void {
|
||||
$fullText .= $delta;
|
||||
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
|
||||
}, [
|
||||
'temperature' => $refresh ? 0.75 : 0.4,
|
||||
'max_tokens' => 720,
|
||||
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
|
||||
]);
|
||||
|
||||
if ($streamRes['ok'] && trim($fullText) !== '') {
|
||||
$parsed = self::parseCarePlainText(trim($fullText));
|
||||
if ($parsed && !empty($parsed['summary'])) {
|
||||
$data = self::finalizeCarePlan($parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
$data = GiKnowledge::fallbackBloodCarePlan(
|
||||
$context['data']['stats_7d'] ?? [],
|
||||
$context['data']['stats_30d'] ?? [],
|
||||
$context['data']
|
||||
);
|
||||
}
|
||||
|
||||
$data['date'] = date('Y-m-d');
|
||||
$data['cached'] = false;
|
||||
$data['analysis'] = $analysis;
|
||||
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||
$emit('done', $data);
|
||||
}
|
||||
|
||||
private static function careCacheKey(int $diagnosisId): string
|
||||
{
|
||||
return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,error?:string,data?:array}
|
||||
*/
|
||||
private static function buildCareContext(int $diagnosisId): array
|
||||
{
|
||||
$base = DailyDietAiLogic::getPatientContext($diagnosisId);
|
||||
if (!$base['ok']) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if ($diagnosis) {
|
||||
$row = $diagnosis->toArray();
|
||||
$base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? ''));
|
||||
$base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? ''));
|
||||
$base['data']['remark'] = trim((string) ($row['remark'] ?? ''));
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{ok:bool,error?:string}
|
||||
*/
|
||||
private static function ensureEnoughBloodDays(array $context): array
|
||||
{
|
||||
$days30 = (int) (($context['stats_30d']['record_days'] ?? 0));
|
||||
$days7 = (int) (($context['stats_7d']['record_days'] ?? 0));
|
||||
$total = max($days30, $days7);
|
||||
if ($total < self::MIN_RECORD_DAYS) {
|
||||
return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function buildAnalysisPayload(array $context): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['stats_7d', 'stats_30d'] as $key) {
|
||||
$s = $context[$key] ?? null;
|
||||
if (is_array($s)) {
|
||||
$out[] = $s;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function aiDailyCarePlan(array $context, bool $refresh): ?array
|
||||
{
|
||||
[$system, $user] = self::buildCareJsonPrompts($context, $refresh);
|
||||
$res = AiChatService::chat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], [
|
||||
'temperature' => $refresh ? 0.7 : 0.35,
|
||||
'max_tokens' => 650,
|
||||
'timeout' => self::AI_TIMEOUT_SEC,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
|
||||
if (empty($res['ok']) || empty($res['content'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = self::parseJsonObject((string) $res['content']);
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private static function buildCareJsonPrompts(array $context, bool $refresh): array
|
||||
{
|
||||
$bloodLine = self::bloodLine($context);
|
||||
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
|
||||
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
|
||||
$syndrome = trim((string) ($context['syndrome_type'] ?? ''));
|
||||
$symptoms = trim((string) ($context['symptoms'] ?? ''));
|
||||
|
||||
$system = <<<SYS
|
||||
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
|
||||
|
||||
硬性要求:
|
||||
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
|
||||
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
|
||||
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
|
||||
4. 只输出 JSON:
|
||||
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s;%s。\n请根据控制好坏给出个性化 JSON。",
|
||||
$context['patient_name'] ?: '患者',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$syndrome !== '' ? $syndrome : '未登记',
|
||||
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
$stat7Line,
|
||||
$stat30Line
|
||||
);
|
||||
|
||||
if ($refresh) {
|
||||
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||
}
|
||||
|
||||
return [$system, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private static function buildCareStreamPrompts(array $context, bool $refresh): array
|
||||
{
|
||||
$bloodLine = self::bloodLine($context);
|
||||
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
|
||||
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
|
||||
|
||||
$system = <<<SYS
|
||||
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
|
||||
|
||||
硬性要求:
|
||||
1. 不得写具体中药方名剂量、不得指导自行调药。
|
||||
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
|
||||
3. 严格按下面 5 行输出,不要 JSON、不要 markdown:
|
||||
总评:(一句话)
|
||||
血糖:(监测、饮食、运动,2-4句)
|
||||
中医:(辨证思路、食疗艾灸方向,2-4句)
|
||||
留意:(用顿号隔开 2-4 条)
|
||||
复诊:(下一步)
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s;%s。",
|
||||
$context['patient_name'] ?: '患者',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
($context['syndrome_type'] ?? '') ?: '未登记',
|
||||
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
|
||||
$bloodLine,
|
||||
$stat7Line,
|
||||
$stat30Line
|
||||
);
|
||||
|
||||
if ($refresh) {
|
||||
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||
}
|
||||
|
||||
return [$system, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
private static function bloodLine(array $context): string
|
||||
{
|
||||
$recent = $context['blood_recent'] ?? [];
|
||||
if (!is_array($recent) || empty($recent)) {
|
||||
return '近7日无逐日明细';
|
||||
}
|
||||
|
||||
return implode(';', $recent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $stats
|
||||
*/
|
||||
private static function statsLine(array $stats): string
|
||||
{
|
||||
$label = (string) ($stats['label'] ?? '统计');
|
||||
if (($stats['record_days'] ?? 0) <= 0) {
|
||||
return $label . '无记录';
|
||||
}
|
||||
|
||||
$parts = [
|
||||
$label . "有记录{$stats['record_days']}天",
|
||||
"偏高{$stats['high_days']}天",
|
||||
"达标率{$stats['compliance']}%",
|
||||
];
|
||||
if ($stats['fasting_avg'] !== null) {
|
||||
$parts[] = "空腹均值{$stats['fasting_avg']}";
|
||||
}
|
||||
if ($stats['postprandial_avg'] !== null) {
|
||||
$parts[] = "餐后均值{$stats['postprandial_avg']}";
|
||||
}
|
||||
|
||||
return implode(',', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function parseCarePlainText(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($text[0] === '{') {
|
||||
$json = self::parseJsonObject($text);
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
if (preg_match('/总评[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/血糖[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/中医[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/留意[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
$parts = preg_split('/[、,,;;\s]+/u', $line) ?: [];
|
||||
$map['watch_points'] = array_values(array_filter(array_map('trim', $parts)));
|
||||
}
|
||||
if (preg_match('/复诊[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
|
||||
if (empty($map['summary'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $raw
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function finalizeCarePlan(array $raw): array
|
||||
{
|
||||
$watch = $raw['watch_points'] ?? [];
|
||||
if (!is_array($watch)) {
|
||||
$watch = preg_split('/[、,,;;\n]+/u', (string) $watch) ?: [];
|
||||
}
|
||||
$watch = array_values(array_filter(array_map('trim', $watch)));
|
||||
|
||||
return [
|
||||
'summary' => trim((string) ($raw['summary'] ?? '')),
|
||||
'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')),
|
||||
'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')),
|
||||
'watch_points' => $watch,
|
||||
'next_steps' => trim((string) ($raw['next_steps'] ?? '')),
|
||||
'control_level' => (string) ($raw['control_level'] ?? ''),
|
||||
'control_label' => (string) ($raw['control_label'] ?? ''),
|
||||
'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')),
|
||||
'source' => (string) ($raw['source'] ?? 'ai'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function parseJsonObject(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/\{[\s\S]*\}/u', $text, $m)) {
|
||||
$text = $m[0];
|
||||
}
|
||||
$data = json_decode($text, true);
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\AiChatService;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 日常护理 - AI 饮食建议(基于霍大夫升糖指数)
|
||||
*/
|
||||
class DailyDietAiLogic
|
||||
{
|
||||
private const CACHE_TTL = 86400;
|
||||
|
||||
/** 单次 AI 请求超时(秒),避免接口长时间挂起 */
|
||||
private const AI_TIMEOUT_SEC = 12;
|
||||
|
||||
/** 流式 AI 请求超时(秒) */
|
||||
private const AI_STREAM_TIMEOUT_SEC = 18;
|
||||
|
||||
/**
|
||||
* 今日推荐饮食(按诊单+日期缓存)
|
||||
*
|
||||
* @return array{ok:bool,error?:string,data?:array}
|
||||
*/
|
||||
public static function getDailyRecommend(int $diagnosisId, bool $refresh = false): array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return ['ok' => false, 'error' => '缺少就诊卡'];
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
|
||||
}
|
||||
$analysis = self::buildAnalysisPayload($context['data']);
|
||||
$exercise = self::buildExercisePayload($context['data']);
|
||||
|
||||
$cacheKey = self::recommendCacheKey($diagnosisId);
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['breakfast'])) {
|
||||
$cached['cached'] = true;
|
||||
$cached['analysis'] = $analysis;
|
||||
$cached['exercise'] = $exercise;
|
||||
return ['ok' => true, 'data' => $cached];
|
||||
}
|
||||
}
|
||||
|
||||
$data = null;
|
||||
if (AiChatService::isEnabled()) {
|
||||
$raw = self::aiDailyRecommend($context['data'], $refresh);
|
||||
if (is_array($raw) && !empty($raw['breakfast'])) {
|
||||
$data = self::finalizeAiRecommendPlan($raw);
|
||||
}
|
||||
}
|
||||
if (!$data) {
|
||||
$tplIdx = self::resolveFallbackTemplateIndex($diagnosisId, $refresh);
|
||||
$data = GiKnowledge::fallbackDailyMeals($diagnosisId, $tplIdx);
|
||||
$data['disclaimer'] = '按升糖指数与农家饭硬性规则推荐(午餐≥2样蛋白、淀粉不重复)。';
|
||||
}
|
||||
|
||||
$data['date'] = date('Y-m-d');
|
||||
$data['cached'] = false;
|
||||
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||
$data['analysis'] = $analysis;
|
||||
$data['exercise'] = $exercise;
|
||||
|
||||
return ['ok' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* 询问某食物能不能吃
|
||||
*
|
||||
* @return array{ok:bool,error?:string,data?:array}
|
||||
*/
|
||||
public static function askFood(int $diagnosisId, string $question): array
|
||||
{
|
||||
$question = trim($question);
|
||||
if ($question === '') {
|
||||
return ['ok' => false, 'error' => '请输入想咨询的食物'];
|
||||
}
|
||||
if (mb_strlen($question) > 80) {
|
||||
return ['ok' => false, 'error' => '问题过长,请简短描述'];
|
||||
}
|
||||
|
||||
$cacheKey = 'daily_diet_ai_ask:' . md5($diagnosisId . ':' . mb_strtolower($question));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['advice'])) {
|
||||
$cached['cached'] = true;
|
||||
return ['ok' => true, 'data' => $cached];
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
$data = null;
|
||||
|
||||
if (AiChatService::isEnabled()) {
|
||||
$data = self::aiAskFood($context['data'] ?? [], $question);
|
||||
}
|
||||
if (!$data) {
|
||||
$data = GiKnowledge::fallbackAsk($question);
|
||||
}
|
||||
|
||||
$data['question'] = $question;
|
||||
$data['disclaimer'] = '仅供参考,不能替代医嘱。';
|
||||
$data['cached'] = false;
|
||||
Cache::set($cacheKey, $data, 3600);
|
||||
|
||||
return ['ok' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式:今日饮食推荐(SSE delta + done)
|
||||
*
|
||||
* @param callable(string, array<string, mixed>):void $emit event: delta|done|error
|
||||
*/
|
||||
public static function streamDailyRecommend(int $diagnosisId, bool $refresh, callable $emit): void
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
$emit('error', ['message' => '缺少就诊卡']);
|
||||
return;
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
|
||||
return;
|
||||
}
|
||||
$analysis = self::buildAnalysisPayload($context['data']);
|
||||
$exercise = self::buildExercisePayload($context['data']);
|
||||
// 先把「分析依据」「运动建议」推给前端,提升首屏感知
|
||||
$emit('analysis', ['analysis' => $analysis]);
|
||||
$emit('exercise', ['exercise' => $exercise]);
|
||||
|
||||
$cacheKey = self::recommendCacheKey($diagnosisId);
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['breakfast'])) {
|
||||
$cached['cached'] = true;
|
||||
$cached['analysis'] = $analysis;
|
||||
$cached['exercise'] = $exercise;
|
||||
$emit('done', $cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$data = null;
|
||||
$fullText = '';
|
||||
|
||||
if (AiChatService::isEnabled()) {
|
||||
[$system, $user] = self::buildRecommendStreamPrompts($context['data'], $refresh);
|
||||
$streamRes = AiChatService::streamChat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], static function (string $delta) use (&$fullText, $emit): void {
|
||||
$fullText .= $delta;
|
||||
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
|
||||
}, [
|
||||
'temperature' => $refresh ? 0.78 : 0.35,
|
||||
'max_tokens' => 400,
|
||||
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
|
||||
]);
|
||||
|
||||
if ($streamRes['ok'] && $fullText !== '') {
|
||||
$parsed = self::parseRecommendPlainText($fullText);
|
||||
if ($parsed && !empty($parsed['breakfast'])) {
|
||||
$data = self::finalizeAiRecommendPlan($parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
$tplIdx = self::resolveFallbackTemplateIndex($diagnosisId, $refresh);
|
||||
$data = GiKnowledge::fallbackDailyMeals($diagnosisId, $tplIdx);
|
||||
$data['source'] = 'rule';
|
||||
$data['disclaimer'] = '按升糖指数与农家饭硬性规则推荐(午餐≥2样蛋白、淀粉不重复)。';
|
||||
}
|
||||
|
||||
$data['date'] = date('Y-m-d');
|
||||
$data['cached'] = false;
|
||||
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||
$data['analysis'] = $analysis;
|
||||
$data['exercise'] = $exercise;
|
||||
$emit('done', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式:能不能吃(SSE delta + done)
|
||||
*
|
||||
* @param callable(string, array<string, mixed>):void $emit event: delta|done|error
|
||||
*/
|
||||
public static function streamAskFood(int $diagnosisId, string $question, callable $emit): void
|
||||
{
|
||||
$question = trim($question);
|
||||
if ($question === '') {
|
||||
$emit('error', ['message' => '请输入想咨询的食物']);
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($question) > 80) {
|
||||
$emit('error', ['message' => '问题过长,请简短描述']);
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = 'daily_diet_ai_ask:' . md5($diagnosisId . ':' . mb_strtolower($question));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['advice'])) {
|
||||
$cached['cached'] = true;
|
||||
$emit('done', $cached);
|
||||
return;
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
|
||||
$data = null;
|
||||
$fullText = '';
|
||||
|
||||
if (AiChatService::isEnabled()) {
|
||||
[$system, $user] = self::buildAskStreamPrompts($context['data'] ?? [], $question);
|
||||
$streamRes = AiChatService::streamChat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], static function (string $delta) use (&$fullText, $emit): void {
|
||||
$fullText .= $delta;
|
||||
$emit('delta', ['text' => $delta, 'advice' => $fullText]);
|
||||
}, [
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 200,
|
||||
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
|
||||
]);
|
||||
|
||||
if ($streamRes['ok'] && trim($fullText) !== '') {
|
||||
$data = self::finalizeAskFromText($context['data'] ?? [], $question, trim($fullText));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
$data = GiKnowledge::fallbackAsk($question);
|
||||
}
|
||||
|
||||
$data['question'] = $question;
|
||||
$data['disclaimer'] = '仅供参考,不能替代医嘱。';
|
||||
$data['cached'] = false;
|
||||
Cache::set($cacheKey, $data, 3600);
|
||||
$emit('done', $data);
|
||||
}
|
||||
|
||||
private static function recommendCacheKey(int $diagnosisId): string
|
||||
{
|
||||
return 'daily_diet_ai_rec_v2:' . $diagnosisId . ':' . date('Y-m-d');
|
||||
}
|
||||
|
||||
/** 换一换时轮换规则模板下标(与 GiKnowledge::$mealTemplates 数量一致) */
|
||||
private static function resolveFallbackTemplateIndex(int $diagnosisId, bool $refresh): int
|
||||
{
|
||||
$count = 6;
|
||||
$cacheKey = 'daily_diet_ai_tpl_idx:' . $diagnosisId . ':' . date('Y-m-d');
|
||||
$defaultIdx = abs(crc32(date('Y-m-d') . ':' . $diagnosisId)) % $count;
|
||||
|
||||
if (!$refresh) {
|
||||
Cache::set($cacheKey, $defaultIdx, self::CACHE_TTL);
|
||||
return $defaultIdx;
|
||||
}
|
||||
|
||||
$last = Cache::get($cacheKey);
|
||||
if ($last === null || $last === false) {
|
||||
$last = $defaultIdx;
|
||||
}
|
||||
$idx = (((int) $last) + 1) % $count;
|
||||
Cache::set($cacheKey, $idx, self::CACHE_TTL);
|
||||
|
||||
return $idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,error?:string,data?:array}
|
||||
*/
|
||||
private static function buildPatientContext(int $diagnosisId): array
|
||||
{
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diagnosis) {
|
||||
return ['ok' => false, 'error' => '就诊卡不存在'];
|
||||
}
|
||||
|
||||
$row = $diagnosis->toArray();
|
||||
$gender = (int) ($row['gender'] ?? 0);
|
||||
$genderText = $gender === 1 ? '男' : ($gender === 0 ? '女' : '未知');
|
||||
$age = (int) ($row['age'] ?? 0);
|
||||
|
||||
// 拉取近 30 天血糖(涵盖近 7 天),统一计算 7/30 天统计
|
||||
$since30 = strtotime('-30 days 00:00:00');
|
||||
$bloodList = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $since30)
|
||||
->where('delete_time', null)
|
||||
->order('record_date', 'desc')
|
||||
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$since7 = strtotime('-7 days 00:00:00');
|
||||
$list7 = array_values(array_filter($bloodList, static function ($b) use ($since7) {
|
||||
return (int) ($b['record_date'] ?? 0) >= $since7;
|
||||
}));
|
||||
|
||||
// 近 7 天逐日明细(喂给 AI 的细节)
|
||||
$bloodSummary = [];
|
||||
foreach (array_slice($list7, 0, 7) as $b) {
|
||||
$date = !empty($b['record_date']) ? date('Y-m-d', (int) $b['record_date']) : '';
|
||||
$parts = [];
|
||||
if ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') {
|
||||
$parts[] = '空腹' . $b['fasting_blood_sugar'];
|
||||
}
|
||||
if ($b['postprandial_blood_sugar'] !== null && $b['postprandial_blood_sugar'] !== '') {
|
||||
$parts[] = '餐后' . $b['postprandial_blood_sugar'];
|
||||
}
|
||||
if ($b['other_blood_sugar'] !== null && $b['other_blood_sugar'] !== '') {
|
||||
$parts[] = '其他' . $b['other_blood_sugar'];
|
||||
}
|
||||
if ($date && $parts) {
|
||||
$bloodSummary[] = $date . ':' . implode(',', $parts) . ' mmol/L';
|
||||
}
|
||||
}
|
||||
|
||||
$stats7 = self::aggregateBloodStats($list7, $age, '近7天');
|
||||
$stats30 = self::aggregateBloodStats($bloodList, $age, '近30天');
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'patient_name' => (string) ($row['patient_name'] ?? ''),
|
||||
'age' => $age,
|
||||
'gender_text' => $genderText,
|
||||
'blood_recent' => $bloodSummary,
|
||||
'stats_7d' => $stats7,
|
||||
'stats_30d' => $stats30,
|
||||
'today' => date('Y-m-d'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据年龄返回血糖偏高阈值(与前端 getBloodSugarThresholds 保持一致)
|
||||
*
|
||||
* @return array{fasting:float,postprandial:float}|null
|
||||
*/
|
||||
private static function bloodThresholds(int $age): array
|
||||
{
|
||||
// 年龄未知时用通用糖尿病控制目标兜底,避免「无阈值=全部达标」
|
||||
if ($age <= 0) {
|
||||
return ['fasting' => 7.0, 'postprandial' => 10.0];
|
||||
}
|
||||
return $age < 50
|
||||
? ['fasting' => 7.0, 'postprandial' => 9.0]
|
||||
: ['fasting' => 8.0, 'postprandial' => 10.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总一段时间内的血糖统计:有记录天数、偏高天数、达标率、均值
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function aggregateBloodStats(array $rows, int $age, string $label): array
|
||||
{
|
||||
$thresholds = self::bloodThresholds($age);
|
||||
|
||||
$byDate = [];
|
||||
$fastingSum = 0.0;
|
||||
$fastingCnt = 0;
|
||||
$postSum = 0.0;
|
||||
$postCnt = 0;
|
||||
|
||||
foreach ($rows as $b) {
|
||||
$ts = (int) ($b['record_date'] ?? 0);
|
||||
if ($ts <= 0) {
|
||||
continue;
|
||||
}
|
||||
$date = date('Y-m-d', $ts);
|
||||
$fasting = ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') ? (float) $b['fasting_blood_sugar'] : null;
|
||||
$post = ($b['postprandial_blood_sugar'] !== null && $b['postprandial_blood_sugar'] !== '') ? (float) $b['postprandial_blood_sugar'] : null;
|
||||
if ($fasting === null && $post === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($byDate[$date])) {
|
||||
$byDate[$date] = ['high' => false];
|
||||
}
|
||||
if ($fasting !== null) {
|
||||
$fastingSum += $fasting;
|
||||
$fastingCnt++;
|
||||
if ($thresholds && $fasting >= $thresholds['fasting']) {
|
||||
$byDate[$date]['high'] = true;
|
||||
}
|
||||
}
|
||||
if ($post !== null) {
|
||||
$postSum += $post;
|
||||
$postCnt++;
|
||||
if ($thresholds && $post >= $thresholds['postprandial']) {
|
||||
$byDate[$date]['high'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recordDays = count($byDate);
|
||||
$highDays = 0;
|
||||
foreach ($byDate as $d) {
|
||||
if ($d['high']) {
|
||||
$highDays++;
|
||||
}
|
||||
}
|
||||
$normalDays = max(0, $recordDays - $highDays);
|
||||
$compliance = $recordDays > 0 ? (int) round($normalDays / $recordDays * 100) : 0;
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'record_days' => $recordDays,
|
||||
'high_days' => $highDays,
|
||||
'normal_days' => $normalDays,
|
||||
'compliance' => $compliance,
|
||||
'fasting_avg' => $fastingCnt > 0 ? round($fastingSum / $fastingCnt, 1) : null,
|
||||
'postprandial_avg' => $postCnt > 0 ? round($postSum / $postCnt, 1) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把统计数据拼成喂给 AI 的一行人类可读文本
|
||||
*
|
||||
* @param array<string,mixed> $stats
|
||||
*/
|
||||
private static function statsToLine(array $stats): string
|
||||
{
|
||||
if (($stats['record_days'] ?? 0) <= 0) {
|
||||
return $stats['label'] . '无血糖记录';
|
||||
}
|
||||
$segs = [
|
||||
$stats['label'] . "有记录{$stats['record_days']}天",
|
||||
"偏高{$stats['high_days']}天",
|
||||
"达标率{$stats['compliance']}%",
|
||||
];
|
||||
if ($stats['fasting_avg'] !== null) {
|
||||
$segs[] = "空腹均值{$stats['fasting_avg']}";
|
||||
}
|
||||
if ($stats['postprandial_avg'] !== null) {
|
||||
$segs[] = "餐后均值{$stats['postprandial_avg']}";
|
||||
}
|
||||
return implode(',', $segs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给前端展示的「分析依据」结构(近7天 + 近30天)
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function buildAnalysisPayload(array $context): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['stats_7d', 'stats_30d'] as $key) {
|
||||
$s = $context[$key] ?? null;
|
||||
if (is_array($s)) {
|
||||
$out[] = $s;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给前端展示的「运动降糖」建议(按血糖统计调整强度)
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function buildExercisePayload(array $context): array
|
||||
{
|
||||
return GiKnowledge::exercisePlan(
|
||||
is_array($context['stats_7d'] ?? null) ? $context['stats_7d'] : [],
|
||||
is_array($context['stats_30d'] ?? null) ? $context['stats_30d'] : []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private static function buildRecommendPrompts(array $context, bool $refresh = false): array
|
||||
{
|
||||
$bloodLine = empty($context['blood_recent'])
|
||||
? '近7日无血糖记录'
|
||||
: implode(';', $context['blood_recent']);
|
||||
$stat7Line = self::statsToLine($context['stats_7d'] ?? ['label' => '近7天', 'record_days' => 0]);
|
||||
$stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]);
|
||||
|
||||
$system = <<<SYS
|
||||
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。输出必须严格可执行。
|
||||
|
||||
硬性规则(违反即不合格):
|
||||
1. 午餐至少 2 样蛋白质(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),且午餐蛋白种类数 > 早餐 > 晚餐。
|
||||
2. 淀粉定义:粥/饭/面/饼/窝头/薯/南瓜。每餐最多 1 种淀粉;早中晚 3 种淀粉必须互不相同。
|
||||
3. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
|
||||
4. 低 GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
|
||||
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
|
||||
6. 早餐必须包含「驼奶粉」(温水冲服,适量),可与粥、蛋、菜并列写。
|
||||
7. drinks 单独写全天喝什么:温开水为主;可写驼奶粉冲饮时间;严禁含糖饮料、果汁、酒。
|
||||
8. 只输出 JSON:
|
||||
{"breakfast":"...","drinks":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s;%s。\n(若近期偏高天数多或达标率低,请收紧主食与高GI食物,多安排蛋白与绿叶菜;若控制平稳可正常推荐。)\n\nGI知识:\n%s\n\n农家饭参考:\n%s\n\n请输出严格符合硬性规则的三餐 JSON。",
|
||||
$context['patient_name'] ?: '大爷大妈',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
$stat7Line,
|
||||
$stat30Line,
|
||||
GiKnowledge::summaryText(),
|
||||
GiKnowledge::ruralMealHints()
|
||||
);
|
||||
|
||||
if ($refresh) {
|
||||
$user .= "\n\n用户点了「换一换」,请给一套完全不同的农家三餐,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||
}
|
||||
|
||||
return [$system, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式三餐推荐:纯文本格式,便于逐字输出
|
||||
*
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private static function buildRecommendStreamPrompts(array $context, bool $refresh = false): array
|
||||
{
|
||||
$bloodLine = empty($context['blood_recent'])
|
||||
? '近7日无血糖记录'
|
||||
: implode(';', $context['blood_recent']);
|
||||
$stat7Line = self::statsToLine($context['stats_7d'] ?? ['label' => '近7天', 'record_days' => 0]);
|
||||
$stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]);
|
||||
|
||||
$system = <<<SYS
|
||||
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。
|
||||
|
||||
硬性规则:
|
||||
1. 午餐至少 2 样蛋白质(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),且午餐蛋白 > 早餐 > 晚餐。
|
||||
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
|
||||
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
|
||||
4. 农村家常饭,禁止轻食/沙拉/藜麦。
|
||||
5. 早餐必须含驼奶粉(温水冲服);喝的单独一行。
|
||||
|
||||
严格按下面 6 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
||||
早餐:(具体食物,须含驼奶粉)
|
||||
喝的:(温开水、驼奶粉冲饮安排等,忌甜饮)
|
||||
午餐:(具体食物,蛋白放前)
|
||||
晚餐:(具体食物)
|
||||
提示:(一句土话提醒)
|
||||
少碰:(用顿号隔开,如:白馒头、油条)
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s;%s。\n(偏高多或达标率低则收紧主食、多蛋白绿叶菜;平稳则正常推荐。)\n\nGI摘要:\n%s",
|
||||
$context['patient_name'] ?: '大爷大妈',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
$stat7Line,
|
||||
$stat30Line,
|
||||
mb_substr(GiKnowledge::summaryText(), 0, 320)
|
||||
);
|
||||
|
||||
if ($refresh) {
|
||||
$user .= "\n\n用户点了「换一换」,请重新给一套完全不同的农家三餐,编号" . substr(md5((string) microtime(true)), 0, 8) . ',别跟常见模板重复。';
|
||||
}
|
||||
|
||||
return [$system, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析流式三餐纯文本(兼容 JSON 回退)
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function parseRecommendPlainText(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($text[0] === '{') {
|
||||
$json = self::parseJsonObject($text);
|
||||
if (is_array($json)) {
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
|
||||
$map = [
|
||||
'breakfast' => null,
|
||||
'drinks' => null,
|
||||
'lunch' => null,
|
||||
'dinner' => null,
|
||||
'tips' => null,
|
||||
'avoid' => null,
|
||||
];
|
||||
|
||||
if (preg_match('/早餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['breakfast'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/喝的[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['drinks'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/午餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/晚餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['dinner'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/提示[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$map['tips'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
if (preg_match('/少碰[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$avoidLine = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
$parts = preg_split('/[、,,;;\s]+/u', $avoidLine) ?: [];
|
||||
$map['avoid'] = array_values(array_filter(array_map('trim', $parts)));
|
||||
}
|
||||
|
||||
if (empty($map['breakfast'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [
|
||||
'breakfast' => (string) $map['breakfast'],
|
||||
'drinks' => (string) ($map['drinks'] ?? ''),
|
||||
'lunch' => (string) ($map['lunch'] ?? ''),
|
||||
'dinner' => (string) ($map['dinner'] ?? ''),
|
||||
'tips' => (string) ($map['tips'] ?? ''),
|
||||
'avoid' => is_array($map['avoid']) ? $map['avoid'] : [],
|
||||
];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品要求:早餐含驼奶粉,并单独给出「喝的」
|
||||
*
|
||||
* @param array<string, mixed> $plan
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function applyDietProductDefaults(array $plan): array
|
||||
{
|
||||
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
|
||||
if ($breakfast !== '' && mb_strpos($breakfast, '驼奶粉') === false && mb_strpos($breakfast, '驼奶') === false) {
|
||||
$plan['breakfast'] = '驼奶粉(温水冲服)、' . $breakfast;
|
||||
}
|
||||
|
||||
$drinks = trim((string) ($plan['drinks'] ?? ''));
|
||||
if ($drinks === '') {
|
||||
$plan['drinks'] = '白天多喝温开水;早餐按量冲驼奶粉,别喝甜饮料、果汁。';
|
||||
} elseif (mb_strpos($drinks, '驼奶') === false) {
|
||||
$plan['drinks'] = $drinks . ';早餐已安排驼奶粉';
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
|
||||
*
|
||||
* @param array<string, mixed> $parsed
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function finalizeAiRecommendPlan(array $parsed): array
|
||||
{
|
||||
$parsed = self::applyDietProductDefaults($parsed);
|
||||
$checked = DietMealValidator::validateAndNormalize($parsed);
|
||||
$data = $checked['plan'];
|
||||
$data['source'] = 'ai';
|
||||
$data['rules_summary'] = DietMealValidator::metaSummary($checked['meta']);
|
||||
if ($checked['ok']) {
|
||||
$data['disclaimer'] = 'AI 建议已通过规则校验,仍请结合医嘱与血糖监测。';
|
||||
} else {
|
||||
$data['validation_warnings'] = $checked['errors'];
|
||||
$data['disclaimer'] = 'AI 建议供参考,仍请结合医嘱与血糖监测。';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private static function buildAskStreamPrompts(array $context, string $question): array
|
||||
{
|
||||
$food = GiKnowledge::extractFoodName($question);
|
||||
$hit = GiKnowledge::lookupFood($food);
|
||||
$kbHint = $hit
|
||||
? "知识库命中:{$hit['food']} → {$hit['level_label']}({$hit['category']})"
|
||||
: '知识库未命中,按 GI 原则用农村常识回答';
|
||||
|
||||
$system = <<<SYS
|
||||
你是村里糖尿病饮食顾问,用升糖指数(GI)回答农村老人「能不能吃某东西」。
|
||||
用一两句土话直接回答,40字以内,先说能不能吃、再说咋吃/吃多少。不要 JSON、不要 markdown、不要列表。
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"老人%s,%s。问:%s\n%s\n\nGI摘要:\n%s",
|
||||
$context['patient_name'] ?? '',
|
||||
$context['gender_text'] ?? '',
|
||||
$question,
|
||||
$kbHint,
|
||||
GiKnowledge::summaryText()
|
||||
);
|
||||
|
||||
return [$system, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function finalizeAskFromText(array $context, string $question, string $text): array
|
||||
{
|
||||
$food = GiKnowledge::extractFoodName($question);
|
||||
$hit = GiKnowledge::lookupFood($food);
|
||||
|
||||
$data = [
|
||||
'food' => $food,
|
||||
'advice' => $text,
|
||||
'source' => 'ai',
|
||||
];
|
||||
|
||||
if ($hit) {
|
||||
$data['food'] = $hit['food'];
|
||||
$data['level'] = $hit['level'];
|
||||
$data['level_label'] = $hit['level_label'];
|
||||
$data['portion'] = $hit['level'] === GiKnowledge::LEVEL_HIGH
|
||||
? '尽量别吃'
|
||||
: ($hit['level'] === GiKnowledge::LEVEL_MEDIUM ? '少量' : '适量');
|
||||
} else {
|
||||
$data['level'] = GiKnowledge::LEVEL_MEDIUM;
|
||||
$data['level_label'] = '中升糖';
|
||||
$data['portion'] = '少量';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function aiDailyRecommend(array $context, bool $refresh = false): ?array
|
||||
{
|
||||
[$system, $user] = self::buildRecommendPrompts($context, $refresh);
|
||||
|
||||
$res = AiChatService::chat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], [
|
||||
'temperature' => $refresh ? 0.78 : 0.35,
|
||||
'max_tokens' => 900,
|
||||
'timeout' => self::AI_TIMEOUT_SEC,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
|
||||
if (!$res['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = self::parseJsonObject((string) $res['content']);
|
||||
if (!$parsed || empty($parsed['breakfast'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed['avoid'] = is_array($parsed['avoid'] ?? null) ? $parsed['avoid'] : [];
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $context
|
||||
* @return array|null
|
||||
*/
|
||||
private static function aiAskFood(array $context, string $question): ?array
|
||||
{
|
||||
$food = GiKnowledge::extractFoodName($question);
|
||||
$hit = GiKnowledge::lookupFood($food);
|
||||
|
||||
$system = <<<SYS
|
||||
你是村里糖尿病饮食顾问,用升糖指数(GI)回答农村老人「能不能吃某东西」。
|
||||
只输出 JSON:{"food":"食物名","level":"low|medium|high","level_label":"低升糖|中升糖|高升糖","advice":"40字内土话建议","portion":"咋吃、吃多少"}
|
||||
level:low≤55,medium 56-69,high≥70。advice 要口语化,别文绉绉。
|
||||
SYS;
|
||||
|
||||
$kbHint = $hit
|
||||
? "知识库命中:{$hit['food']} → {$hit['level_label']}({$hit['category']})"
|
||||
: '知识库未命中,按 GI 原则用农村常识回答';
|
||||
|
||||
$user = sprintf(
|
||||
"老人%s,%s。问:%s\n%s\n\nGI摘要:\n%s\n\n农家饭原则:\n%s",
|
||||
$context['patient_name'] ?? '',
|
||||
$context['gender_text'] ?? '',
|
||||
$question,
|
||||
$kbHint,
|
||||
GiKnowledge::summaryText(),
|
||||
GiKnowledge::ruralMealHints()
|
||||
);
|
||||
|
||||
$res = AiChatService::chat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
], [
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 400,
|
||||
'timeout' => self::AI_TIMEOUT_SEC,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
|
||||
if (!$res['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = self::parseJsonObject((string) $res['content']);
|
||||
if (!$parsed || empty($parsed['advice'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($hit) {
|
||||
$parsed['food'] = $hit['food'];
|
||||
$parsed['level'] = $hit['level'];
|
||||
$parsed['level_label'] = $hit['level_label'];
|
||||
if (empty($parsed['portion'])) {
|
||||
$parsed['portion'] = $hit['level'] === GiKnowledge::LEVEL_HIGH ? '尽量别吃' : ($hit['level'] === GiKnowledge::LEVEL_MEDIUM ? '少量' : '适量');
|
||||
}
|
||||
}
|
||||
|
||||
$parsed['source'] = 'ai';
|
||||
if (empty($parsed['food'])) {
|
||||
$parsed['food'] = $food;
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function parseJsonObject(string $content): ?array
|
||||
{
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return null;
|
||||
}
|
||||
if ($content[0] !== '{') {
|
||||
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
|
||||
$content = $m[0];
|
||||
}
|
||||
}
|
||||
$decoded = json_decode($content, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\DailyFamilyLike;
|
||||
use app\common\model\tcm\DailyShareInvite;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
|
||||
/**
|
||||
* 家人点赞(邀请观看页 → 患者日常页展示)
|
||||
*/
|
||||
class DailyFamilyLikeLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
/** @var string[] */
|
||||
protected static array $praisePool = [
|
||||
'坚持得很好,为您点赞!',
|
||||
'每天记录真棒,继续保持!',
|
||||
'您的自律让人佩服!',
|
||||
'加油,家人一直支持您!',
|
||||
'稳糖路上,您并不孤单!',
|
||||
'好习惯正在养成,真为您高兴!',
|
||||
];
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当日邀请码
|
||||
*
|
||||
* @return array{diagnosis_id:int,invite_code:string}|false
|
||||
*/
|
||||
protected static function resolveInvite(string $inviteCode): array|false
|
||||
{
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($inviteCode === '') {
|
||||
return self::setError('邀请码不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
|
||||
if (!$row) {
|
||||
return self::setError('邀请码无效或已失效') ? false : false;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
if ((string) $row['invite_date'] !== $today) {
|
||||
return self::setError('邀请码已过期,仅可在分享当天点赞') ? false : false;
|
||||
}
|
||||
|
||||
return [
|
||||
'diagnosis_id' => (int) $row['diagnosis_id'],
|
||||
'invite_code' => $inviteCode,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function normalizeViewerKey(string $viewerKey): string
|
||||
{
|
||||
$viewerKey = preg_replace('/[^\w\-]/', '', trim($viewerKey)) ?? '';
|
||||
if (strlen($viewerKey) < 8) {
|
||||
return '';
|
||||
}
|
||||
return substr($viewerKey, 0, 64);
|
||||
}
|
||||
|
||||
protected static function normalizeNickname(string $nickname): string
|
||||
{
|
||||
$nickname = trim($nickname);
|
||||
if ($nickname === '') {
|
||||
return '家人';
|
||||
}
|
||||
$nickname = mb_substr($nickname, 0, 8, 'UTF-8');
|
||||
return $nickname !== '' ? $nickname : '家人';
|
||||
}
|
||||
|
||||
public static function countToday(int $diagnosisId, ?string $likeDate = null): int
|
||||
{
|
||||
$likeDate = $likeDate ?: date('Y-m-d');
|
||||
return (int) DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->count();
|
||||
}
|
||||
|
||||
public static function likedByViewer(int $diagnosisId, string $viewerKey, ?string $likeDate = null): bool
|
||||
{
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
if ($viewerKey === '') {
|
||||
return false;
|
||||
}
|
||||
$likeDate = $likeDate ?: date('Y-m-d');
|
||||
return DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->where('viewer_key', $viewerKey)
|
||||
->find() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请预览附加点赞信息
|
||||
*/
|
||||
public static function metaForInvite(string $inviteCode, string $viewerKey = ''): array
|
||||
{
|
||||
$resolved = self::resolveInvite($inviteCode);
|
||||
if ($resolved === false) {
|
||||
return [
|
||||
'like_count' => 0,
|
||||
'liked_by_me' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$diagnosisId = $resolved['diagnosis_id'];
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
|
||||
return [
|
||||
'like_count' => self::countToday($diagnosisId),
|
||||
'liked_by_me' => $viewerKey !== '' && self::likedByViewer($diagnosisId, $viewerKey),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 家人点赞(无需登录)
|
||||
*/
|
||||
public static function addLike(string $inviteCode, string $viewerKey, string $nickname = ''): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$resolved = self::resolveInvite($inviteCode);
|
||||
if ($resolved === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
if ($viewerKey === '') {
|
||||
return self::setError('设备标识无效,请重试') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosisId = $resolved['diagnosis_id'];
|
||||
$likeDate = date('Y-m-d');
|
||||
$nickname = self::normalizeNickname($nickname);
|
||||
|
||||
$exists = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->where('viewer_key', $viewerKey)
|
||||
->find();
|
||||
|
||||
if ($exists) {
|
||||
return [
|
||||
'already_liked' => true,
|
||||
'like_count' => self::countToday($diagnosisId, $likeDate),
|
||||
'praise_message' => '您今天已经点过赞啦,谢谢您的鼓励',
|
||||
'nickname' => (string) ($exists['nickname'] ?? '家人'),
|
||||
];
|
||||
}
|
||||
|
||||
$todayCount = self::countToday($diagnosisId, $likeDate);
|
||||
if ($todayCount >= 50) {
|
||||
return self::setError('今日点赞已满,明天再来吧') ? false : false;
|
||||
}
|
||||
|
||||
DailyFamilyLike::create([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'like_date' => $likeDate,
|
||||
'invite_code' => $resolved['invite_code'],
|
||||
'viewer_key' => $viewerKey,
|
||||
'nickname' => $nickname,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$likeCount = self::countToday($diagnosisId, $likeDate);
|
||||
$idx = $likeCount > 0 ? ($likeCount - 1) % count(self::$praisePool) : 0;
|
||||
|
||||
return [
|
||||
'already_liked' => false,
|
||||
'like_count' => $likeCount,
|
||||
'praise_message' => self::$praisePool[$idx],
|
||||
'nickname' => $nickname,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者查看今日家人点赞(需登录且拥有诊单)
|
||||
*/
|
||||
public static function summaryForPatient(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权查看该诊单') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->field('show_card')->find();
|
||||
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||
}
|
||||
|
||||
$likeDate = date('Y-m-d');
|
||||
$count = self::countToday($diagnosisId, $likeDate);
|
||||
|
||||
$rows = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->order('id', 'desc')
|
||||
->limit(8)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$recent = [];
|
||||
foreach ($rows as $r) {
|
||||
$recent[] = [
|
||||
'nickname' => (string) ($r['nickname'] ?? '家人'),
|
||||
'time_label' => self::timeLabel((int) ($r['create_time'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
$summaryLine = $count > 0
|
||||
? "今日有 {$count} 位家人为您点赞,继续加油!"
|
||||
: '分享战报给家人,邀请他们为您点赞鼓劲';
|
||||
|
||||
return [
|
||||
'like_count' => $count,
|
||||
'summary_line' => $summaryLine,
|
||||
'recent' => $recent,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function timeLabel(int $ts): string
|
||||
{
|
||||
if ($ts <= 0) {
|
||||
return '刚刚';
|
||||
}
|
||||
$diff = time() - $ts;
|
||||
if ($diff < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($diff < 3600) {
|
||||
return (int) floor($diff / 60) . '分钟前';
|
||||
}
|
||||
return date('H:i', $ts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\DailyGamify;
|
||||
use app\common\model\tcm\DietRecord;
|
||||
use app\common\model\tcm\ExerciseRecord;
|
||||
|
||||
/**
|
||||
* 稳糖分 / 勋章 / 浇水领奖
|
||||
*/
|
||||
class DailyGamifyLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
/** @var array<string,array{name:string,points:int}> */
|
||||
protected static array $taskDefs = [
|
||||
'glucose' => ['name' => '测血糖', 'points' => 10],
|
||||
'bp' => ['name' => '测血压', 'points' => 10],
|
||||
'diet' => ['name' => '饮食', 'points' => 10],
|
||||
'exercise' => ['name' => '运动', 'points' => 10],
|
||||
];
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function assertOwned(int $userId, int $diagnosisId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权操作该诊单') ? false : false;
|
||||
}
|
||||
$diagnosis = \app\common\model\tcm\Diagnosis::where('id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->field('show_card')
|
||||
->find();
|
||||
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static function todayRange(): array
|
||||
{
|
||||
return [
|
||||
strtotime(date('Y-m-d 00:00:00')),
|
||||
strtotime(date('Y-m-d 23:59:59')),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function hasValue($v): bool
|
||||
{
|
||||
if ($v === null || $v === '' || $v === '0' || $v === 0) {
|
||||
return false;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
return trim($v) !== '';
|
||||
}
|
||||
return is_numeric($v) && (float) $v > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日任务是否已完成(依据真实业务记录)
|
||||
*/
|
||||
public static function evaluateTaskCompletion(int $diagnosisId): array
|
||||
{
|
||||
[$start, $end] = self::todayRange();
|
||||
|
||||
$blood = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('source', 1)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->find();
|
||||
|
||||
$glucoseDone = false;
|
||||
$bpDone = false;
|
||||
if ($blood) {
|
||||
$glucoseDone = self::hasValue($blood['fasting_blood_sugar'] ?? null)
|
||||
|| self::hasValue($blood['postprandial_blood_sugar'] ?? null)
|
||||
|| self::hasValue($blood['other_blood_sugar'] ?? null);
|
||||
$bpDone = self::hasValue($blood['systolic_pressure'] ?? null)
|
||||
|| self::hasValue($blood['diastolic_pressure'] ?? null);
|
||||
}
|
||||
|
||||
$diet = DietRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$dietDone = false;
|
||||
if ($diet) {
|
||||
$dietDone = self::hasValue($diet['breakfast_foods'] ?? null)
|
||||
|| self::hasValue($diet['lunch_foods'] ?? null)
|
||||
|| self::hasValue($diet['dinner_foods'] ?? null);
|
||||
}
|
||||
|
||||
$exercise = ExerciseRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$exerciseDone = false;
|
||||
if ($exercise) {
|
||||
$exerciseDone = self::hasValue($exercise['exercise_type'] ?? null)
|
||||
|| self::hasValue($exercise['duration'] ?? null);
|
||||
}
|
||||
|
||||
return [
|
||||
'glucose' => $glucoseDone,
|
||||
'bp' => $bpDone,
|
||||
'diet' => $dietDone,
|
||||
'exercise' => $exerciseDone,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,bool>> $taskAwards
|
||||
* @return array<int,array{id:string,name:string,points:int,completed:bool,claimed:bool}>
|
||||
*/
|
||||
public static function buildTodayTasks(int $diagnosisId, array $taskAwards): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$awards = isset($taskAwards[$today]) && is_array($taskAwards[$today]) ? $taskAwards[$today] : [];
|
||||
$completion = self::evaluateTaskCompletion($diagnosisId);
|
||||
$list = [];
|
||||
|
||||
foreach (self::$taskDefs as $id => $def) {
|
||||
$list[] = [
|
||||
'id' => $id,
|
||||
'name' => $def['name'],
|
||||
'points' => $def['points'],
|
||||
'completed' => !empty($completion[$id]),
|
||||
'claimed' => self::isTaskClaimed($id, $awards, $completion),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务是否已领取(兼容旧版 blood 合并任务)
|
||||
*
|
||||
* @param array<string,bool> $awards
|
||||
* @param array<string,bool> $completion
|
||||
*/
|
||||
protected static function isTaskClaimed(string $id, array $awards, array $completion = []): bool
|
||||
{
|
||||
if (!empty($awards[$id])) {
|
||||
return true;
|
||||
}
|
||||
// 旧版 blood 一次性领取:对应分项当日已有记录则视为已领,避免拆分后重复领奖/轮换引导
|
||||
if (!empty($awards['blood'])) {
|
||||
if ($id === 'glucose' && !empty($completion['glucose'])) {
|
||||
return true;
|
||||
}
|
||||
if ($id === 'bp' && !empty($completion['bp'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 与前端 tongji/utils/treeLevels.js 保持一致 */
|
||||
protected const TREE_MAX_LEVEL = 9;
|
||||
protected const TREE_XP_PER_LEVEL = 50;
|
||||
|
||||
protected static function treeMeta(int $points): array
|
||||
{
|
||||
$points = max(0, (int) $points);
|
||||
$level = min(self::TREE_MAX_LEVEL, (int) floor($points / self::TREE_XP_PER_LEVEL));
|
||||
$names = ['种子眠', '破土芽', '展两叶', '小树苗', '青枝繁', '拔节高', '稳糖冠', '初绽香', '漫开花', '圆满树'];
|
||||
$xpIn = $level >= self::TREE_MAX_LEVEL ? self::TREE_XP_PER_LEVEL : ($points % self::TREE_XP_PER_LEVEL);
|
||||
$progress = $level >= self::TREE_MAX_LEVEL
|
||||
? 100
|
||||
: (int) round(($xpIn / self::TREE_XP_PER_LEVEL) * 100);
|
||||
$nextName = $level < self::TREE_MAX_LEVEL ? ($names[$level + 1] ?? '') : '';
|
||||
$pointsToNext = $level >= self::TREE_MAX_LEVEL
|
||||
? 0
|
||||
: (self::TREE_XP_PER_LEVEL - $xpIn);
|
||||
|
||||
return [
|
||||
'tree_level' => $level,
|
||||
'tree_progress' => $progress,
|
||||
'tree_level_name' => $names[$level] ?? '种子眠',
|
||||
'tree_xp_in_level' => $xpIn,
|
||||
'tree_xp_need' => self::TREE_XP_PER_LEVEL,
|
||||
'tree_points_next' => $pointsToNext,
|
||||
'tree_next_name' => $nextName,
|
||||
'tree_is_max' => $level >= self::TREE_MAX_LEVEL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取稳糖乐园状态(含今日任务)
|
||||
*/
|
||||
public static function getState(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$points = $row ? (int) $row['points'] : 0;
|
||||
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
|
||||
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
|
||||
|
||||
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
$claimable = 0;
|
||||
foreach ($todayTasks as $t) {
|
||||
if ($t['completed'] && !$t['claimed']) {
|
||||
$claimable += (int) $t['points'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $todayTasks,
|
||||
'claimable_points' => $claimable,
|
||||
], self::treeMeta($points));
|
||||
}
|
||||
|
||||
/**
|
||||
* 浇水:领取今日已完成且未领取的任务积分
|
||||
*/
|
||||
public static function waterTree(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$points = $row ? (int) $row['points'] : 0;
|
||||
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
|
||||
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
$addedPoints = 0;
|
||||
$claimedIds = [];
|
||||
$pending = [];
|
||||
|
||||
if (!isset($taskAwards[$today]) || !is_array($taskAwards[$today])) {
|
||||
$taskAwards[$today] = [];
|
||||
}
|
||||
|
||||
foreach ($todayTasks as $task) {
|
||||
if ($task['completed'] && !$task['claimed']) {
|
||||
$id = (string) $task['id'];
|
||||
$taskAwards[$today][$id] = true;
|
||||
$addedPoints += (int) $task['points'];
|
||||
$claimedIds[] = $id;
|
||||
} elseif (!$task['completed']) {
|
||||
$pending[] = [
|
||||
'id' => $task['id'],
|
||||
'name' => $task['name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($addedPoints <= 0) {
|
||||
$claimable = 0;
|
||||
foreach ($todayTasks as $t) {
|
||||
if ($t['completed'] && !$t['claimed']) {
|
||||
$claimable += (int) $t['points'];
|
||||
}
|
||||
}
|
||||
$refreshedTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
return [
|
||||
'added_points' => 0,
|
||||
'claimed_tasks' => [],
|
||||
'claimable_points' => $claimable,
|
||||
'pending_tasks' => $pending,
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $refreshedTasks,
|
||||
'message' => $claimable > 0 ? '请先点击浇水领取积分' : (count($pending) ? '请先完成今日任务再浇水' : '今日奖励已全部领取'),
|
||||
] + self::treeMeta($points);
|
||||
}
|
||||
|
||||
$newPoints = $points + $addedPoints;
|
||||
$saved = self::saveState($userId, $diagnosisId, $newPoints, $badges, $taskAwards);
|
||||
if ($saved === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$refreshed = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
|
||||
return [
|
||||
'added_points' => $addedPoints,
|
||||
'claimed_tasks' => $claimedIds,
|
||||
'claimable_points' => 0,
|
||||
'pending_tasks' => $pending,
|
||||
'points' => $newPoints,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $refreshed,
|
||||
'message' => "浇水成功,获得 {$addedPoints} 稳糖积分",
|
||||
] + self::treeMeta($newPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $badges
|
||||
* @param array<string,array<string,bool>> $taskAwards
|
||||
*/
|
||||
public static function saveState(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$points = max(0, (int) $points);
|
||||
$badges = array_values(array_unique(array_filter(array_map('strval', $badges))));
|
||||
if (!is_array($taskAwards)) {
|
||||
$taskAwards = [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$data = [
|
||||
'points' => $points,
|
||||
'badges' => json_encode($badges, JSON_UNESCAPED_UNICODE),
|
||||
'task_awards' => json_encode($taskAwards, JSON_UNESCAPED_UNICODE),
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
if ($row) {
|
||||
DailyGamify::where('id', (int) $row['id'])->update($data);
|
||||
} else {
|
||||
$data['diagnosis_id'] = $diagnosisId;
|
||||
$data['user_id'] = $userId;
|
||||
$data['create_time'] = $now;
|
||||
DailyGamify::create($data);
|
||||
}
|
||||
|
||||
return [
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地缓存迁到服务端:取 points/badges/task_awards 的较大合并
|
||||
*/
|
||||
public static function mergeFromClient(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
|
||||
{
|
||||
$server = self::getState($userId, $diagnosisId);
|
||||
if ($server === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mergedPoints = max((int) $server['points'], max(0, $points));
|
||||
$mergedBadges = array_values(array_unique(array_merge($server['badges'], $badges)));
|
||||
$mergedAwards = $server['task_awards'];
|
||||
foreach ($taskAwards as $date => $tasks) {
|
||||
if (!is_array($tasks)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($mergedAwards[$date]) || !is_array($mergedAwards[$date])) {
|
||||
$mergedAwards[$date] = [];
|
||||
}
|
||||
foreach ($tasks as $taskId => $flag) {
|
||||
if ($flag) {
|
||||
$mergedAwards[$date][(string) $taskId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::saveState($userId, $diagnosisId, $mergedPoints, $mergedBadges, $mergedAwards);
|
||||
}
|
||||
|
||||
protected static function decodeJsonArray(string $json): array
|
||||
{
|
||||
if ($json === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? array_values(array_map('strval', $data)) : [];
|
||||
}
|
||||
|
||||
protected static function decodeJsonObject(string $json): array
|
||||
{
|
||||
if ($json === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\user\User;
|
||||
|
||||
/**
|
||||
* 日常血糖:手机号快捷建档(无完整就诊卡时)
|
||||
*/
|
||||
class DailyPhoneLogic
|
||||
{
|
||||
/** 小程序完整建档(edit_card) */
|
||||
public const CREATE_SOURCE_MNP = 'mnp';
|
||||
|
||||
/** 小程序手机号快捷建档(日常血糖) */
|
||||
public const CREATE_SOURCE_MNP_DAILY = 'mnp_daily';
|
||||
|
||||
/**
|
||||
* 用户 sex(1男2女) → 诊单 gender(1男0女)
|
||||
*/
|
||||
public static function mapUserSexToDiagnosisGender($sex): int
|
||||
{
|
||||
$sex = (int) $sex;
|
||||
if ($sex === 2) {
|
||||
return 0;
|
||||
}
|
||||
if ($sex === 1) {
|
||||
return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定手机号则返回可用诊单;无卡时自动创建轻量档案
|
||||
*
|
||||
* @param array{sex?:int|null} $options sex: 用户表 1男2女,授权手机号时可传入
|
||||
* @return array{ok:bool,need_mobile?:bool,error?:string,created?:bool,diagnosis_id?:int,patient_id?:int,patient_name?:string,gender?:int,age?:int,mobile?:string,create_source?:string}
|
||||
*/
|
||||
public static function ensureDailyContext(int $userId, array $options = []): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'need_mobile' => false, 'error' => '请先登录'];
|
||||
}
|
||||
|
||||
$user = User::where('id', $userId)
|
||||
->field('id,nickname,real_name,mobile,sex,age')
|
||||
->find();
|
||||
if (!$user) {
|
||||
return ['ok' => false, 'need_mobile' => false, 'error' => '用户不存在'];
|
||||
}
|
||||
|
||||
$mobile = trim((string) ($user['mobile'] ?? ''));
|
||||
if ($mobile === '') {
|
||||
return ['ok' => false, 'need_mobile' => true, 'error' => '请先授权手机号'];
|
||||
}
|
||||
|
||||
if (array_key_exists('sex', $options) && $options['sex'] !== null && $options['sex'] !== '') {
|
||||
$sex = (int) $options['sex'];
|
||||
if (in_array($sex, [1, 2], true) && (int) ($user['sex'] ?? 0) !== $sex) {
|
||||
User::update(['id' => $userId, 'sex' => $sex]);
|
||||
$user['sex'] = $sex;
|
||||
}
|
||||
}
|
||||
|
||||
$cardList = DiagnosisLogic::getCardList($userId);
|
||||
if ($cardList === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => DiagnosisLogic::getError() ?: '获取就诊卡失败',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($cardList)) {
|
||||
$card = $cardList[0];
|
||||
return self::buildContextOk($card, $mobile, false, false);
|
||||
}
|
||||
|
||||
// 该手机号已在诊单库建档:关联查看记录,禁止重复创建轻量档案
|
||||
$existing = Diagnosis::where('phone', $mobile)
|
||||
->where('delete_time', null)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($existing && (int) ($existing['show_card'] ?? 1) !== 1) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => '该手机号对应就诊卡因隐私设置已隐藏,请联系医生',
|
||||
];
|
||||
}
|
||||
if ($existing) {
|
||||
if (!self::ensureUserViewRecord($userId, (int) $existing['id'], (int) $existing['patient_id'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => '该手机号已建档,暂无法关联到当前账号',
|
||||
];
|
||||
}
|
||||
return self::buildContextOk([
|
||||
'id' => (int) $existing['id'],
|
||||
'patient_id' => (int) $existing['patient_id'],
|
||||
'patient_name' => (string) ($existing['patient_name'] ?? ''),
|
||||
'gender' => (int) ($existing['gender'] ?? 0),
|
||||
'age' => (int) ($existing['age'] ?? 0),
|
||||
'create_source' => (string) ($existing['create_source'] ?? ''),
|
||||
], $mobile, false, true);
|
||||
}
|
||||
|
||||
$displayName = trim((string) (($user['real_name'] ?? '') ?: ($user['nickname'] ?? '')));
|
||||
if ($displayName === '') {
|
||||
$displayName = '用户' . substr($mobile, -4);
|
||||
}
|
||||
|
||||
$gender = self::mapUserSexToDiagnosisGender($user['sex'] ?? 1);
|
||||
|
||||
$result = DiagnosisLogic::addCard([
|
||||
'user_id' => $userId,
|
||||
'patient_name' => $displayName,
|
||||
'phone' => $mobile,
|
||||
'gender' => $gender,
|
||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||
'diagnosis_date' => time(),
|
||||
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
||||
'remark' => '小程序手机号快捷建档(日常血糖)',
|
||||
]);
|
||||
|
||||
if ($result === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => DiagnosisLogic::getError() ?: '创建档案失败',
|
||||
];
|
||||
}
|
||||
|
||||
return self::buildContextOk([
|
||||
'id' => (int) $result['id'],
|
||||
'patient_id' => (int) $result['patient_id'],
|
||||
'patient_name' => $displayName,
|
||||
'gender' => $gender,
|
||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
||||
], $mobile, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,patient_id:int,patient_name?:string,gender?:int,age?:int,create_source?:string} $card
|
||||
*/
|
||||
private static function buildContextOk(array $card, string $mobile, bool $created, bool $linkedExisting): array
|
||||
{
|
||||
return [
|
||||
'ok' => true,
|
||||
'need_mobile' => false,
|
||||
'created' => $created,
|
||||
'linked_existing' => $linkedExisting,
|
||||
'diagnosis_id' => (int) $card['id'],
|
||||
'patient_id' => (int) $card['patient_id'],
|
||||
'patient_name' => (string) ($card['patient_name'] ?? ''),
|
||||
'gender' => (int) ($card['gender'] ?? 0),
|
||||
'age' => (int) ($card['age'] ?? 0),
|
||||
'mobile' => $mobile,
|
||||
'create_source' => (string) ($card['create_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** 将已有诊单挂到当前小程序用户(diagnosis_view_records) */
|
||||
private static function ensureUserViewRecord(int $userId, int $diagnosisId, int $patientId): bool
|
||||
{
|
||||
if ($userId <= 0 || $diagnosisId <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$exists = DiagnosisViewRecord::where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if ($exists) {
|
||||
return true;
|
||||
}
|
||||
$now = time();
|
||||
DiagnosisViewRecord::create([
|
||||
'user_id' => $userId,
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'patient_id' => $patientId,
|
||||
'share_user_id' => 0,
|
||||
'view_count' => 1,
|
||||
'first_view_time' => $now,
|
||||
'last_view_time' => $now,
|
||||
'is_confirmed' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\DailyShareInvite;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
|
||||
/**
|
||||
* 日常记录分享邀请码(当日有效、脱敏预览)
|
||||
*/
|
||||
class DailyShareLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当日邀请码(分享人须已拥有诊单)
|
||||
*/
|
||||
public static function createInvite(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权分享该诊单') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diagnosis) {
|
||||
return self::setError('诊单不存在') ? false : false;
|
||||
}
|
||||
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||
}
|
||||
|
||||
$inviteDate = date('Y-m-d');
|
||||
$code = self::generateUniqueCode();
|
||||
|
||||
DailyShareInvite::create([
|
||||
'invite_code' => $code,
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'user_id' => $userId,
|
||||
'invite_date' => $inviteDate,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'invite_code' => $code,
|
||||
'invite_date' => $inviteDate,
|
||||
'expires_hint' => '邀请码仅今日有效,明日将无法查看',
|
||||
'share_path' => '/tongji/pages/index?from=share&invite_code=' . $code,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭邀请码查看分享战报(含近 7 日血糖/血压记录,仅当日邀请码有效)
|
||||
*/
|
||||
public static function previewByInviteCode(string $inviteCode, string $viewerKey = ''): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($inviteCode === '') {
|
||||
return self::setError('邀请码不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
|
||||
if (!$row) {
|
||||
return self::setError('邀请码无效或已失效') ? false : false;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
if ((string) $row['invite_date'] !== $today) {
|
||||
return self::setError('邀请码已过期,仅可在分享当天查看') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosisId = (int) $row['diagnosis_id'];
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diagnosis) {
|
||||
return self::setError('诊单不存在') ? false : false;
|
||||
}
|
||||
|
||||
$age = (int) ($diagnosis['age'] ?? 0);
|
||||
$stats = self::buildDesensitizedWeekStats($diagnosisId, $age);
|
||||
$name = trim((string) ($diagnosis['patient_name'] ?? ''));
|
||||
$label = self::maskPatientName($name);
|
||||
|
||||
$likeMeta = DailyFamilyLikeLogic::metaForInvite($inviteCode, $viewerKey);
|
||||
|
||||
return array_merge($stats, $likeMeta, [
|
||||
'invite_code' => $inviteCode,
|
||||
'invite_date' => $today,
|
||||
'expires_hint' => '本邀请码仅今日有效,明日将无法查看',
|
||||
'viewer_notice' => '您正在查看家人分享的近7日血糖记录',
|
||||
'patient_label' => $label,
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function generateUniqueCode(): string
|
||||
{
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$code = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||
if (!DailyShareInvite::where('invite_code', $code)->find()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
return strtoupper(substr(uniqid('', true), -8));
|
||||
}
|
||||
|
||||
protected static function maskPatientName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '家人';
|
||||
}
|
||||
$len = mb_strlen($name, 'UTF-8');
|
||||
if ($len <= 1) {
|
||||
return $name . '*';
|
||||
}
|
||||
if ($len === 2) {
|
||||
return mb_substr($name, 0, 1, 'UTF-8') . '*';
|
||||
}
|
||||
return mb_substr($name, 0, 1, 'UTF-8') . '*' . mb_substr($name, -1, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 近 7 天习惯统计 + 每日血糖/血压记录(供家人分享页展示)
|
||||
*/
|
||||
public static function buildDesensitizedWeekStats(int $diagnosisId, int $age = 0): array
|
||||
{
|
||||
$today = new \DateTime('today');
|
||||
$dayKeys = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$d = clone $today;
|
||||
$d->modify("-{$i} days");
|
||||
$dayKeys[] = $d->format('Y-m-d');
|
||||
}
|
||||
|
||||
$startTs = strtotime($dayKeys[0] . ' 00:00:00');
|
||||
$endTs = strtotime($dayKeys[6] . ' 23:59:59');
|
||||
|
||||
$rows = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $startTs)
|
||||
->where('record_date', '<=', $endTs)
|
||||
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar,systolic_pressure,diastolic_pressure')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$byDate = [];
|
||||
foreach ($rows as $r) {
|
||||
$key = date('Y-m-d', (int) $r['record_date']);
|
||||
if (!isset($byDate[$key])) {
|
||||
$byDate[$key] = $r;
|
||||
} else {
|
||||
foreach (['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure'] as $f) {
|
||||
if (self::hasValue($r[$f]) && !self::hasValue($byDate[$key][$f])) {
|
||||
$byDate[$key][$f] = $r[$f];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recordDays = 0;
|
||||
$completeDays = 0;
|
||||
foreach ($dayKeys as $key) {
|
||||
$b = $byDate[$key] ?? null;
|
||||
if (!$b || !self::dayHasBlood($b)) {
|
||||
continue;
|
||||
}
|
||||
$recordDays++;
|
||||
if (self::hasValue($b['fasting_blood_sugar']) && self::hasValue($b['postprandial_blood_sugar'])) {
|
||||
$completeDays++;
|
||||
}
|
||||
}
|
||||
|
||||
$streakDays = self::calcStreakDays($diagnosisId);
|
||||
|
||||
$tree = self::treeMeta($recordDays);
|
||||
$quote = self::buildQuote($recordDays, $completeDays);
|
||||
|
||||
$weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
$dailyRecords = [];
|
||||
foreach ($dayKeys as $key) {
|
||||
$b = $byDate[$key] ?? null;
|
||||
$dt = new \DateTime($key);
|
||||
$w = (int) $dt->format('w');
|
||||
$has = $b && self::dayHasBlood($b);
|
||||
|
||||
$fasting = $has ? self::formatSugarValue($b['fasting_blood_sugar'] ?? null) : null;
|
||||
$post = $has ? self::formatSugarValue($b['postprandial_blood_sugar'] ?? null) : null;
|
||||
$other = $has ? self::formatSugarValue($b['other_blood_sugar'] ?? null) : null;
|
||||
$sys = $has ? self::formatSugarValue($b['systolic_pressure'] ?? null) : null;
|
||||
$dia = $has ? self::formatSugarValue($b['diastolic_pressure'] ?? null) : null;
|
||||
|
||||
$dailyRecords[] = [
|
||||
'date' => $key,
|
||||
'date_label' => $dt->format('n') . '/' . $dt->format('j'),
|
||||
'weekday' => '周' . $weekdayLabels[$w],
|
||||
'has_record' => $has,
|
||||
'fasting' => $fasting,
|
||||
'fasting_high' => self::isHighFasting($fasting, $age),
|
||||
'postprandial' => $post,
|
||||
'postprandial_high' => self::isHighPostprandial($post, $age),
|
||||
'other' => $other,
|
||||
'other_high' => self::isHighPostprandial($other, $age),
|
||||
'systolic' => $sys,
|
||||
'diastolic' => $dia,
|
||||
'bp_high' => self::isHighBp($sys, $dia),
|
||||
'bp_text' => self::formatBpText($sys, $dia),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'week_label' => $today->format('Y') . '年 第' . self::weekOfYear($today) . '周',
|
||||
'record_days' => $recordDays,
|
||||
'complete_days' => $completeDays,
|
||||
'streak_days' => $streakDays,
|
||||
'quote' => $quote,
|
||||
'tree_emoji' => $tree['emoji'],
|
||||
'tree_title' => $tree['title'],
|
||||
'tree_desc' => $tree['desc'],
|
||||
'tree_level' => $tree['level'],
|
||||
'daily_records' => $dailyRecords,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function formatSugarValue($v): ?float
|
||||
{
|
||||
if (!self::hasValue($v)) {
|
||||
return null;
|
||||
}
|
||||
return round((float) $v, 1);
|
||||
}
|
||||
|
||||
protected static function getBloodSugarThresholds(int $age): ?array
|
||||
{
|
||||
if ($age <= 0) {
|
||||
return null;
|
||||
}
|
||||
if ($age < 50) {
|
||||
return ['fasting' => 7.0, 'postprandial' => 9.0];
|
||||
}
|
||||
return ['fasting' => 8.0, 'postprandial' => 10.0];
|
||||
}
|
||||
|
||||
protected static function isHighFasting(?float $v, int $age): bool
|
||||
{
|
||||
$t = self::getBloodSugarThresholds($age);
|
||||
return $v !== null && $t !== null && $v >= $t['fasting'];
|
||||
}
|
||||
|
||||
protected static function isHighPostprandial(?float $v, int $age): bool
|
||||
{
|
||||
$t = self::getBloodSugarThresholds($age);
|
||||
return $v !== null && $t !== null && $v >= $t['postprandial'];
|
||||
}
|
||||
|
||||
protected static function isHighBp(?float $systolic, ?float $diastolic): bool
|
||||
{
|
||||
return ($systolic !== null && $systolic > 140)
|
||||
|| ($diastolic !== null && $diastolic > 90);
|
||||
}
|
||||
|
||||
protected static function formatBpText(?float $systolic, ?float $diastolic): string
|
||||
{
|
||||
if ($systolic === null && $diastolic === null) {
|
||||
return '';
|
||||
}
|
||||
$s = $systolic !== null ? (string) (int) round($systolic) : '—';
|
||||
$d = $diastolic !== null ? (string) (int) round($diastolic) : '—';
|
||||
return $s . '/' . $d;
|
||||
}
|
||||
|
||||
protected static function hasValue($v): bool
|
||||
{
|
||||
if ($v === null || $v === '' || $v === '0' || $v === 0) {
|
||||
return false;
|
||||
}
|
||||
return is_numeric($v) && (float) $v > 0;
|
||||
}
|
||||
|
||||
protected static function dayHasBlood(array $b): bool
|
||||
{
|
||||
return self::hasValue($b['fasting_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['postprandial_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['other_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['systolic_pressure'] ?? null)
|
||||
|| self::hasValue($b['diastolic_pressure'] ?? null);
|
||||
}
|
||||
|
||||
protected static function calcStreakDays(int $diagnosisId): int
|
||||
{
|
||||
$today = new \DateTime('today');
|
||||
$count = 0;
|
||||
for ($i = 0; $i < 90; $i++) {
|
||||
$d = clone $today;
|
||||
$d->modify("-{$i} days");
|
||||
$key = $d->format('Y-m-d');
|
||||
$start = strtotime($key . ' 00:00:00');
|
||||
$end = strtotime($key . ' 23:59:59');
|
||||
$exists = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->whereRaw('(fasting_blood_sugar > 0 OR postprandial_blood_sugar > 0 OR other_blood_sugar > 0 OR systolic_pressure > 0 OR diastolic_pressure > 0)')
|
||||
->find();
|
||||
if ($exists) {
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
if ($i === 0) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected static function treeMeta(int $recordDays): array
|
||||
{
|
||||
if ($recordDays >= 7) {
|
||||
return ['level' => 4, 'emoji' => '🌸', 'title' => '控糖树 · 开花啦', 'desc' => '本周每天都记录了'];
|
||||
}
|
||||
if ($recordDays >= 5) {
|
||||
return ['level' => 3, 'emoji' => '🌳', 'title' => '控糖树 · 枝繁叶茂', 'desc' => "本周 {$recordDays}/7 天有记录"];
|
||||
}
|
||||
if ($recordDays >= 3) {
|
||||
return ['level' => 2, 'emoji' => '🌿', 'title' => '控糖树 · 茁壮成长', 'desc' => "本周 {$recordDays}/7 天有记录"];
|
||||
}
|
||||
if ($recordDays >= 1) {
|
||||
return ['level' => 1, 'emoji' => '🌱', 'title' => '控糖树 · 破土发芽', 'desc' => '本周已开始记录'];
|
||||
}
|
||||
return ['level' => 0, 'emoji' => '🪴', 'title' => '控糖树 · 等待浇水', 'desc' => '本周暂无记录'];
|
||||
}
|
||||
|
||||
protected static function buildQuote(int $recordDays, int $completeDays): string
|
||||
{
|
||||
if ($recordDays >= 7) {
|
||||
return '本周每天都留下了记录,这份自律值得骄傲!';
|
||||
}
|
||||
if ($completeDays >= 5) {
|
||||
return "本周有 {$completeDays} 天完成了空腹+餐后记录,习惯越来越稳。";
|
||||
}
|
||||
if ($recordDays >= 4) {
|
||||
return "本周已记录 {$recordDays} 天,坚持就是胜利。";
|
||||
}
|
||||
if ($recordDays > 0) {
|
||||
return '好的开始!继续记录会更稳。';
|
||||
}
|
||||
return '分享者本周尚未记录,鼓励 Ta 每天记一笔。';
|
||||
}
|
||||
|
||||
protected static function weekOfYear(\DateTime $date): int
|
||||
{
|
||||
return (int) $date->format('W');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
/**
|
||||
* 三餐推荐硬性规则校验(午餐高蛋白、淀粉不重复、禁高 GI)
|
||||
*/
|
||||
class DietMealValidator
|
||||
{
|
||||
/** 淀粉类关键词 → 分组(同组即视为同一种淀粉) */
|
||||
private const STARCH_GROUPS = [
|
||||
'porridge' => ['粥', '稀饭', '玉米碴', '棒子面', '高粱', '糊糊'],
|
||||
'rice' => ['米饭', '糙米饭', '二米饭', '杂豆饭', '红米饭', '饭'],
|
||||
'noodle' => ['挂面', '面条', '手擀面', '拉面', '刀削面'],
|
||||
'bun' => ['馒头', '窝头', '贴饼', '玉米面饼', '杂面馒头', '杂面窝头', '杂面', '饼'],
|
||||
'potato' => ['红薯', '地瓜', '番薯', '土豆', '洋芋', '芋头'],
|
||||
'pumpkin' => ['南瓜'],
|
||||
];
|
||||
|
||||
/** 蛋白质关键词(出现即计 1,同词不重复计) */
|
||||
private const PROTEIN_KEYWORDS = [
|
||||
'鲫鱼', '鲤鱼', '小鱼', '清蒸鱼', '炖鱼', '鱼',
|
||||
'虾', '蟹',
|
||||
'鸡肉', '鸡胸', '鸡', '鸭肉', '鸭',
|
||||
'牛肉', '羊肉', '猪肉', '瘦肉', '肉',
|
||||
'鸡蛋', '茶叶蛋', '蛋羹', '炒蛋', '蛋',
|
||||
'豆腐脑', '豆干', '豆腐', '豆角', '芸豆', '扁豆',
|
||||
];
|
||||
|
||||
/** 高 GI 禁用(出现在任一餐即违规) */
|
||||
private const FORBIDDEN_HIGH_GI = [
|
||||
'白馒头', '馒头', '油条', '粘豆包', '糯米饭', '西瓜', '荔枝', '龙眼',
|
||||
'含糖饮料', '可乐', '汽水', '糖糕', '糕点', '白稀饭', '稀饭',
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验并规范化三餐方案;不通过则 ok=false
|
||||
*
|
||||
* @param array{breakfast?:string,lunch?:string,dinner?:string,tips?:string,avoid?:array} $plan
|
||||
* @return array{ok:bool,errors:list<string>,plan:array,meta:array}
|
||||
*/
|
||||
public static function validateAndNormalize(array $plan): array
|
||||
{
|
||||
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
|
||||
$lunch = trim((string) ($plan['lunch'] ?? ''));
|
||||
$dinner = trim((string) ($plan['dinner'] ?? ''));
|
||||
$errors = [];
|
||||
|
||||
if ($breakfast === '' || $lunch === '' || $dinner === '') {
|
||||
$errors[] = '三餐内容不完整';
|
||||
}
|
||||
|
||||
foreach (self::FORBIDDEN_HIGH_GI as $bad) {
|
||||
foreach (['breakfast' => $breakfast, 'lunch' => $lunch, 'dinner' => $dinner] as $mealName => $text) {
|
||||
if ($text !== '' && mb_strpos($text, $bad) !== false) {
|
||||
// 「杂面馒头」含馒头但可接受;纯「馒头」才禁
|
||||
if ($bad === '馒头' && (mb_strpos($text, '杂面') !== false || mb_strpos($text, '玉米面') !== false)) {
|
||||
continue;
|
||||
}
|
||||
if ($bad === '稀饭' && mb_strpos($text, '玉米') !== false) {
|
||||
continue;
|
||||
}
|
||||
$errors[] = "{$mealName}含高 GI 食物「{$bad}」";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bfStarch = self::detectStarchGroups($breakfast);
|
||||
$luStarch = self::detectStarchGroups($lunch);
|
||||
$diStarch = self::detectStarchGroups($dinner);
|
||||
|
||||
if (count($bfStarch) > 1) {
|
||||
$errors[] = '早餐淀粉种类超过 1 种';
|
||||
}
|
||||
if (count($luStarch) > 1) {
|
||||
$errors[] = '午餐淀粉种类超过 1 种';
|
||||
}
|
||||
if (count($diStarch) > 1) {
|
||||
$errors[] = '晚餐淀粉种类超过 1 种';
|
||||
}
|
||||
|
||||
$dayGroups = array_values(array_filter([
|
||||
$bfStarch[0] ?? null,
|
||||
$luStarch[0] ?? null,
|
||||
$diStarch[0] ?? null,
|
||||
]));
|
||||
if (count($dayGroups) !== count(array_unique($dayGroups))) {
|
||||
$errors[] = '早中晚淀粉种类重复';
|
||||
}
|
||||
|
||||
$bfProtein = self::countProteinHits($breakfast);
|
||||
$luProtein = self::countProteinHits($lunch);
|
||||
$diProtein = self::countProteinHits($dinner);
|
||||
|
||||
if ($luProtein < 2) {
|
||||
$errors[] = '午餐高蛋白不足(至少 2 样:鱼/肉/蛋/豆腐)';
|
||||
}
|
||||
if ($luProtein < $bfProtein || $luProtein < $diProtein) {
|
||||
$errors[] = '午餐蛋白质应多于早、晚餐';
|
||||
}
|
||||
|
||||
$avoid = is_array($plan['avoid'] ?? null) ? $plan['avoid'] : [];
|
||||
if (count($avoid) < 3) {
|
||||
$avoid = array_values(array_unique(array_merge($avoid, [
|
||||
'白面馒头', '油条', '粘豆包', '西瓜', '含糖饮料',
|
||||
])));
|
||||
$avoid = array_slice($avoid, 0, 5);
|
||||
}
|
||||
|
||||
$tips = trim((string) ($plan['tips'] ?? ''));
|
||||
if ($tips === '') {
|
||||
$tips = '中午鱼蛋豆肉吃足;早中晚各一种主食,别重复。';
|
||||
}
|
||||
|
||||
$normalized = [
|
||||
'breakfast' => $breakfast,
|
||||
'drinks' => trim((string) ($plan['drinks'] ?? '')),
|
||||
'lunch' => $lunch,
|
||||
'dinner' => $dinner,
|
||||
'tips' => $tips,
|
||||
'avoid' => $avoid,
|
||||
];
|
||||
|
||||
$meta = [
|
||||
'protein' => ['breakfast' => $bfProtein, 'lunch' => $luProtein, 'dinner' => $diProtein],
|
||||
'starch' => ['breakfast' => $bfStarch[0] ?? '', 'lunch' => $luStarch[0] ?? '', 'dinner' => $diStarch[0] ?? ''],
|
||||
'rules_ok' => empty($errors),
|
||||
];
|
||||
|
||||
return [
|
||||
'ok' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'plan' => $normalized,
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> 淀粉分组 id,按命中顺序
|
||||
*/
|
||||
public static function detectStarchGroups(string $text): array
|
||||
{
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
$found = [];
|
||||
foreach (self::STARCH_GROUPS as $groupId => $keywords) {
|
||||
foreach ($keywords as $kw) {
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
$found[$groupId] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_keys($found);
|
||||
}
|
||||
|
||||
public static function countProteinHits(string $text): int
|
||||
{
|
||||
if ($text === '') {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
$used = [];
|
||||
foreach (self::PROTEIN_KEYWORDS as $kw) {
|
||||
if (isset($used[$kw])) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
// 「鱼」已命中则不再计「小鱼」「鲫鱼」
|
||||
$skip = false;
|
||||
foreach ($used as $u) {
|
||||
if (mb_strpos($u, $kw) !== false || mb_strpos($kw, $u) !== false) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
$used[$kw] = true;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function metaSummary(array $meta): string
|
||||
{
|
||||
$p = $meta['protein'] ?? [];
|
||||
$s = $meta['starch'] ?? [];
|
||||
$starchLabels = [
|
||||
'porridge' => '粥', 'rice' => '饭', 'noodle' => '面', 'bun' => '饼/窝头',
|
||||
'potato' => '薯', 'pumpkin' => '南瓜',
|
||||
];
|
||||
$sBf = $starchLabels[$s['breakfast'] ?? ''] ?? ($s['breakfast'] ?: '—');
|
||||
$sLu = $starchLabels[$s['lunch'] ?? ''] ?? ($s['lunch'] ?: '—');
|
||||
$sDi = $starchLabels[$s['dinner'] ?? ''] ?? ($s['dinner'] ?: '—');
|
||||
return sprintf(
|
||||
'午餐 %d 样蛋白(早%d/晚%d);淀粉:早%s·午%s·晚%s',
|
||||
(int) ($p['lunch'] ?? 0),
|
||||
(int) ($p['breakfast'] ?? 0),
|
||||
(int) ($p['dinner'] ?? 0),
|
||||
$sBf,
|
||||
$sLu,
|
||||
$sDi
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\service\FileService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
|
||||
*/
|
||||
class GamePlatformLogic
|
||||
{
|
||||
private const GROUP_SIZE = 7;
|
||||
private const MAX_SESSION_LEARNED = 20000;
|
||||
private const MAX_SCORE = 100000000;
|
||||
|
||||
protected static string $error = '';
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function fail(string $message): bool
|
||||
{
|
||||
self::$error = $message;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
|
||||
*/
|
||||
public static function leaderboard(int $userId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('leaderboard', $e);
|
||||
return self::fail('同行榜暂时不可用,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
|
||||
*
|
||||
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
|
||||
*/
|
||||
public static function submitProgress(int $userId, array $params): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
$sessionKey = trim((string) ($params['session_key'] ?? ''));
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||||
return self::fail('游戏局标识无效');
|
||||
}
|
||||
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
|
||||
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
|
||||
$ended = !empty($params['ended']) ? 1 : 0;
|
||||
$weekStart = self::weekStart();
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
|
||||
$session = Db::name('tcm_game_session')
|
||||
->where('session_key', $sessionKey)
|
||||
->lock(true)
|
||||
->find();
|
||||
|
||||
$now = time();
|
||||
if ($session && (int) $session['user_id'] !== $userId) {
|
||||
Db::rollback();
|
||||
return self::fail('游戏局标识已被使用');
|
||||
}
|
||||
|
||||
if (!$session) {
|
||||
$sessionId = Db::name('tcm_game_session')->insertGetId([
|
||||
'session_key' => $sessionKey,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$session = [
|
||||
'id' => $sessionId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$sessionWeek = (string) $session['week_start'];
|
||||
if ($sessionWeek !== $weekStart) {
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
|
||||
}
|
||||
|
||||
$confirmedLearned = (int) $session['learned_count'];
|
||||
$nextLearned = max($confirmedLearned, $learned);
|
||||
$delta = $nextLearned - $confirmedLearned;
|
||||
$wasEnded = (int) $session['ended'] === 1;
|
||||
$markEnded = $wasEnded || $ended === 1;
|
||||
|
||||
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
|
||||
'learned_count' => $nextLearned,
|
||||
'last_score' => max((int) $session['last_score'], $scoreValue),
|
||||
'ended' => $markEnded ? 1 : 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
|
||||
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
|
||||
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
|
||||
'learned_count' => $weeklyLearned,
|
||||
'best_score' => $weeklyBest,
|
||||
'games_played' => $gamesPlayed,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
|
||||
Db::commit();
|
||||
|
||||
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
|
||||
$result['confirmed_session_learned'] = $nextLearned;
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('submitProgress', $e);
|
||||
return self::fail('成绩保存失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录用户发起一次微信分享,并返回当前分享码。 */
|
||||
public static function recordShare(int $userId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
return ['invite_code' => $inviteCode];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('recordShare', $e);
|
||||
return self::fail('分享记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
|
||||
*/
|
||||
public static function acceptShare(int $userId, string $inviteCode): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
|
||||
return self::fail('分享码无效');
|
||||
}
|
||||
|
||||
try {
|
||||
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
|
||||
if (!$invite) {
|
||||
return self::fail('分享已失效');
|
||||
}
|
||||
$inviterUserId = (int) $invite['user_id'];
|
||||
if ($inviterUserId === $userId) {
|
||||
return ['accepted' => false, 'message' => '这是您自己的分享'];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
$inserted = Db::name('tcm_game_share_visit')->duplicate([
|
||||
'invite_code',
|
||||
])->insert([
|
||||
'invite_code' => $inviteCode,
|
||||
'inviter_user_id' => $inviterUserId,
|
||||
'visitor_user_id' => $userId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
$accepted = $inserted === 1;
|
||||
if ($accepted) {
|
||||
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
|
||||
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
|
||||
return [
|
||||
'accepted' => $accepted,
|
||||
'message' => '已加入控糖消消乐',
|
||||
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('acceptShare', $e);
|
||||
return self::fail('分享关系记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureWeeklyScore(int $userId, string $weekStart): array
|
||||
{
|
||||
$profile = self::userProfile($userId);
|
||||
$now = time();
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
if ($existing) {
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('id', (int) $existing['id'])
|
||||
->lock(true)
|
||||
->find();
|
||||
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|
||||
|| (string) $existing['avatar'] !== $profile['avatar'];
|
||||
if ($profileChanged) {
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$existing['nickname'] = $profile['nickname'];
|
||||
$existing['avatar'] = $profile['avatar'];
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行,
|
||||
// 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。
|
||||
Db::name('tcm_game_weekly_allocator')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => $profile['sex'],
|
||||
'create_time'=> $now,
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$allocator = Db::name('tcm_game_weekly_allocator')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$allocator) {
|
||||
throw new \RuntimeException('同行分配锁创建失败');
|
||||
}
|
||||
|
||||
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->where('member_count', '<', self::GROUP_SIZE)
|
||||
->order('group_no', 'asc')
|
||||
->lock(true)
|
||||
->find();
|
||||
|
||||
if (!$group) {
|
||||
$maxGroupNo = (int) Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->max('group_no');
|
||||
$groupNo = $maxGroupNo + 1;
|
||||
// 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert,
|
||||
// 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。
|
||||
Db::name('tcm_game_weekly_group')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => $profile['sex'],
|
||||
'group_no' => $groupNo,
|
||||
'member_count'=> 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->where('group_no', $groupNo)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$group) {
|
||||
throw new \RuntimeException('同行分组创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
$scoreRow = [
|
||||
'group_id' => (int) $group['id'],
|
||||
'week_start' => $weekStart,
|
||||
'user_id' => $userId,
|
||||
'learned_count' => 0,
|
||||
'best_score' => 0,
|
||||
'games_played' => 0,
|
||||
'share_count' => 0,
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'sex' => $profile['sex'],
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$inserted = Db::name('tcm_game_weekly_score')->duplicate([
|
||||
'nickname',
|
||||
'avatar',
|
||||
'sex',
|
||||
'update_time',
|
||||
])->insert($scoreRow);
|
||||
$score = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$score) {
|
||||
throw new \RuntimeException('同行成绩创建失败');
|
||||
}
|
||||
|
||||
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
|
||||
if ($inserted === 1) {
|
||||
$memberCount = (int) Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', (int) $group['id'])
|
||||
->count();
|
||||
Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
|
||||
'member_count' => min(self::GROUP_SIZE, $memberCount),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
private static function buildLeaderboard(
|
||||
int $groupId,
|
||||
int $userId,
|
||||
string $inviteCode,
|
||||
?string $weekStart = null
|
||||
): array {
|
||||
$weekStart = $weekStart ?: self::weekStart();
|
||||
$rows = Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', $groupId)
|
||||
->where('week_start', $weekStart)
|
||||
->order('learned_count', 'desc')
|
||||
->order('best_score', 'desc')
|
||||
->order('create_time', 'asc')
|
||||
->limit(self::GROUP_SIZE)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$players = [];
|
||||
$myIndex = 0;
|
||||
foreach ($rows as $index => $row) {
|
||||
$isMe = (int) $row['user_id'] === $userId;
|
||||
if ($isMe) {
|
||||
$myIndex = $index;
|
||||
}
|
||||
$players[] = [
|
||||
// 前端只需要稳定列表键,不暴露平台内部 user_id。
|
||||
'id' => (int) $row['id'],
|
||||
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
|
||||
'avatar' => self::avatarUrl((string) $row['avatar']),
|
||||
'count' => (int) $row['learned_count'],
|
||||
'best_score' => (int) $row['best_score'],
|
||||
'rank' => $index + 1,
|
||||
'is_me' => $isMe,
|
||||
];
|
||||
}
|
||||
|
||||
$me = $players[$myIndex] ?? [
|
||||
'count' => 0,
|
||||
'rank' => 1,
|
||||
'best_score' => 0,
|
||||
];
|
||||
$distance = $myIndex > 0
|
||||
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
|
||||
: 0;
|
||||
$group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
|
||||
$sex = (int) ($group['sex'] ?? 0);
|
||||
|
||||
return [
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
|
||||
'sex' => $sex,
|
||||
'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
|
||||
'group_size' => self::GROUP_SIZE,
|
||||
'member_count'=> count($players),
|
||||
'players' => $players,
|
||||
'me' => [
|
||||
'count' => (int) ($me['count'] ?? 0),
|
||||
'rank' => (int) ($me['rank'] ?? 1),
|
||||
'best_score' => (int) ($me['best_score'] ?? 0),
|
||||
'distance' => $distance,
|
||||
'is_first' => $myIndex === 0,
|
||||
],
|
||||
'invite_code' => $inviteCode,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{nickname:string,avatar:string,sex:int} */
|
||||
private static function userProfile(int $userId): array
|
||||
{
|
||||
$user = Db::name('user')
|
||||
->where('id', $userId)
|
||||
->whereNull('delete_time')
|
||||
->field('id,sn,nickname,avatar,sex')
|
||||
->find();
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在');
|
||||
}
|
||||
|
||||
$sex = (int) ($user['sex'] ?? 0);
|
||||
if ($sex !== 1 && $sex !== 2) {
|
||||
$gender = Db::name('diagnosis_view_records')->alias('v')
|
||||
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
|
||||
->where('v.user_id', $userId)
|
||||
->whereNull('v.delete_time')
|
||||
->whereNull('d.delete_time')
|
||||
->order('v.id', 'desc')
|
||||
->value('d.gender');
|
||||
if ($gender !== null && $gender !== '') {
|
||||
$sex = (int) $gender === 1 ? 1 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
|
||||
'avatar' => (string) ($user['avatar'] ?? ''),
|
||||
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static function displayName(string $nickname, int $userId): string
|
||||
{
|
||||
$nickname = trim(strip_tags($nickname));
|
||||
if ($nickname === '') {
|
||||
return '控糖好友' . substr((string) $userId, -2);
|
||||
}
|
||||
return mb_substr($nickname, 0, 12);
|
||||
}
|
||||
|
||||
private static function avatarUrl(string $avatar): string
|
||||
{
|
||||
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
|
||||
}
|
||||
|
||||
private static function ensureShareInvite(int $userId, string $weekStart): string
|
||||
{
|
||||
$existing = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return (string) $existing['invite_code'];
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$code = strtoupper(bin2hex(random_bytes(6)));
|
||||
$now = time();
|
||||
// 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合;
|
||||
// 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。
|
||||
Db::name('tcm_game_share_invite')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'invite_code' => $code,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'open_count' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$invite = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($invite) {
|
||||
return (string) $invite['invite_code'];
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('分享码生成失败');
|
||||
}
|
||||
|
||||
private static function weekStart(?int $timestamp = null): string
|
||||
{
|
||||
$timestamp = $timestamp ?: time();
|
||||
$day = (int) date('N', $timestamp);
|
||||
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
|
||||
}
|
||||
|
||||
private static function logException(string $action, \Throwable $e): void
|
||||
{
|
||||
Log::error(sprintf(
|
||||
'tcm endless game %s failed: %s at %s:%d',
|
||||
$action,
|
||||
$e->getMessage(),
|
||||
$e->getFile(),
|
||||
$e->getLine()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
/**
|
||||
* 霍大夫升糖指数(GI)知识库
|
||||
*/
|
||||
class GiKnowledge
|
||||
{
|
||||
public const LEVEL_LOW = 'low';
|
||||
public const LEVEL_MEDIUM = 'medium';
|
||||
public const LEVEL_HIGH = 'high';
|
||||
|
||||
/** @var array<string, array<string, list<string>>> */
|
||||
private static array $foods = [
|
||||
self::LEVEL_LOW => [
|
||||
'五谷类' => ['全蛋面', '荞麦面', '粉丝', '黑米', '黑米粥', '通心粉', '藕粉'],
|
||||
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
|
||||
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
|
||||
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
|
||||
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '驼奶粉', '驼奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
||||
],
|
||||
self::LEVEL_MEDIUM => [
|
||||
'主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'],
|
||||
'蔬菜类' => ['薯片', '番茄', '莲藕', '牛蒡'],
|
||||
'肉类' => ['鱼肉', '鸡肉', '鸭肉', '猪肉', '羊肉', '牛肉', '虾子', '蟹'],
|
||||
'水果类' => ['木瓜', '提子干', '菠萝', '香蕉', '芒果', '哈密瓜', '奇异果', '柳丁'],
|
||||
'其他' => ['蔗糖', '蜂蜜', '红酒', '啤酒', '可乐', '咖啡'],
|
||||
],
|
||||
self::LEVEL_HIGH => [
|
||||
'主食类' => ['白饭', '馒头', '油条', '糯米饭', '白面包', '燕麦片', '拉面', '炒饭', '爆米花'],
|
||||
'肉类加工品' => ['贡丸', '肥肠', '蛋饺'],
|
||||
'蔬菜类' => ['薯蓉', '南瓜', '焗薯'],
|
||||
'水果类' => ['西瓜', '荔枝', '龙眼', '凤梨', '枣'],
|
||||
'其他' => ['葡萄糖', '砂糖', '麦芽糖', '汽水', '柳橙汁', '蜂蜜'],
|
||||
],
|
||||
];
|
||||
|
||||
/** @var list<array{breakfast:string,drinks:string,lunch:string,dinner:string,tips:string}> */
|
||||
private static array $mealTemplates = [
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、玉米碴子粥、煮鸡蛋、拌黄瓜',
|
||||
'drinks' => '白天多喝温开水;早餐冲驼奶粉,别喝甜饮料、果汁。',
|
||||
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
|
||||
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
||||
'drinks' => '温开水为主;驼奶粉跟早餐一起吃,下午也记得喝水。',
|
||||
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
|
||||
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
|
||||
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、豆腐脑(少卤)、煮鸡蛋',
|
||||
'drinks' => '早餐饮驼奶粉;白天喝白开水,别碰含糖饮料。',
|
||||
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
|
||||
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
|
||||
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、高粱米粥、茶叶蛋、拌萝卜丝',
|
||||
'drinks' => '温开水;早餐驼奶粉按说明冲,别加糖。',
|
||||
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
|
||||
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
|
||||
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、棒子面粥、水煮蛋、大拌菜(少油)',
|
||||
'drinks' => '多喝温开水;驼奶粉放早餐,忌可乐汽水果汁。',
|
||||
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
|
||||
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、拌生菜',
|
||||
'drinks' => '白天小口多次喝温水;早餐冲好驼奶粉再吃饭。',
|
||||
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
|
||||
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
|
||||
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
|
||||
],
|
||||
];
|
||||
|
||||
public static function summaryText(): string
|
||||
{
|
||||
$path = root_path() . 'app/api/logic/tcm/data/gi_huo_summary.txt';
|
||||
if (is_file($path)) {
|
||||
$text = trim((string) file_get_contents($path));
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
return '低 GI(≤55)优先;中 GI(56-69)适量;高 GI(≥70)尽量避免。多选全谷物、蔬菜、豆类,少加工、少精制糖。';
|
||||
}
|
||||
|
||||
/** 农村/口语别名 → 标准名(便于「能不能吃」匹配) */
|
||||
private static array $aliases = [
|
||||
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
|
||||
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
|
||||
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
||||
'驼奶' => '驼奶粉',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{level:string,level_label:string,category:string,advice:string}|null
|
||||
*/
|
||||
public static function lookupFood(string $name): ?array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$canonical = self::$aliases[$name] ?? $name;
|
||||
|
||||
foreach ([self::LEVEL_LOW, self::LEVEL_MEDIUM, self::LEVEL_HIGH] as $level) {
|
||||
foreach (self::$foods[$level] as $category => $items) {
|
||||
foreach ($items as $item) {
|
||||
if ($canonical === $item || $name === $item
|
||||
|| mb_strpos($item, $canonical) !== false || mb_strpos($canonical, $item) !== false
|
||||
|| mb_strpos($item, $name) !== false || mb_strpos($name, $item) !== false) {
|
||||
return [
|
||||
'level' => $level,
|
||||
'level_label' => self::levelLabel($level),
|
||||
'category' => $category,
|
||||
'food' => $item,
|
||||
'advice' => self::adviceForLevel($level, $name),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 供 AI 参考:老农民家常、集市易得食材示例 */
|
||||
public static function ruralMealHints(): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'【早餐】须安排驼奶粉温水冲服(低 GI 奶类,稳血糖),可与粥、蛋、菜同餐。',
|
||||
'【喝的】单独说明:温开水为主;早餐驼奶粉;严禁含糖饮料、果汁、酒。',
|
||||
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
|
||||
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
|
||||
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
|
||||
'推荐蔬菜:白菜、萝卜、黄瓜、豆角、茄子、菠菜、芹菜;',
|
||||
'淀粉示例(全天选 3 种不同的):玉米碴粥、二米饭小半碗、贴饼子、杂面窝头、挂面小碗、蒸红薯;',
|
||||
'少吃:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、糕点。',
|
||||
'说话要土话、短句,像村里大夫叮嘱。',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function levelLabel(string $level): string
|
||||
{
|
||||
return match ($level) {
|
||||
self::LEVEL_LOW => '低升糖',
|
||||
self::LEVEL_MEDIUM => '中升糖',
|
||||
default => '高升糖',
|
||||
};
|
||||
}
|
||||
|
||||
public static function adviceForLevel(string $level, string $food): string
|
||||
{
|
||||
return match ($level) {
|
||||
self::LEVEL_LOW => "「{$food}」升糖慢,老农民家常能吃,当菜当饭都行,有助于稳血糖。",
|
||||
self::LEVEL_MEDIUM => "「{$food}」升糖中等,可以吃但要少搁点,别当主食猛吃,配着蔬菜鸡蛋更好。",
|
||||
default => "「{$food}」升糖快,血糖高时尽量别吃或少吃,尤其别空腹猛吃。",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $templateIndex 指定模板下标(换一换时轮换)
|
||||
* @return array{breakfast:string,lunch:string,dinner:string,tips:string,avoid:list<string>,source:string}
|
||||
*/
|
||||
public static function fallbackDailyMeals(int $diagnosisId, ?int $templateIndex = null): array
|
||||
{
|
||||
$templates = self::$mealTemplates;
|
||||
$count = count($templates);
|
||||
if ($templateIndex !== null) {
|
||||
$idx = (($templateIndex % $count) + $count) % $count;
|
||||
} else {
|
||||
$idx = abs(crc32(date('Y-m-d') . ':' . $diagnosisId)) % $count;
|
||||
}
|
||||
$meal = $templates[$idx];
|
||||
|
||||
$plan = [
|
||||
'breakfast' => $meal['breakfast'],
|
||||
'drinks' => $meal['drinks'] ?? '白天多喝温开水;早餐冲驼奶粉,别喝甜饮。',
|
||||
'lunch' => $meal['lunch'],
|
||||
'dinner' => $meal['dinner'],
|
||||
'tips' => $meal['tips'],
|
||||
'avoid' => ['白面馒头', '油条', '粘豆包', '西瓜', '含糖饮料', '稀饭配糖'],
|
||||
'source' => 'rule',
|
||||
];
|
||||
|
||||
$checked = DietMealValidator::validateAndNormalize($plan);
|
||||
$out = $checked['plan'];
|
||||
$out['source'] = 'rule';
|
||||
$out['rules_summary'] = DietMealValidator::metaSummary($checked['meta']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据血糖统计给出「运动降糖」建议(规则化,按控制好坏调整强度)
|
||||
*
|
||||
* @param array<string,mixed> $stats7 近7天统计
|
||||
* @param array<string,mixed> $stats30 近30天统计
|
||||
* @return array{level:string,level_label:string,headline:string,items:list<string>,intensity:string,note:string}
|
||||
*/
|
||||
public static function exercisePlan(array $stats7 = [], array $stats30 = []): array
|
||||
{
|
||||
// 优先用记录更全的口径判断;都没记录则用近7天
|
||||
$primary = ((int) ($stats30['record_days'] ?? 0) > 0) ? $stats30 : $stats7;
|
||||
$recordDays = (int) ($primary['record_days'] ?? 0);
|
||||
$compliance = (int) ($primary['compliance'] ?? 0);
|
||||
$highDays = (int) ($primary['high_days'] ?? 0);
|
||||
|
||||
if ($recordDays <= 0) {
|
||||
$level = 'unknown';
|
||||
} elseif ($compliance < 50 || $highDays * 2 >= $recordDays) {
|
||||
$level = 'high'; // 偏高多、达标率低 = 控制不佳
|
||||
} elseif ($compliance < 80) {
|
||||
$level = 'medium';
|
||||
} else {
|
||||
$level = 'good';
|
||||
}
|
||||
|
||||
$note = '餐后 1 小时内开始动,别空腹剧烈运动;身上带几块糖,头晕、心慌、出虚汗就马上停下歇着。';
|
||||
|
||||
switch ($level) {
|
||||
case 'high':
|
||||
return [
|
||||
'level' => 'high',
|
||||
'level_label' => '血糖偏高 · 多动',
|
||||
'headline' => '近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。',
|
||||
'items' => [
|
||||
'早饭后快走 15 分钟,走到微微出汗',
|
||||
'午饭后快走或原地踏步 20–30 分钟,降餐后血糖最管用',
|
||||
'晚饭后慢走 30 分钟,吃完别马上坐下、躺下',
|
||||
],
|
||||
'intensity' => '中等强度 · 每天累计约 60 分钟',
|
||||
'note' => $note,
|
||||
];
|
||||
case 'medium':
|
||||
return [
|
||||
'level' => 'medium',
|
||||
'level_label' => '基本平稳 · 再稳稳',
|
||||
'headline' => '血糖大体平稳,坚持餐后活动,把它稳住。',
|
||||
'items' => [
|
||||
'三餐后各散步 20 分钟,午饭后可以走快些',
|
||||
'每天加一段八段锦或太极拳,10–15 分钟',
|
||||
'能走楼梯少坐电梯,多干点家务、地里活',
|
||||
],
|
||||
'intensity' => '中低强度 · 每天累计 40–50 分钟',
|
||||
'note' => $note,
|
||||
];
|
||||
case 'good':
|
||||
return [
|
||||
'level' => 'good',
|
||||
'level_label' => '控制不错 · 保持',
|
||||
'headline' => '血糖控制得不错,保持规律活动就行。',
|
||||
'items' => [
|
||||
'餐后散步 15–20 分钟,雷打不动',
|
||||
'每天一段八段锦、太极或柔和拉伸',
|
||||
'天好多到院里、地里走动,别久坐',
|
||||
],
|
||||
'intensity' => '中低强度 · 每天累计 30–40 分钟',
|
||||
'note' => $note,
|
||||
];
|
||||
default:
|
||||
return [
|
||||
'level' => 'unknown',
|
||||
'level_label' => '先记血糖 · 再调运动',
|
||||
'headline' => '血糖记录还少,先按基础来:饭后多走动。',
|
||||
'items' => [
|
||||
'三餐后都散步 15–20 分钟',
|
||||
'每天一段八段锦或太极拳',
|
||||
'少久坐,坐 1 小时就起来活动几分钟',
|
||||
],
|
||||
'intensity' => '中低强度 · 每天累计约 30 分钟',
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{food:string,level:string,level_label:string,advice:string,source:string}
|
||||
*/
|
||||
public static function fallbackAsk(string $question): array
|
||||
{
|
||||
$food = self::extractFoodName($question);
|
||||
$hit = self::lookupFood($food);
|
||||
|
||||
if ($hit) {
|
||||
return [
|
||||
'food' => $hit['food'],
|
||||
'level' => $hit['level'],
|
||||
'level_label' => $hit['level_label'],
|
||||
'advice' => $hit['advice'],
|
||||
'source' => 'rule',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'food' => $food,
|
||||
'level' => '',
|
||||
'level_label' => '未知',
|
||||
'advice' => '库里没查到「' . $food . '」。一般多吃小米玉米、白菜豆角豆腐鸡蛋,少吃白馒头油条甜口;拿不准问村医。',
|
||||
'source' => 'rule',
|
||||
];
|
||||
}
|
||||
|
||||
public static function extractFoodName(string $question): string
|
||||
{
|
||||
$q = trim($question);
|
||||
$q = preg_replace('/^(能不能吃|可以吃|能吃|可不可以吃|请问|我想问)/u', '', $q) ?? $q;
|
||||
$q = preg_replace('/(吗|呢|?|\?)+$/u', '', $q) ?? $q;
|
||||
$q = trim($q);
|
||||
return $q !== '' ? $q : $question;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
食物升糖指数(GI)知识摘要(霍大夫),面向农村老人日常吃饭:
|
||||
- 低 GI(≤55):小米、玉米碴、杂面、大部分蔬菜、豆腐、鸡蛋、苹果/梨/柚子等,有助于稳血糖。
|
||||
- 中 GI(56-69):二米饭、红薯、土豆、香蕉等,可以吃但要少、别当顿顿主食。
|
||||
- 高 GI(≥70):白馒头、白稀饭、油条、粘豆包、糯米饭、西瓜、含糖饮料、糕点,尽量别碰。
|
||||
- 午餐蛋白质要最高:鱼、鸡鸭、瘦肉、蛋、豆腐至少两样,午饭是全天蛋白主力。
|
||||
- 淀粉不重复:粥/饭/面/饼/薯/玉米/南瓜等,早中晚各最多一种,三顿不要同一种、别顿顿大主食。
|
||||
- 吃饭窍门:先吃菜和蛋白,再动主食,每餐七分饱。
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
// 多应用下「api」应用的路由文件;URL 形如 /api/qywx/...,此处规则不含应用前缀 api。
|
||||
// @see https://doc.thinkphp.cn/v8_0/multi_app_model.html
|
||||
|
||||
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
// 企业微信内部应用推广助手:公开 JS 与服务端随机分流。
|
||||
Route::get('qywx-promotion/js/:key', 'QywxPromotionPublic/script');
|
||||
Route::get('qywx-promotion/go/:key', 'QywxPromotionPublic/redirect');
|
||||
@@ -0,0 +1,120 @@
|
||||
<?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\service;
|
||||
|
||||
use app\common\cache\UserTokenCache;
|
||||
use app\common\model\user\UserSession;
|
||||
use think\facade\Config;
|
||||
|
||||
class UserTokenService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 设置或更新用户token
|
||||
* @param $userId
|
||||
* @param $terminal
|
||||
* @return array|false|mixed
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:10
|
||||
*/
|
||||
public static function setToken($userId, $terminal)
|
||||
{
|
||||
$time = time();
|
||||
$userSession = UserSession::where([['user_id', '=', $userId], ['terminal', '=', $terminal]])->find();
|
||||
|
||||
//获取token延长过期的时间
|
||||
$expireTime = $time + Config::get('project.user_token.expire_duration');
|
||||
$userTokenCache = new UserTokenCache();
|
||||
|
||||
//token处理
|
||||
if ($userSession) {
|
||||
//清空缓存
|
||||
$userTokenCache->deleteUserInfo($userSession->token);
|
||||
//重新获取token
|
||||
$userSession->token = create_token($userId);
|
||||
$userSession->expire_time = $expireTime;
|
||||
$userSession->update_time = $time;
|
||||
$userSession->save();
|
||||
} else {
|
||||
//找不到在该终端的token记录,创建token记录
|
||||
$userSession = UserSession::create([
|
||||
'user_id' => $userId,
|
||||
'terminal' => $terminal,
|
||||
'token' => create_token($userId),
|
||||
'expire_time' => $expireTime
|
||||
]);
|
||||
}
|
||||
|
||||
return $userTokenCache->setUserInfo($userSession->token);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 延长token过期时间
|
||||
* @param $token
|
||||
* @return array|false|mixed
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:10
|
||||
*/
|
||||
public static function overtimeToken($token)
|
||||
{
|
||||
$time = time();
|
||||
$userSession = UserSession::where('token', '=', $token)->find();
|
||||
if ($userSession->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
//延长token过期时间
|
||||
$userSession->expire_time = $time + Config::get('project.user_token.expire_duration');
|
||||
$userSession->update_time = $time;
|
||||
$userSession->save();
|
||||
|
||||
return (new UserTokenCache())->setUserInfo($userSession->token);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置token为过期
|
||||
* @param $token
|
||||
* @return bool
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:10
|
||||
*/
|
||||
public static function expireToken($token)
|
||||
{
|
||||
$userSession = UserSession::where('token', '=', $token)
|
||||
->find();
|
||||
if (empty($userSession)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$time = time();
|
||||
$userSession->expire_time = $time;
|
||||
$userSession->update_time = $time;
|
||||
$userSession->save();
|
||||
|
||||
return (new UserTokenCache())->deleteUserInfo($token);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?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\service;
|
||||
|
||||
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\model\user\{User, UserAuth};
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\service\{ConfigService, storage\Driver as StorageDriver};
|
||||
use think\Exception;
|
||||
|
||||
|
||||
/**
|
||||
* 用户功能类(主要微信登录后创建和更新用户)
|
||||
* Class WechatUserService
|
||||
* @package app\api\service
|
||||
*/
|
||||
class WechatUserService
|
||||
{
|
||||
|
||||
protected int $terminal = UserTerminalEnum::WECHAT_MMP;
|
||||
protected array $response = [];
|
||||
protected ?string $code = null;
|
||||
protected ?string $openid = null;
|
||||
protected ?string $unionid = null;
|
||||
protected ?string $nickname = null;
|
||||
protected ?string $headimgurl = null;
|
||||
protected User $user;
|
||||
|
||||
|
||||
public function __construct(array $response, int $terminal)
|
||||
{
|
||||
$this->terminal = $terminal;
|
||||
$this->setParams($response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置微信返回的用户信息
|
||||
* @param $response
|
||||
* @author cjhao
|
||||
* @date 2021/8/2 11:49
|
||||
*/
|
||||
private function setParams($response): void
|
||||
{
|
||||
$this->response = $response;
|
||||
$this->openid = $response['openid'];
|
||||
$this->unionid = $response['unionid'] ?? '';
|
||||
$this->nickname = $response['nickname'] ?? '';
|
||||
$this->headimgurl = $response['headimgurl'] ?? '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 根据opendid或unionid获取系统用户信息
|
||||
* @return $this
|
||||
* @author 段誉
|
||||
* @date 2022/9/23 16:09
|
||||
*/
|
||||
public function getResopnseByUserInfo(): self
|
||||
{
|
||||
$openid = $this->openid;
|
||||
$unionid = $this->unionid;
|
||||
|
||||
$user = User::alias('u')
|
||||
->with('diagnosis')
|
||||
->field('u.id,u.sn,u.mobile,u.nickname,u.avatar,u.mobile,u.is_disable,u.is_new_user,au.openid')
|
||||
->join('user_auth au', 'au.user_id = u.id')
|
||||
->where(function ($query) use ($openid, $unionid) {
|
||||
$query->whereOr(['au.openid' => $openid]);
|
||||
if (isset($unionid) && $unionid) {
|
||||
$query->whereOr(['au.unionid' => $unionid]);
|
||||
}
|
||||
})
|
||||
->findOrEmpty();
|
||||
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取用户信息
|
||||
* @param bool $isCheck 是否验证账号是否可用
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @author cjhao
|
||||
* @date 2021/8/3 11:42
|
||||
*/
|
||||
public function getUserInfo($isCheck = true): array
|
||||
{
|
||||
if (!$this->user->isEmpty() && $isCheck) {
|
||||
$this->checkAccount();
|
||||
}
|
||||
if (!$this->user->isEmpty()) {
|
||||
$this->getToken();
|
||||
}
|
||||
return $this->user->toArray();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 校验账号
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:14
|
||||
*/
|
||||
private function checkAccount()
|
||||
{
|
||||
if ($this->user->is_disable) {
|
||||
throw new Exception('您的账号异常,请联系客服。');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 创建用户
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:06
|
||||
*/
|
||||
private function createUser(): void
|
||||
{
|
||||
//设置头像
|
||||
if (empty($this->headimgurl)) {
|
||||
// 默认头像
|
||||
$defaultAvatar = config('project.default_image.user_avatar');
|
||||
$avatar = ConfigService::get('default_image', 'user_avatar', $defaultAvatar);
|
||||
} else {
|
||||
// 微信获取到的头像信息
|
||||
$avatar = $this->getAvatarByWechat();
|
||||
}
|
||||
|
||||
$userSn = User::createUserSn();
|
||||
$this->user->sn = $userSn;
|
||||
$this->user->account = 'u' . $userSn;
|
||||
$this->user->nickname = "用户" . $userSn;
|
||||
$this->user->avatar = $avatar;
|
||||
$this->user->channel = $this->terminal;
|
||||
$this->user->is_new_user = YesNoEnum::YES;
|
||||
|
||||
if ($this->terminal != UserTerminalEnum::WECHAT_MMP && !empty($this->nickname)) {
|
||||
$this->user->nickname = $this->nickname;
|
||||
}
|
||||
|
||||
$this->user->save();
|
||||
|
||||
UserAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'openid' => $this->openid,
|
||||
'unionid' => $this->unionid,
|
||||
'terminal' => $this->terminal,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 更新用户信息
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:06
|
||||
* @remark 该端没授权信息,重新写入一条该端的授权信息
|
||||
*/
|
||||
private function updateUser(): void
|
||||
{
|
||||
// 无头像需要更新头像
|
||||
if (empty($this->user->avatar)) {
|
||||
$this->user->avatar = $this->getAvatarByWechat();
|
||||
$this->user->save();
|
||||
}
|
||||
|
||||
$userAuth = UserAuth::where(['user_id' => $this->user->id, 'openid' => $this->openid])
|
||||
->findOrEmpty();
|
||||
|
||||
// 无该端授权信息,新增一条
|
||||
if ($userAuth->isEmpty()) {
|
||||
$userAuth->user_id = $this->user->id;
|
||||
$userAuth->openid = $this->openid;
|
||||
$userAuth->unionid = $this->unionid;
|
||||
$userAuth->terminal = $this->terminal;
|
||||
$userAuth->save();
|
||||
} else {
|
||||
if (empty($userAuth['unionid']) && !empty($this->unionid)) {
|
||||
$userAuth->unionid = $this->unionid;
|
||||
$userAuth->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取token
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author cjhao
|
||||
* @date 2021/8/2 16:45
|
||||
*/
|
||||
private function getToken(): void
|
||||
{
|
||||
$user = UserTokenService::setToken($this->user->id, $this->terminal);
|
||||
$this->user->token = $user['token'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 用户授权登录,
|
||||
* 如果用户不存在,创建用户;用户存在,更新用户信息,并检查该端信息是否需要写入
|
||||
* @return WechatUserService
|
||||
* @throws Exception
|
||||
* @author cjhao
|
||||
* @date 2021/8/2 16:35
|
||||
*/
|
||||
public function authUserLogin(): self
|
||||
{
|
||||
if ($this->user->isEmpty()) {
|
||||
$this->createUser();
|
||||
} else {
|
||||
$this->updateUser();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 处理从微信获取到的头像信息
|
||||
* @return string
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 9:50
|
||||
*/
|
||||
public function getAvatarByWechat(): string
|
||||
{
|
||||
// 存储引擎
|
||||
$config = [
|
||||
'default' => ConfigService::get('storage', 'default', 'local'),
|
||||
'engine' => ConfigService::get('storage')
|
||||
];
|
||||
|
||||
$fileName = md5($this->openid . time()) . '.jpeg';
|
||||
|
||||
if ($config['default'] == 'local') {
|
||||
// 本地存储
|
||||
$avatar = download_file($this->headimgurl, 'uploads/user/avatar/', $fileName);
|
||||
} else {
|
||||
// 第三方存储
|
||||
$avatar = 'uploads/user/avatar/' . $fileName;
|
||||
$StorageDriver = new StorageDriver($config);
|
||||
if (!$StorageDriver->fetch($this->headimgurl, $avatar)) {
|
||||
throw new Exception('头像保存失败:' . $StorageDriver->getError());
|
||||
}
|
||||
}
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\cache\UserAccountSafeCache;
|
||||
use app\common\enum\LoginEnum;
|
||||
use app\common\enum\notice\NoticeEnum;
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\sms\SmsDriver;
|
||||
use app\common\validate\BaseValidate;
|
||||
use app\common\model\user\User;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 账号密码登录校验
|
||||
* Class LoginValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class LoginAccountValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'terminal' => 'require|in:' . UserTerminalEnum::WECHAT_MMP . ',' . UserTerminalEnum::WECHAT_OA . ','
|
||||
. UserTerminalEnum::H5 . ',' . UserTerminalEnum::PC . ',' . UserTerminalEnum::IOS .
|
||||
',' . UserTerminalEnum::ANDROID,
|
||||
'scene' => 'require|in:' . LoginEnum::ACCOUNT_PASSWORD . ',' . LoginEnum::MOBILE_CAPTCHA . '|checkConfig',
|
||||
'account' => 'require',
|
||||
];
|
||||
|
||||
|
||||
protected $message = [
|
||||
'terminal.require' => '终端参数缺失',
|
||||
'terminal.in' => '终端参数状态值不正确',
|
||||
'scene.require' => '场景不能为空',
|
||||
'scene.in' => '场景值错误',
|
||||
'account.require' => '请输入账号',
|
||||
'password.require' => '请输入密码',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 登录场景相关校验
|
||||
* @param $scene
|
||||
* @param $rule
|
||||
* @param $data
|
||||
* @return bool|string
|
||||
* @author 段誉
|
||||
* @date 2022/9/15 14:37
|
||||
*/
|
||||
public function checkConfig($scene, $rule, $data)
|
||||
{
|
||||
$config = ConfigService::get('login', 'login_way');
|
||||
if (!in_array($scene, $config)) {
|
||||
return '不支持的登录方式';
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
if (LoginEnum::ACCOUNT_PASSWORD == $scene) {
|
||||
if (!isset($data['password'])) {
|
||||
return '请输入密码';
|
||||
}
|
||||
return $this->checkPassword($data['password'], [], $data);
|
||||
}
|
||||
|
||||
// 手机验证码登录
|
||||
if (LoginEnum::MOBILE_CAPTCHA == $scene) {
|
||||
if (!isset($data['code'])) {
|
||||
return '请输入手机验证码';
|
||||
}
|
||||
return $this->checkCode($data['code'], [], $data);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 登录密码校验
|
||||
* @param $password
|
||||
* @param $other
|
||||
* @param $data
|
||||
* @return bool|string
|
||||
* @author 段誉
|
||||
* @date 2022/9/15 14:39
|
||||
*/
|
||||
public function checkPassword($password, $other, $data)
|
||||
{
|
||||
//账号安全机制,连续输错后锁定,防止账号密码暴力破解
|
||||
$userAccountSafeCache = new UserAccountSafeCache();
|
||||
if (!$userAccountSafeCache->isSafe()) {
|
||||
return '密码连续' . $userAccountSafeCache->count . '次输入错误,请' . $userAccountSafeCache->minute . '分钟后重试';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
if ($data['scene'] == LoginEnum::ACCOUNT_PASSWORD) {
|
||||
// 手机号密码登录
|
||||
$where = ['account|mobile' => $data['account']];
|
||||
}
|
||||
|
||||
$userInfo = User::where($where)
|
||||
->field(['password,is_disable'])
|
||||
->findOrEmpty();
|
||||
|
||||
if ($userInfo->isEmpty()) {
|
||||
return '用户不存在';
|
||||
}
|
||||
|
||||
if ($userInfo['is_disable'] === YesNoEnum::YES) {
|
||||
return '用户已禁用';
|
||||
}
|
||||
|
||||
if (empty($userInfo['password'])) {
|
||||
$userAccountSafeCache->record();
|
||||
return '用户不存在';
|
||||
}
|
||||
|
||||
$passwordSalt = Config::get('project.unique_identification');
|
||||
if ($userInfo['password'] !== create_password($password, $passwordSalt)) {
|
||||
$userAccountSafeCache->record();
|
||||
return '密码错误';
|
||||
}
|
||||
|
||||
$userAccountSafeCache->relieve();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 校验验证码
|
||||
* @param $code
|
||||
* @param $rule
|
||||
* @param $data
|
||||
* @return bool|string
|
||||
* @author Tab
|
||||
* @date 2021/8/25 15:43
|
||||
*/
|
||||
public function checkCode($code, $rule, $data)
|
||||
{
|
||||
$smsDriver = new SmsDriver();
|
||||
$result = $smsDriver->verify($data['account'], $code, NoticeEnum::LOGIN_CAPTCHA);
|
||||
if ($result) {
|
||||
return true;
|
||||
}
|
||||
return '验证码错误';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 密码校验
|
||||
* Class PasswordValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class PasswordValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'mobile' => 'require|mobile',
|
||||
'code' => 'require',
|
||||
'password' => 'require|length:6,20|alphaNum',
|
||||
'password_confirm' => 'require|confirm',
|
||||
];
|
||||
|
||||
|
||||
protected $message = [
|
||||
'mobile.require' => '请输入手机号',
|
||||
'mobile.mobile' => '请输入正确手机号',
|
||||
'code.require' => '请填写验证码',
|
||||
'password.require' => '请输入密码',
|
||||
'password.length' => '密码须在6-25位之间',
|
||||
'password.alphaNum' => '密码须为字母数字组合',
|
||||
'password_confirm.require' => '请确认密码',
|
||||
'password_confirm.confirm' => '两次输入的密码不一致'
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 重置登录密码
|
||||
* @return PasswordValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 18:11
|
||||
*/
|
||||
public function sceneResetPassword()
|
||||
{
|
||||
return $this->only(['mobile', 'code', 'password', 'password_confirm']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 修改密码场景
|
||||
* @return PasswordValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/20 19:14
|
||||
*/
|
||||
public function sceneChangePassword()
|
||||
{
|
||||
return $this->only(['password', 'password_confirm']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 支付验证
|
||||
* Class PayValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class PayValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'from' => 'require',
|
||||
'pay_way' => 'require|in:' . PayEnum::BALANCE_PAY . ',' . PayEnum::WECHAT_PAY . ',' . PayEnum::ALI_PAY,
|
||||
'order_id' => 'require'
|
||||
];
|
||||
|
||||
|
||||
protected $message = [
|
||||
'from.require' => '参数缺失',
|
||||
'pay_way.require' => '支付方式参数缺失',
|
||||
'pay_way.in' => '支付方式参数错误',
|
||||
'order_id.require' => '订单参数缺失'
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 支付方式场景
|
||||
* @return PayValidate
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 17:43
|
||||
*/
|
||||
public function scenePayway()
|
||||
{
|
||||
return $this->only(['from', 'order_id']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 支付状态
|
||||
* @return PayValidate
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 16:17
|
||||
*/
|
||||
public function sceneStatus()
|
||||
{
|
||||
return $this->only(['from', 'order_id']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 用户验证器
|
||||
* Class UserValidate
|
||||
* @package app\shopapi\validate
|
||||
*/
|
||||
class RechargeValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'money' => 'require|gt:0|checkMoney',
|
||||
];
|
||||
|
||||
|
||||
protected $message = [
|
||||
'money.require' => '请填写充值金额',
|
||||
'money.gt' => '请填写大于0的充值金额',
|
||||
];
|
||||
|
||||
|
||||
public function sceneRecharge()
|
||||
{
|
||||
return $this->only(['money']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @notes 校验金额
|
||||
* @param $money
|
||||
* @param $rule
|
||||
* @param $data
|
||||
* @return bool|string
|
||||
* @author 段誉
|
||||
* @date 2023/2/24 10:42
|
||||
*/
|
||||
protected function checkMoney($money, $rule, $data)
|
||||
{
|
||||
$status = ConfigService::get('recharge', 'status', 0);
|
||||
if (!$status) {
|
||||
return '充值功能已关闭';
|
||||
}
|
||||
|
||||
$minAmount = ConfigService::get('recharge', 'min_amount', 0);
|
||||
|
||||
if ($money < $minAmount) {
|
||||
return '最低充值金额' . $minAmount . "元";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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\validate;
|
||||
|
||||
|
||||
use app\common\model\user\User;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 注册验证器
|
||||
* Class RegisterValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class RegisterValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $regex = [
|
||||
'register' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]+$',
|
||||
'password' => '/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[\(\)])+$)([^(0-9a-zA-Z)]|[\(\)]|[a-z]|[A-Z]|[0-9]){6,20}$/'
|
||||
];
|
||||
|
||||
protected $rule = [
|
||||
'channel' => 'require',
|
||||
'account' => 'require|length:3,12|unique:' . User::class . '|regex:register',
|
||||
'password' => 'require|length:6,20|regex:password',
|
||||
'password_confirm' => 'require|confirm'
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'channel.require' => '注册来源参数缺失',
|
||||
'account.require' => '请输入账号',
|
||||
'account.regex' => '账号须为字母数字组合',
|
||||
'account.length' => '账号须为3-12位之间',
|
||||
'account.unique' => '账号已存在',
|
||||
'password.require' => '请输入密码',
|
||||
'password.length' => '密码须在6-25位之间',
|
||||
'password.regex' => '密码须为数字,字母或符号组合',
|
||||
'password_confirm.require' => '请确认密码',
|
||||
'password_confirm.confirm' => '两次输入的密码不一致'
|
||||
];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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\validate;
|
||||
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 短信验证
|
||||
* Class SmsValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class SendSmsValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'mobile' => 'require|mobile',
|
||||
'scene' => 'require',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'mobile.require' => '请输入手机号',
|
||||
'mobile.mobile' => '请输入正确手机号',
|
||||
'scene.require' => '请输入场景值',
|
||||
];
|
||||
}
|
||||
@@ -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\validate;
|
||||
|
||||
|
||||
use app\common\model\user\User;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 设置用户信息验证
|
||||
* Class SetUserInfoValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class SetUserInfoValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'avatar' => 'string',
|
||||
'phone' => 'regex:/^1[3-9]\d{9}$/|',
|
||||
'nickname' => 'string|max:50',
|
||||
'gender' => 'in:0,1',
|
||||
'age' => 'integer|between:0,150',
|
||||
'patient_name' => 'string|max:50'
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'phone.regex' => '手机号格式不正确',
|
||||
'nickname.max' => '昵称不能超过50个字符',
|
||||
'gender.in' => '性别参数错误',
|
||||
'age.between' => '年龄必须在0-150之间',
|
||||
'patient_name.max' => '姓名不能超过50个字符'
|
||||
];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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\validate;
|
||||
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 用户验证器
|
||||
* Class UserValidate
|
||||
* @package app\shopapi\validate
|
||||
*/
|
||||
class UserValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'code' => 'require',
|
||||
'sex' => 'in:1,2',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'code.require' => '参数缺失',
|
||||
'sex.in' => '性别参数无效',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取小程序手机号场景
|
||||
* @return UserValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 16:44
|
||||
*/
|
||||
public function sceneGetMobileByMnp()
|
||||
{
|
||||
return $this->only(['code', 'sex']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 绑定/变更 手机号
|
||||
* @return UserValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/21 17:37
|
||||
*/
|
||||
public function sceneBindMobile()
|
||||
{
|
||||
return $this->only(['mobile', 'code']);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\cache\WebScanLoginCache;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 网站扫码登录验证
|
||||
* Class WebScanLoginValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class WebScanLoginValidate extends BaseValidate
|
||||
{
|
||||
|
||||
protected $rule = [
|
||||
'code' => 'require',
|
||||
'state' => 'require|checkState',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'code.require' => '参数缺失',
|
||||
'state.require' => '昵称缺少',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 校验登录状态标记
|
||||
* @param $value
|
||||
* @param $rule
|
||||
* @param $data
|
||||
* @return bool|string
|
||||
* @author 段誉
|
||||
* @date 2022/10/21 9:47
|
||||
*/
|
||||
protected function checkState($value, $rule, $data)
|
||||
{
|
||||
$check = (new WebScanLoginCache())->getScanLoginState($value);
|
||||
|
||||
if (empty($check)) {
|
||||
return '二维码已失效或不存在,请重新扫码';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
|
||||
/**
|
||||
* 微信登录验证
|
||||
* Class WechatLoginValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class WechatLoginValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'code' => 'require',
|
||||
'nickname' => 'require',
|
||||
'headimgurl' => 'require',
|
||||
'openid' => 'require',
|
||||
'access_token' => 'require',
|
||||
'terminal' => 'require',
|
||||
'avatar' => 'require',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'code.require' => 'code缺少',
|
||||
'nickname.require' => '昵称缺少',
|
||||
'headimgurl.require' => '头像缺少',
|
||||
'openid.require' => 'opendid缺少',
|
||||
'access_token.require' => 'access_token缺少',
|
||||
'terminal.require' => '终端参数缺少',
|
||||
'avatar.require' => '头像缺少',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号登录场景
|
||||
* @return WechatLoginValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 10:57
|
||||
*/
|
||||
public function sceneOa()
|
||||
{
|
||||
return $this->only(['code']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序-授权登录场景
|
||||
* @return WechatLoginValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 11:15
|
||||
*/
|
||||
public function sceneMnpLogin()
|
||||
{
|
||||
return $this->only(['code']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes
|
||||
* @return WechatLoginValidate
|
||||
* @author 段誉
|
||||
* @date 2022/9/16 11:15
|
||||
*/
|
||||
public function sceneWechatAuth()
|
||||
{
|
||||
return $this->only(['code']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 更新用户信息场景
|
||||
* @return WechatLoginValidate
|
||||
* @author 段誉
|
||||
* @date 2023/2/22 11:14
|
||||
*/
|
||||
public function sceneUpdateUser()
|
||||
{
|
||||
return $this->only(['nickname', 'avatar']);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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\validate;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 微信验证器
|
||||
* Class WechatValidate
|
||||
* @package app\api\validate
|
||||
*/
|
||||
class WechatValidate extends BaseValidate
|
||||
{
|
||||
public $rule = [
|
||||
'url' => 'require'
|
||||
];
|
||||
|
||||
public $message = [
|
||||
'url.require' => '请提供url'
|
||||
];
|
||||
|
||||
public function sceneJsConfig()
|
||||
{
|
||||
return $this->only(['url']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user