first commit
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* OpenAI 兼容 Chat Completions 客户端
|
||||
*/
|
||||
class AiChatService
|
||||
{
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
return !empty($cfg['enable']) && trim((string) ($cfg['api_key'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role:string,content:string}> $messages
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{ok:bool,content?:string,error?:string,raw?:array}
|
||||
*/
|
||||
public static function chat(array $messages, array $options = []): array
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
if (empty($cfg['enable'])) {
|
||||
return ['ok' => false, 'error' => 'AI 未启用'];
|
||||
}
|
||||
$apiKey = trim((string) ($cfg['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return ['ok' => false, 'error' => 'AI 密钥未配置'];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/');
|
||||
$model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat'));
|
||||
$timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60));
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => (float) ($options['temperature'] ?? 0.4),
|
||||
'max_tokens' => (int) ($options['max_tokens'] ?? 1200),
|
||||
];
|
||||
if (!empty($options['response_format'])) {
|
||||
$payload['response_format'] = $options['response_format'];
|
||||
}
|
||||
|
||||
$url = $baseUrl . '/v1/chat/completions';
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3))));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(5, $timeout));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['ok' => false, 'error' => 'AI 请求失败:' . $err];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $body, true);
|
||||
if ($code >= 400 || !is_array($decoded)) {
|
||||
$msg = is_array($decoded) ? ($decoded['error']['message'] ?? $decoded['message'] ?? 'AI 返回异常') : 'AI 返回异常';
|
||||
return ['ok' => false, 'error' => (string) $msg, 'raw' => $decoded];
|
||||
}
|
||||
|
||||
$content = (string) ($decoded['choices'][0]['message']['content'] ?? '');
|
||||
if ($content === '') {
|
||||
return ['ok' => false, 'error' => 'AI 未返回内容', 'raw' => $decoded];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'content' => $content, 'raw' => $decoded];
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式 Chat Completions(OpenAI SSE 格式)
|
||||
*
|
||||
* @param callable(string):void $onDelta 每收到一段文本回调
|
||||
* @return array{ok:bool,content?:string,error?:string}
|
||||
*/
|
||||
public static function streamChat(array $messages, callable $onDelta, array $options = []): array
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
if (empty($cfg['enable'])) {
|
||||
return ['ok' => false, 'error' => 'AI 未启用'];
|
||||
}
|
||||
$apiKey = trim((string) ($cfg['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return ['ok' => false, 'error' => 'AI 密钥未配置'];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/');
|
||||
$model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat'));
|
||||
$timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60));
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => (float) ($options['temperature'] ?? 0.4),
|
||||
'max_tokens' => (int) ($options['max_tokens'] ?? 1200),
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$lineBuffer = '';
|
||||
$fullContent = '';
|
||||
|
||||
$writeFn = static function ($ch, string $chunk) use (&$lineBuffer, &$fullContent, $onDelta): int {
|
||||
$lineBuffer .= $chunk;
|
||||
while (($pos = strpos($lineBuffer, "\n")) !== false) {
|
||||
$line = rtrim(substr($lineBuffer, 0, $pos), "\r");
|
||||
$lineBuffer = substr($lineBuffer, $pos + 1);
|
||||
if ($line === '' || strncmp($line, 'data:', 5) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$data = trim(substr($line, 5));
|
||||
if ($data === '' || $data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
$json = json_decode($data, true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
$delta = (string) ($json['choices'][0]['delta']['content'] ?? '');
|
||||
if ($delta === '') {
|
||||
continue;
|
||||
}
|
||||
$fullContent .= $delta;
|
||||
$onDelta($delta);
|
||||
}
|
||||
|
||||
return strlen($chunk);
|
||||
};
|
||||
|
||||
$url = $baseUrl . '/v1/chat/completions';
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
|
||||
curl_setopt($ch, CURLOPT_WRITEFUNCTION, $writeFn);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3))));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(10, $timeout));
|
||||
curl_setopt($ch, CURLOPT_TCP_NODELAY, true);
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Accept: text/event-stream',
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['ok' => false, 'error' => 'AI 请求失败:' . $err];
|
||||
}
|
||||
if ($code >= 400) {
|
||||
return ['ok' => false, 'error' => 'AI 返回异常(HTTP ' . $code . ')'];
|
||||
}
|
||||
if ($fullContent === '') {
|
||||
return ['ok' => false, 'error' => 'AI 未返回内容'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'content' => $fullContent];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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\common\service;
|
||||
|
||||
use app\common\model\Config;
|
||||
|
||||
class ConfigService
|
||||
{
|
||||
/**
|
||||
* @notes 设置配置值
|
||||
* @param $type
|
||||
* @param $name
|
||||
* @param $value
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2021/12/27 15:00
|
||||
*/
|
||||
public static function set(string $type, string $name, $value)
|
||||
{
|
||||
$original = $value;
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
$data = Config::where(['type' => $type, 'name' => $name])->findOrEmpty();
|
||||
|
||||
if ($data->isEmpty()) {
|
||||
Config::create([
|
||||
'type' => $type,
|
||||
'name' => $name,
|
||||
'value' => $value,
|
||||
]);
|
||||
} else {
|
||||
$data->value = $value;
|
||||
$data->save();
|
||||
}
|
||||
|
||||
// 返回原始值
|
||||
return $original;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取配置值
|
||||
* @param $type
|
||||
* @param string $name
|
||||
* @param null $default_value
|
||||
* @return array|int|mixed|string
|
||||
* @author Tab
|
||||
* @date 2021/7/15 15:16
|
||||
*/
|
||||
public static function get(string $type, string $name = '', $default_value = null)
|
||||
{
|
||||
if (!empty($name)) {
|
||||
$value = Config::where(['type' => $type, 'name' => $name])->value('value');
|
||||
if (!is_null($value)) {
|
||||
$json = json_decode($value, true);
|
||||
$value = json_last_error() === JSON_ERROR_NONE ? $json : $value;
|
||||
}
|
||||
if ($value) {
|
||||
return $value;
|
||||
}
|
||||
// 返回特殊值 0 '0'
|
||||
if ($value === 0 || $value === '0') {
|
||||
return $value;
|
||||
}
|
||||
// 返回默认值
|
||||
if ($default_value !== null) {
|
||||
return $default_value;
|
||||
}
|
||||
// 返回本地配置文件中的值
|
||||
return config('project.' . $type . '.' . $name);
|
||||
}
|
||||
|
||||
// 取某个类型下的所有name的值
|
||||
$data = Config::where(['type' => $type])->column('value', 'name');
|
||||
foreach ($data as $k => $v) {
|
||||
$json = json_decode($v, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$data[$k] = $json;
|
||||
}
|
||||
}
|
||||
if ($data) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\DataScope;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 数据范围(数据隔离)工具服务。
|
||||
*
|
||||
* 设计约定:
|
||||
* - ALL (1) = 全部数据,不附加过滤
|
||||
* - DEPT_AND_CHILD (2) = 本部门及所有子部门(取 admin 全部部门的并集)
|
||||
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
|
||||
* - SELF (4) = 仅本人
|
||||
*
|
||||
* 多角色时,2/3/4 范围按授权并集取最宽范围;迁移期遗留的普通 scope=1 角色不会
|
||||
* 自动冲掉有限范围。只有明确的管理员角色、root 或 exempt_roles 才能在组合角色中放开全部。
|
||||
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
|
||||
*
|
||||
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 null(ALL = 不过滤)。
|
||||
*/
|
||||
class DataScopeService
|
||||
{
|
||||
public const SCOPE_ALL = 1;
|
||||
public const SCOPE_DEPT_AND_CHILD = 2;
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
|
||||
/** @var string[] 明确允许在多角色组合中放开全部数据的内置角色名。 */
|
||||
private const ALL_SCOPE_ROLE_NAMES = ['管理员', '系统管理员'];
|
||||
|
||||
/**
|
||||
* 计算当前 admin 的有效数据范围。
|
||||
*/
|
||||
public static function getEffectiveScope(array $adminInfo): int
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
$roleIds = self::normalizeRoleIds($adminInfo['role_id'] ?? null);
|
||||
$exempt = array_map('intval', Config::get('project.data_scope.exempt_roles', []) ?: []);
|
||||
if ($roleIds !== [] && array_intersect($roleIds, $exempt) !== []) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ($roleIds === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
$roleRows = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->field('id,name,data_scope')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::mergeRoleScopes($roleRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个角色的数据范围。
|
||||
*
|
||||
* 历史迁移曾把全部旧角色默认成 scope=1;如果账号同时具有有限范围角色,普通的
|
||||
* scope=1 视为功能角色而不参与放大,防止“医生 + 医助”等组合意外获得全站数据。
|
||||
* 2/3/4 是嵌套授权,取最小值即可表达多个有效数据角色的可见范围并集。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $roleRows
|
||||
*/
|
||||
private static function mergeRoleScopes(array $roleRows): int
|
||||
{
|
||||
$validRows = [];
|
||||
foreach ($roleRows as $roleRow) {
|
||||
$scope = (int) ($roleRow['data_scope'] ?? 0);
|
||||
if ($scope < self::SCOPE_ALL || $scope > self::SCOPE_SELF) {
|
||||
continue;
|
||||
}
|
||||
$validRows[] = [
|
||||
'name' => trim((string) ($roleRow['name'] ?? '')),
|
||||
'scope' => $scope,
|
||||
];
|
||||
}
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到本人。
|
||||
if ($validRows === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
|
||||
foreach ($validRows as $roleRow) {
|
||||
if ($roleRow['scope'] === self::SCOPE_ALL
|
||||
&& in_array($roleRow['name'], self::ALL_SCOPE_ROLE_NAMES, true)) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
$boundedScopes = array_column(array_values(array_filter(
|
||||
$validRows,
|
||||
static fn (array $roleRow): bool => $roleRow['scope'] > self::SCOPE_ALL
|
||||
)), 'scope');
|
||||
if ($boundedScopes !== []) {
|
||||
return (int) min($boundedScopes);
|
||||
}
|
||||
|
||||
// 只有普通 scope=1 角色时保持原有“全部数据”行为。
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一解析 token/cache 中的 role_id(数组 | 单整数 | JSON 字符串)。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function normalizeRoleIds(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (\is_int($raw) || \is_float($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw) && is_numeric($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (\is_array($decoded)) {
|
||||
$raw = $decoded;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!\is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn ($v): int => (int) $v,
|
||||
$raw
|
||||
), static fn (int $v): bool => $v > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 admin id 集合;null = 不过滤(ALL)
|
||||
*
|
||||
* @return array<int>|null
|
||||
*/
|
||||
public static function getVisibleAdminIds(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
$scope = self::getEffectiveScope($adminInfo);
|
||||
if ($scope === self::SCOPE_ALL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($scope === self::SCOPE_SELF) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$myDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$myDeptIds = array_values(array_filter(array_map('intval', $myDeptIds), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
}));
|
||||
|
||||
if ($myDeptIds === []) {
|
||||
$fallback = (bool) Config::get('project.data_scope.no_dept_fallback_self', true);
|
||||
|
||||
return $fallback ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$targetDeptIds = [];
|
||||
if ($scope === self::SCOPE_DEPT) {
|
||||
$targetDeptIds = $myDeptIds;
|
||||
} else {
|
||||
foreach ($myDeptIds as $did) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($did) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$targetDeptIds[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$targetDeptIds = array_keys($targetDeptIds);
|
||||
}
|
||||
|
||||
if ($targetDeptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$ids = AdminDept::whereIn('dept_id', $targetDeptIds)->column('admin_id');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
})));
|
||||
if ($adminId > 0 && !in_array($adminId, $ids, true)) {
|
||||
$ids[] = $adminId;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return (bool) Config::get('project.data_scope.enabled', true);
|
||||
}
|
||||
|
||||
public static function isAll(array $adminInfo): bool
|
||||
{
|
||||
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
|
||||
*
|
||||
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
|
||||
*/
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if ($adminId <= 0 || !self::isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$set = [];
|
||||
foreach ($visibleIds as $aid) {
|
||||
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
|
||||
foreach ($deptRows as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
|
||||
$x = (int) $x;
|
||||
if ($x > 0) {
|
||||
$set[$x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字描述(日志 / 接口返回可选使用)
|
||||
*/
|
||||
public static function scopeLabel(int $scope): string
|
||||
{
|
||||
return [
|
||||
self::SCOPE_ALL => '全部',
|
||||
self::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
self::SCOPE_DEPT => '仅本部门',
|
||||
self::SCOPE_SELF => '仅本人',
|
||||
][$scope] ?? '全部';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 处方/诊单 AI 上游客户端。
|
||||
*
|
||||
* 兼容 Dify blocking chat-messages 与 OpenAI-compatible chat completions。
|
||||
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
|
||||
*/
|
||||
class DifyChatService
|
||||
{
|
||||
/** @var array<int,string> */
|
||||
private const ALLOWED_PROFILES = ['qwen', 'openai'];
|
||||
|
||||
private const MIN_TIMEOUT = 1;
|
||||
|
||||
private const MAX_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
|
||||
*/
|
||||
public static function chat(string $profile, array $inputs, string $query, string $user): array
|
||||
{
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
|
||||
}
|
||||
|
||||
$modelConfig = self::resolveProfileConfig($config, $profile);
|
||||
if ($modelConfig === null) {
|
||||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||||
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
|
||||
$apiKey = trim($rawApiKey);
|
||||
if ($baseUrl === '' || $apiKey === '') {
|
||||
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
|
||||
}
|
||||
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
|
||||
}
|
||||
|
||||
$timeout = (int) ($config['timeout'] ?? 0);
|
||||
if (!self::isValidTimeout($timeout)) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||||
}
|
||||
|
||||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||||
if ($model === '') {
|
||||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||||
}
|
||||
|
||||
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
|
||||
$startedAt = microtime(true);
|
||||
$lastResponse = null;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendRequest(
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout
|
||||
);
|
||||
$lastResponse = $response;
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return self::formatResponse($response, $startedAt);
|
||||
}
|
||||
|
||||
return self::formatResponse($lastResponse ?? [
|
||||
'body' => '',
|
||||
'errno' => 0,
|
||||
'http_code' => 0,
|
||||
], $startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function resolveProfileConfig(array $config, string $profile): ?array
|
||||
{
|
||||
if (!in_array($profile, self::ALLOWED_PROFILES, true)) {
|
||||
return null;
|
||||
}
|
||||
$modelConfig = $config['models'][$profile] ?? null;
|
||||
return is_array($modelConfig) ? $modelConfig : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
|
||||
*/
|
||||
private static function buildRequestSpecs(
|
||||
string $baseUrl,
|
||||
string $model,
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user
|
||||
): array {
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
|
||||
$difySpec = [
|
||||
'protocol' => 'dify',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
|
||||
'payload' => [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking',
|
||||
'user' => $user,
|
||||
],
|
||||
];
|
||||
$openAiSpec = [
|
||||
'protocol' => 'openai',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
|
||||
'payload' => [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $query],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (str_ends_with($path, '/chat-messages')) {
|
||||
return [$difySpec];
|
||||
}
|
||||
if (str_ends_with($path, '/chat/completions')) {
|
||||
return [$openAiSpec];
|
||||
}
|
||||
|
||||
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
|
||||
return [$difySpec, $openAiSpec];
|
||||
}
|
||||
|
||||
private static function buildEndpoint(string $baseUrl, string $endpoint): string
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) {
|
||||
return $baseUrl;
|
||||
}
|
||||
if (str_ends_with($path, '/v1')) {
|
||||
return $baseUrl . '/' . $endpoint;
|
||||
}
|
||||
return $baseUrl . '/v1/' . $endpoint;
|
||||
}
|
||||
|
||||
private static function isValidBaseUrl(string $baseUrl): bool
|
||||
{
|
||||
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
|
||||
return false;
|
||||
}
|
||||
$parts = parse_url($baseUrl);
|
||||
if (!is_array($parts)) {
|
||||
return false;
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
return in_array($scheme, ['http', 'https'], true)
|
||||
&& trim((string) ($parts['host'] ?? '')) !== ''
|
||||
&& !isset($parts['user'])
|
||||
&& !isset($parts['pass'])
|
||||
&& !isset($parts['query'])
|
||||
&& !isset($parts['fragment']);
|
||||
}
|
||||
|
||||
private static function isValidTimeout(int $timeout): bool
|
||||
{
|
||||
return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{body:string,errno:int,http_code:int}
|
||||
*/
|
||||
private static function sendRequest(
|
||||
string $url,
|
||||
array $payload,
|
||||
string $apiKey,
|
||||
int $timeout
|
||||
): array {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($body === false) {
|
||||
return ['body' => '', 'errno' => -1, 'http_code' => 0];
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['body' => '', 'errno' => -2, 'http_code' => 0];
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'body' => is_string($responseBody) ? $responseBody : '',
|
||||
'errno' => $errno,
|
||||
'http_code' => $httpCode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{body:string,errno:int,http_code:int} $response
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||||
*/
|
||||
private static function formatResponse(array $response, float $startedAt): array
|
||||
{
|
||||
$latencyMs = self::elapsedMilliseconds($startedAt);
|
||||
$errno = $response['errno'];
|
||||
$httpCode = $response['http_code'];
|
||||
|
||||
if ($errno !== 0) {
|
||||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($errno === -1) {
|
||||
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
|
||||
}
|
||||
if ($errno === -2) {
|
||||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
|
||||
}
|
||||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||||
}
|
||||
|
||||
$decoded = json_decode($response['body'], true);
|
||||
if ($httpCode === 401 || $httpCode === 403) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
|
||||
}
|
||||
if ($httpCode === 429 || $httpCode >= 500) {
|
||||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($httpCode >= 400 || $httpCode < 200) {
|
||||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||||
}
|
||||
if (!is_array($decoded)) {
|
||||
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
$answer = self::extractContent($decoded);
|
||||
if ($answer === '') {
|
||||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'content' => $answer,
|
||||
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $decoded */
|
||||
private static function extractContent(array $decoded): string
|
||||
{
|
||||
$content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? '';
|
||||
if (is_string($content)) {
|
||||
return trim($content);
|
||||
}
|
||||
if (!is_array($content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($content as $part) {
|
||||
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
|
||||
$parts[] = $part['text'];
|
||||
}
|
||||
}
|
||||
return trim(implode('', $parts));
|
||||
}
|
||||
|
||||
private static function elapsedMilliseconds(float $startedAt): int
|
||||
{
|
||||
return (int) round((microtime(true) - $startedAt) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:false,error_code:string,error:string,latency_ms:int}
|
||||
*/
|
||||
private static function error(string $code, string $message, int $latencyMs = 0): array
|
||||
{
|
||||
return [
|
||||
'ok' => false,
|
||||
'error_code' => $code,
|
||||
'error' => $message,
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\FileEnum;
|
||||
use app\common\model\file\File;
|
||||
use app\common\service\storage\engine\Qcloud as QcloudEngine;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 浏览器直传 OSS 服务
|
||||
* - 目前仅支持腾讯云 COS(qcloud)
|
||||
* - 其他 driver 返回 fallback=true,由前端降级到老链路
|
||||
*
|
||||
* Class DirectUploadService
|
||||
* @package app\common\service
|
||||
*/
|
||||
class DirectUploadService
|
||||
{
|
||||
/** 视频允许的扩展名(沿用 config/project.file_video) */
|
||||
public const TYPE_VIDEO = 'video';
|
||||
public const TYPE_VOICE = 'voice';
|
||||
|
||||
/** 默认凭证有效期 30 分钟 */
|
||||
public const DEFAULT_DURATION = 1800;
|
||||
|
||||
/** 允许扩展类型 → 大小上限(字节) */
|
||||
private const MAX_SIZE = [
|
||||
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 签发临时凭证
|
||||
* @param string $type
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function issueCredentials(string $type): array
|
||||
{
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型: ' . $type);
|
||||
}
|
||||
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
if ($default !== 'qcloud') {
|
||||
// 其他 driver 不支持直传,前端降级
|
||||
return ['provider' => $default, 'fallback' => true];
|
||||
}
|
||||
|
||||
$storageConfig = ConfigService::get('storage', 'qcloud');
|
||||
if (empty($storageConfig['bucket']) || empty($storageConfig['region'])
|
||||
|| empty($storageConfig['access_key']) || empty($storageConfig['secret_key'])) {
|
||||
throw new Exception('腾讯云 COS 配置不完整');
|
||||
}
|
||||
|
||||
$keyPrefix = self::buildKeyPrefix($type);
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
|
||||
|
||||
return [
|
||||
'provider' => 'qcloud',
|
||||
'fallback' => false,
|
||||
'bucket' => $sts['bucket'],
|
||||
'region' => $sts['region'],
|
||||
'host' => $sts['host'],
|
||||
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
|
||||
'key_prefix' => $keyPrefix,
|
||||
'max_size' => self::MAX_SIZE[$type],
|
||||
'duration' => self::DEFAULT_DURATION,
|
||||
'expired_time' => $sts['expiredTime'],
|
||||
'start_time' => $sts['startTime'],
|
||||
'credentials' => $sts['credentials'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 直传完成后的回执:HEAD 校验 + 写 file 表
|
||||
* @param array $params {key, type, name, size, content_type, cid, admin_id}
|
||||
* @return array {id, cid, type, name, uri, url}
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function confirm(array $params): array
|
||||
{
|
||||
$type = (string)($params['type'] ?? '');
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型');
|
||||
}
|
||||
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
if ($default !== 'qcloud') {
|
||||
throw new Exception('当前存储驱动不支持直传回执');
|
||||
}
|
||||
|
||||
$key = ltrim((string)($params['key'] ?? ''), '/');
|
||||
$allowedPrefix = self::buildKeyPrefix($type);
|
||||
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
|
||||
throw new Exception('对象 Key 非法');
|
||||
}
|
||||
|
||||
$storageConfig = ConfigService::get('storage', 'qcloud');
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$head = $engine->headObject($key);
|
||||
if ($head === false) {
|
||||
throw new Exception('对象未找到,请确认上传是否完成');
|
||||
}
|
||||
if ($head['size'] <= 0 || $head['size'] > self::MAX_SIZE[$type]) {
|
||||
throw new Exception('文件大小超出限制');
|
||||
}
|
||||
|
||||
$name = trim((string)($params['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = basename($key);
|
||||
}
|
||||
if (strlen($name) > 128) {
|
||||
$name = substr($name, 0, 123) . substr($name, -5);
|
||||
}
|
||||
|
||||
$file = File::create([
|
||||
'cid' => (int)($params['cid'] ?? 0),
|
||||
'type' => self::resolveFileType($type),
|
||||
'name' => $name,
|
||||
'uri' => $key,
|
||||
'source' => FileEnum::SOURCE_ADMIN,
|
||||
'source_id' => (int)($params['admin_id'] ?? 0),
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$url = FileService::getFileUrl($key);
|
||||
return [
|
||||
'id' => $file['id'],
|
||||
'cid' => $file['cid'],
|
||||
'type' => $file['type'],
|
||||
'name' => $file['name'],
|
||||
'uri' => $url,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildKeyPrefix(string $type): string
|
||||
{
|
||||
return 'uploads/' . $type . '/' . date('Ymd') . '/';
|
||||
}
|
||||
|
||||
private static function resolveFileType(string $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
|
||||
self::TYPE_VOICE => FileEnum::FILE_TYPE,
|
||||
default => FileEnum::FILE_TYPE,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 快递轨迹:顺丰(shunfeng)、京东(jd),优先走快递100;未配置时返回官网查询链接
|
||||
*/
|
||||
class ExpressTrackService
|
||||
{
|
||||
private const KUAIDI_COM_SF = 'shunfeng';
|
||||
|
||||
private const KUAIDI_COM_JD = 'jingdong'; // 京东快递(快递100编码)
|
||||
|
||||
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
|
||||
|
||||
/**
|
||||
* @param string $phoneTailOverride 手工填写的收件电话(仅数字;完整 11 位或与面单一致的后四位等),优先于订单收货手机
|
||||
*
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = '', string $phoneTailOverride = ''): array
|
||||
{
|
||||
$num = trim($trackingNumber);
|
||||
$overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? '';
|
||||
$recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
|
||||
// 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」
|
||||
$phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits);
|
||||
|
||||
$resolved = self::resolveCarrier($expressCompany, $num);
|
||||
$carrier = $resolved['carrier'];
|
||||
$kuaidiCom = $resolved['kuaidi_com'];
|
||||
$label = $resolved['label'];
|
||||
$comCandidates = self::kuaidiComCandidates($kuaidiCom, $num);
|
||||
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'official_only',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$cfg = Config::get('logistics.kuaidi100', []);
|
||||
$enable = !empty($cfg['enable']);
|
||||
|
||||
$result = $out;
|
||||
if (! $enable) {
|
||||
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||
} else {
|
||||
$matched = null;
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
$matched = $tryOut;
|
||||
break;
|
||||
}
|
||||
$lastFail = $tryOut;
|
||||
}
|
||||
$result = $matched ?? $lastFail ?? $out;
|
||||
}
|
||||
|
||||
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
|
||||
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
|
||||
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
|
||||
try {
|
||||
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
|
||||
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
|
||||
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
|
||||
$result['traces'] = $jd['traces'];
|
||||
$result['state'] = (string) $jd['state'];
|
||||
$result['state_text'] = (string) $jd['state_text'];
|
||||
$result['source'] = 'jd_official';
|
||||
$result['hint'] = '';
|
||||
// carrier / carrier_label / official_url / kuaidi_com 保留原值
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ExpressTrackService jd official fallback failed', [
|
||||
'num' => $num,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 京东官方轨迹是否应优先于快递100 结果采用:
|
||||
* 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。
|
||||
*
|
||||
* @param array{traces?:array,newest_unix?:int} $jd
|
||||
* @param array{traces?:array} $kuaidi
|
||||
*/
|
||||
private static function jdResultPreferred(array $jd, array $kuaidi): bool
|
||||
{
|
||||
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
|
||||
if ($kuaidiTraces === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
|
||||
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
|
||||
if ($jdNewest > $kuaidiNewest) {
|
||||
return true;
|
||||
}
|
||||
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
|
||||
return count($jd['traces'] ?? []) > count($kuaidiTraces);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{time?:string}> $traces
|
||||
*/
|
||||
private static function newestUnixFromTraces(array $traces): int
|
||||
{
|
||||
$best = 0;
|
||||
foreach ($traces as $t) {
|
||||
if (!is_array($t)) {
|
||||
continue;
|
||||
}
|
||||
$p = strtotime((string) ($t['time'] ?? ''));
|
||||
if ($p !== false && (int) $p > $best) {
|
||||
$best = (int) $p;
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出)
|
||||
*/
|
||||
public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($trackingNumber);
|
||||
if ($byNumber === null) {
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
|
||||
return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto';
|
||||
}
|
||||
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
$byEc = self::carrierFromExpressCode($ec);
|
||||
if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $cfg
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
private static function queryKuaidiOnce(
|
||||
array $cfg,
|
||||
string $kuaidiCom,
|
||||
string $num,
|
||||
string $phoneForKuaidi,
|
||||
string $carrier,
|
||||
string $label
|
||||
): array {
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'kuaidi100',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$paramArr = [
|
||||
'com' => $kuaidiCom,
|
||||
'num' => $num,
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($phoneForKuaidi !== '') {
|
||||
$paramArr['phone'] = $phoneForKuaidi;
|
||||
}
|
||||
|
||||
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
|
||||
$customer = (string) $cfg['customer'];
|
||||
$key = (string) $cfg['key'];
|
||||
$sign = strtoupper(md5($paramJson . $key . $customer));
|
||||
$postBody = http_build_query([
|
||||
'customer' => $customer,
|
||||
'param' => $paramJson,
|
||||
'sign' => $sign,
|
||||
]);
|
||||
|
||||
$url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do');
|
||||
$raw = self::httpPostForm($url, $postBody);
|
||||
if ($raw === null || $raw === '') {
|
||||
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
$out['hint'] = '快递100返回异常,请使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500), 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
if (isset($json['result']) && $json['result'] === false) {
|
||||
$msg = (string) ($json['message'] ?? '查询失败');
|
||||
$returnCode = (string) ($json['returnCode'] ?? '');
|
||||
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
||||
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
||||
} else {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
Log::info('ExpressTrackService kuaidi100 business fail', [
|
||||
'message' => $msg,
|
||||
'returnCode' => $returnCode,
|
||||
'num' => $num,
|
||||
'com' => $kuaidiCom,
|
||||
]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
||||
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$traces = [];
|
||||
foreach ($data as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$t = (string) ($row['ftime'] ?? $row['time'] ?? '');
|
||||
$c = (string) ($row['context'] ?? '');
|
||||
if ($t === '' && $c === '') {
|
||||
continue;
|
||||
}
|
||||
$traces[] = ['time' => $t, 'context' => $c];
|
||||
}
|
||||
|
||||
$out['traces'] = $traces;
|
||||
$out['state'] = (string) ($json['state'] ?? '');
|
||||
$out['state_text'] = self::stateText($out['state']);
|
||||
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function kuaidiComCandidates(string $primaryCom, string $num): array
|
||||
{
|
||||
$list = [$primaryCom];
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) {
|
||||
$list[] = $byNumber['kuaidi_com'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) {
|
||||
$list[] = 'jd';
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) {
|
||||
foreach (['jingdong', 'jd'] as $c) {
|
||||
if (!in_array($c, $list, true)) {
|
||||
$list[] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
|
||||
* 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
|
||||
*/
|
||||
private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string
|
||||
{
|
||||
$d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits;
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($d) >= 11) {
|
||||
return substr($d, -11);
|
||||
}
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
/** 实时查询文档:顺丰速运、中通快递等 phone 必填 */
|
||||
private static function kuaidiPhoneRequired(string $kuaidiCom): bool
|
||||
{
|
||||
$c = strtolower($kuaidiCom);
|
||||
|
||||
return $c === self::KUAIDI_COM_SF || $c === 'zhongtong';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function carrierFromExpressCode(string $expressCompany): ?array
|
||||
{
|
||||
$ec = strtolower(trim($expressCompany));
|
||||
if ($ec === 'sf' || $ec === 'shunfeng') {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
||||
}
|
||||
if ($ec === 'jd' || $ec === 'jingdong') {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
||||
}
|
||||
if ($ec === 'jt' || $ec === 'jtexpress') {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function detectCarrierFromNumber(string $num): ?array
|
||||
{
|
||||
$n = trim($num);
|
||||
if ($n === '') {
|
||||
return null;
|
||||
}
|
||||
$u = strtoupper($n);
|
||||
if (preg_match('/^SF\d/i', $n)) {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $n)) {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
$byEc = self::carrierFromExpressCode($expressCompany);
|
||||
|
||||
if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_number' => $num,
|
||||
'stored_carrier' => $byEc['carrier'],
|
||||
'detected_carrier' => $byNumber['carrier'],
|
||||
]);
|
||||
|
||||
return $byNumber;
|
||||
}
|
||||
if ($byEc !== null) {
|
||||
return $byEc;
|
||||
}
|
||||
if ($byNumber !== null) {
|
||||
return $byNumber;
|
||||
}
|
||||
|
||||
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
||||
}
|
||||
|
||||
private static function stateText(string $state): string
|
||||
{
|
||||
$m = [
|
||||
'0' => '在途',
|
||||
'1' => '揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回',
|
||||
'7' => '转投',
|
||||
'10' => '待清关',
|
||||
'11' => '清关中',
|
||||
'12' => '已清关',
|
||||
'13' => '清关异常',
|
||||
'14' => '收件人拒签',
|
||||
];
|
||||
|
||||
return $m[$state] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sf: string, jd: string, jt: string}
|
||||
*/
|
||||
public static function officialUrls(string $trackingNumber): array
|
||||
{
|
||||
$n = trim($trackingNumber);
|
||||
$enc = rawurlencode($n);
|
||||
|
||||
return [
|
||||
// 顺丰速运官网查询(新版)
|
||||
'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc,
|
||||
// 京东物流官网查询
|
||||
'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc,
|
||||
// 极兔速递官网查询
|
||||
'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildOfficialUrl(string $carrier, string $num): string
|
||||
{
|
||||
$urls = self::officialUrls($num);
|
||||
if ($carrier === 'sf') {
|
||||
return $urls['sf'];
|
||||
}
|
||||
if ($carrier === 'jd') {
|
||||
return $urls['jd'];
|
||||
}
|
||||
if ($carrier === 'jt') {
|
||||
return $urls['jt'];
|
||||
}
|
||||
|
||||
return $urls['jt']; // 默认返回极兔
|
||||
}
|
||||
|
||||
private static function httpPostForm(string $url, string $body): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $resp === false ? null : (string) $resp;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
<?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\common\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
class FileService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 补全路径
|
||||
* @param string $uri
|
||||
* @param string $type
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2021/12/28 15:19
|
||||
* @remark
|
||||
* 场景一:补全域名路径,仅传参$uri;
|
||||
* 例: FileService::getFileUrl('uploads/img.png');
|
||||
* 返回 http://www.likeadmin.localhost/uploads/img.png
|
||||
*
|
||||
* 场景二:补全获取web根目录路径, 传参$uri 和 $type = public_path;
|
||||
* 例: FileService::getFileUrl('uploads/img.png', 'public_path');
|
||||
* 返回 /project-services/likeadmin/server/public/uploads/img.png
|
||||
*
|
||||
* 场景三:获取当前储存方式的域名
|
||||
* 例: FileService::getFileUrl();
|
||||
* 返回 http://www.likeadmin.localhost/
|
||||
*/
|
||||
public static function getFileUrl(string $uri = '', string $type = '') : string
|
||||
{
|
||||
if (strstr($uri, 'http://')) return $uri;
|
||||
if (strstr($uri, 'https://')) return $uri;
|
||||
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
|
||||
if ($default === 'local') {
|
||||
if($type == 'public_path') {
|
||||
return public_path(). $uri;
|
||||
}
|
||||
$domain = request()->domain();
|
||||
} else {
|
||||
$storage = ConfigService::get('storage', $default);
|
||||
$domain = $storage ? $storage['domain'] : '';
|
||||
}
|
||||
|
||||
return self::format($domain, $uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 转相对路径
|
||||
* @param $uri
|
||||
* @return mixed
|
||||
* @author 张无忌
|
||||
* @date 2021/7/28 15:09
|
||||
*/
|
||||
public static function setFileUrl($uri)
|
||||
{
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
if ($default === 'local') {
|
||||
$domain = request()->domain();
|
||||
return str_replace($domain.'/', '', $uri);
|
||||
} else {
|
||||
$storage = ConfigService::get('storage', $default);
|
||||
return str_replace($storage['domain'].'/', '', $uri);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 格式化url
|
||||
* @param $domain
|
||||
* @param $uri
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/7/11 10:36
|
||||
*/
|
||||
public static function format($domain, $uri)
|
||||
{
|
||||
// 处理域名
|
||||
$domainLen = strlen($domain);
|
||||
$domainRight = substr($domain, $domainLen -1, 1);
|
||||
if ('/' == $domainRight) {
|
||||
$domain = substr_replace($domain,'',$domainLen -1, 1);
|
||||
}
|
||||
|
||||
// 处理uri
|
||||
$uriLeft = substr($uri, 0, 1);
|
||||
if('/' == $uriLeft) {
|
||||
$uri = substr_replace($uri,'',0, 1);
|
||||
}
|
||||
|
||||
return trim($domain) . '/' . trim($uri);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 京东官方物流轨迹查询(京东物流开放平台 LOP,https://api.jdl.com)
|
||||
*
|
||||
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
|
||||
* 同时供后台「京东接口更新」按钮直接拉取并落库。
|
||||
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
|
||||
* 未配置时 isConfigured()=false,ExpressTrackService 完全沿用快递100 逻辑,互不影响。
|
||||
*
|
||||
* 接口:京东物流标准轨迹服务 /jd/tracking/query(2025-04-29 改版,对接方案编码 Tracking_JD)。
|
||||
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
|
||||
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN)以 query string 拼到 URL;
|
||||
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
|
||||
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
|
||||
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
|
||||
*
|
||||
* 网关/path/对接方案编码/单号类型 走 .env(JD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
|
||||
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
|
||||
*/
|
||||
final class JdLogisticsService
|
||||
{
|
||||
/** 轨迹行「时间」候选字段(按优先级) */
|
||||
private const TIME_FIELDS = [
|
||||
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
|
||||
'time', 'createTime', 'waybillStateTime', 'orderTime',
|
||||
];
|
||||
|
||||
/** 轨迹行「描述」候选字段(按优先级) */
|
||||
private const CONTEXT_FIELDS = [
|
||||
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
|
||||
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
|
||||
];
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// LOP 网关签名用 app_secret(md5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
|
||||
return !empty($cfg['enable'])
|
||||
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
|
||||
*
|
||||
* @return array{
|
||||
* traces: list<array{time:string,context:string,status:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* newest_unix: int
|
||||
* }|null
|
||||
*/
|
||||
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
|
||||
{
|
||||
$num = trim($waybillCode);
|
||||
if ($num === '' || !self::isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// 京东物流标准轨迹服务 /jd/tracking/query:body 为 JSON 数组 [{referenceNumber, referenceType, phone}]
|
||||
$row = [
|
||||
(string) ($cfg['reference_field'] ?? 'referenceNumber') => $num,
|
||||
'referenceType' => (string) ($cfg['reference_type'] ?? '20000'),
|
||||
];
|
||||
$tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4);
|
||||
if ($tail !== '') {
|
||||
$row['phone'] = $tail;
|
||||
}
|
||||
$customerCode = trim((string) ($cfg['customer_code'] ?? ''));
|
||||
if ($customerCode !== '') {
|
||||
$row['customerCode'] = $customerCode;
|
||||
}
|
||||
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$raw = self::request($cfg, (string) $body);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题
|
||||
$code = (string) ($json['code'] ?? $json['resultCode'] ?? '');
|
||||
if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) {
|
||||
Log::warning('JdLogisticsService lop error', [
|
||||
'num' => $num,
|
||||
'code' => $code,
|
||||
'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''),
|
||||
'raw' => mb_substr($raw, 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
// 兼容 JOS 网关层错误结构
|
||||
if (isset($json['error_response'])) {
|
||||
Log::warning('JdLogisticsService gateway error', [
|
||||
'num' => $num,
|
||||
'error' => $json['error_response'],
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = self::extractTraceRows($json);
|
||||
if ($rows === []) {
|
||||
Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$traces = self::normalizeRows($rows);
|
||||
if ($traces === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 时间倒序(最新在前),与快递100 输出一致
|
||||
usort($traces, static function (array $a, array $b): int {
|
||||
return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0);
|
||||
});
|
||||
|
||||
$newestUnix = (int) ($traces[0]['_unix'] ?? 0);
|
||||
$signed = false;
|
||||
foreach ($traces as $t) {
|
||||
if (self::looksSigned((string) $t['context'])) {
|
||||
$signed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state = $signed ? '3' : '0';
|
||||
|
||||
// 去掉内部辅助字段
|
||||
$clean = [];
|
||||
foreach ($traces as $t) {
|
||||
$clean[] = [
|
||||
'time' => (string) $t['time'],
|
||||
'context' => (string) $t['context'],
|
||||
'status' => (string) ($t['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'traces' => $clean,
|
||||
'state' => $state,
|
||||
'state_text' => $signed ? '已签收' : '在途',
|
||||
'source' => 'jd_official',
|
||||
'hint' => '',
|
||||
'newest_unix' => $newestUnix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
|
||||
*
|
||||
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
|
||||
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
|
||||
*
|
||||
* @param array<string,mixed> $cfg
|
||||
* @param string $body 业务参数 JSON 字符串(param_json)
|
||||
*/
|
||||
private static function request(array $cfg, string $body): ?string
|
||||
{
|
||||
$appKey = (string) ($cfg['app_key'] ?? '');
|
||||
$appSecret = (string) ($cfg['app_secret'] ?? '');
|
||||
$accessToken = (string) ($cfg['access_token'] ?? '');
|
||||
$path = (string) ($cfg['method'] ?? '/jd/tracking/query');
|
||||
$version = (string) ($cfg['api_version'] ?? '2.0');
|
||||
$algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt';
|
||||
$lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD');
|
||||
// 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8
|
||||
$now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai'));
|
||||
$timestamp = $now->format('Y-m-d H:i:s');
|
||||
|
||||
// 待签串:固定顺序拼接,首尾包 appSecret(method=接口path,param_json=业务体)
|
||||
$content = implode('', [
|
||||
$appSecret,
|
||||
'access_token', $accessToken,
|
||||
'app_key', $appKey,
|
||||
'method', $path,
|
||||
'param_json', $body,
|
||||
'timestamp', $timestamp,
|
||||
'v', $version,
|
||||
$appSecret,
|
||||
]);
|
||||
$sign = self::sign($algorithm, $content, $appSecret);
|
||||
if ($sign === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'LOP-DN' => $lopDn,
|
||||
'app_key' => $appKey,
|
||||
'access_token' => $accessToken,
|
||||
'timestamp' => $timestamp,
|
||||
'v' => $version,
|
||||
'sign' => $sign,
|
||||
'algorithm' => $algorithm,
|
||||
];
|
||||
|
||||
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
|
||||
$url = $base . $path . '?' . http_build_query($query);
|
||||
|
||||
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
|
||||
$offsetHours = (int) ($now->getOffset() / 3600);
|
||||
|
||||
return self::httpPostJson($url, $body, [
|
||||
'Content-Type: application/json;charset=utf-8',
|
||||
'User-Agent: lop-http/php',
|
||||
'lop-tz: ' . $offsetHours,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* LOP 网关签名(与官方 SDK Utils::sign 一致):
|
||||
* - md5-salt :md5(content) 的小写十六进制
|
||||
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512:base64(hmac(算法, content, appSecret))
|
||||
* 不支持的算法返回 null。
|
||||
*/
|
||||
private static function sign(string $algorithm, string $content, string $secret): ?string
|
||||
{
|
||||
switch (trim($algorithm)) {
|
||||
case 'md5-salt':
|
||||
return md5($content);
|
||||
case 'HMacMD5':
|
||||
return base64_encode(hash_hmac('md5', $content, $secret, true));
|
||||
case 'HMacSHA1':
|
||||
return base64_encode(hash_hmac('sha1', $content, $secret, true));
|
||||
case 'HMacSHA256':
|
||||
return base64_encode(hash_hmac('sha256', $content, $secret, true));
|
||||
case 'HMacSHA512':
|
||||
return base64_encode(hash_hmac('sha512', $content, $secret, true));
|
||||
default:
|
||||
Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。
|
||||
* 兼容字段被序列化成 JSON 字符串(如 querytrace_result 为 string)的情况。
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private static function extractTraceRows($node): array
|
||||
{
|
||||
$best = [];
|
||||
|
||||
$walk = function ($n) use (&$walk, &$best): void {
|
||||
if (is_string($n)) {
|
||||
$trimmed = trim($n);
|
||||
if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$walk($decoded);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (!is_array($n)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组
|
||||
if (self::isList($n)) {
|
||||
$rows = [];
|
||||
foreach ($n as $item) {
|
||||
if (is_array($item) && self::rowHasTraceFields($item)) {
|
||||
$rows[] = $item;
|
||||
}
|
||||
}
|
||||
if (count($rows) > count($best)) {
|
||||
$best = $rows;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($n as $v) {
|
||||
$walk($v);
|
||||
}
|
||||
};
|
||||
|
||||
$walk($node);
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
private static function rowHasTraceFields(array $row): bool
|
||||
{
|
||||
$hasTime = false;
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$hasTime = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasTime) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @return list<array{time:string,context:string,status:string,_unix:int}>
|
||||
*/
|
||||
private static function normalizeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$time = '';
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$time = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$context = '';
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$context = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($time === '' && $context === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unix = self::parseTimeToUnix($time);
|
||||
$out[] = [
|
||||
'time' => $time !== '' ? self::formatTime($time, $unix) : '',
|
||||
'context' => $context,
|
||||
'status' => '',
|
||||
'_unix' => $unix,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function parseTimeToUnix(string $time): int
|
||||
{
|
||||
$t = trim($time);
|
||||
if ($t === '') {
|
||||
return 0;
|
||||
}
|
||||
// 毫秒时间戳
|
||||
if (preg_match('/^\d{13}$/', $t)) {
|
||||
return (int) ((int) $t / 1000);
|
||||
}
|
||||
// 秒时间戳
|
||||
if (preg_match('/^\d{10}$/', $t)) {
|
||||
return (int) $t;
|
||||
}
|
||||
$p = strtotime($t);
|
||||
|
||||
return $p !== false ? (int) $p : 0;
|
||||
}
|
||||
|
||||
private static function formatTime(string $raw, int $unix): string
|
||||
{
|
||||
// 纯时间戳统一格式化成可读时间,便于落库/前端展示
|
||||
if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) {
|
||||
return date('Y-m-d H:i:s', $unix);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $arr
|
||||
*/
|
||||
private static function isList(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('array_is_list')) {
|
||||
return array_is_list($arr);
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
private static function looksSigned(string $hay): bool
|
||||
{
|
||||
if ($hay === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
|
||||
if (mb_stripos($hay, $neg) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (['签收', '妥投', '送达', '已放在'] as $k) {
|
||||
if (mb_stripos($hay, $k) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
*/
|
||||
private static function httpPostJson(string $url, string $body, array $headers): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($resp === false) {
|
||||
Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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\common\service;
|
||||
|
||||
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\lists\BaseDataLists;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use think\facade\Config;
|
||||
use think\Response;
|
||||
use think\response\Json;
|
||||
use think\exception\HttpResponseException;
|
||||
|
||||
class JsonService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 接口操作成功,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function success(string $msg = 'success', array $data = [], int $code = 1, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口操作失败,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回数据
|
||||
* @param $data
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function data($data): Json
|
||||
{
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回信息
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $httpStatus
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
private static function result(int $code, int $show, string $msg = 'OK', array $data = [], int $httpStatus = 200): Json
|
||||
{
|
||||
$result = compact('code', 'show', 'msg', 'data');
|
||||
return json($result, $httpStatus);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 抛出异常json
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function throw(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
$data = compact('code', 'show', 'msg', 'data');
|
||||
$response = Response::create($data, 'json', 200);
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 数据列表
|
||||
* @param \app\common\lists\BaseDataLists $lists
|
||||
* @return \think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/28 11:15
|
||||
*/
|
||||
public static function dataLists(BaseDataLists $lists): Json
|
||||
{
|
||||
//获取导出信息
|
||||
if ($lists->export == ExportEnum::INFO && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
|
||||
return self::data($lists->excelInfo());
|
||||
}
|
||||
|
||||
//获取导出文件的下载链接
|
||||
if ($lists->export == ExportEnum::EXPORT && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
$exportDownloadUrl = $lists->createExcel($lists->setExcelFields(), $lists->lists());
|
||||
|
||||
return self::success('', ['url' => $exportDownloadUrl], 2);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'lists' => $lists->lists(),
|
||||
'count' => $lists->count(),
|
||||
'page_no' => $lists->pageNo,
|
||||
'page_size' => $lists->pageSize,
|
||||
];
|
||||
$data['extend'] = [];
|
||||
if ($lists instanceof ListsExtendInterface) {
|
||||
$data['extend'] = $lists->extend();
|
||||
}
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 导出:拉数 + PhpSpreadsheet 易超过默认 max_execution_time=30
|
||||
*/
|
||||
private static function relaxLimitsForExcelExport(): void
|
||||
{
|
||||
@set_time_limit(0);
|
||||
$max = Config::get('project.lists.export_max_execution_time', 600);
|
||||
$max = is_numeric($max) ? (int) $max : 600;
|
||||
if ($max > 0) {
|
||||
@ini_set('max_execution_time', (string) $max);
|
||||
}
|
||||
$mem = Config::get('project.lists.export_memory_limit', '512M');
|
||||
if (is_string($mem) && $mem !== '') {
|
||||
@ini_set('memory_limit', $mem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 腾讯云IM UserSig生成类(官方算法)
|
||||
* 参考:https://github.com/tencentyun/tls-sig-api-v2-php
|
||||
*/
|
||||
class TLSSigAPIv2
|
||||
{
|
||||
private $sdkappid;
|
||||
private $key;
|
||||
|
||||
public function __construct($sdkappid, $key)
|
||||
{
|
||||
$this->sdkappid = $sdkappid;
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成UserSig
|
||||
* @param string $identifier 用户ID
|
||||
* @param int $expire 过期时间(秒)
|
||||
* @return string UserSig
|
||||
*/
|
||||
public function genUserSig($identifier, $expire = 86400)
|
||||
{
|
||||
return $this->__genSig($identifier, $expire, '', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带权限的UserSig
|
||||
* @param string $identifier 用户ID
|
||||
* @param int $expire 过期时间(秒)
|
||||
* @param string $userbuf 用户权限buffer
|
||||
* @return string UserSig
|
||||
*/
|
||||
public function genUserSigWithUserBuf($identifier, $expire, $userbuf)
|
||||
{
|
||||
return $this->__genSig($identifier, $expire, $userbuf, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方法:生成签名
|
||||
*/
|
||||
private function __genSig($identifier, $expire, $userbuf, $userbuf_enabled)
|
||||
{
|
||||
$current = time();
|
||||
$sigDoc = [
|
||||
'TLS.ver' => '2.0',
|
||||
'TLS.identifier' => strval($identifier),
|
||||
'TLS.sdkappid' => intval($this->sdkappid),
|
||||
'TLS.expire' => intval($expire),
|
||||
'TLS.time' => intval($current)
|
||||
];
|
||||
|
||||
$base64_userbuf = '';
|
||||
if ($userbuf_enabled) {
|
||||
$base64_userbuf = base64_encode($userbuf);
|
||||
$sigDoc['TLS.userbuf'] = $base64_userbuf;
|
||||
}
|
||||
|
||||
$sig = $this->hmacsha256($identifier, $current, $expire, $base64_userbuf, $userbuf_enabled);
|
||||
$sigDoc['TLS.sig'] = base64_encode($sig);
|
||||
|
||||
$json_text = json_encode($sigDoc);
|
||||
$compressed = gzcompress($json_text);
|
||||
|
||||
return $this->base64_url_encode($compressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成HMAC-SHA256签名
|
||||
*/
|
||||
private function hmacsha256($identifier, $curr_time, $expire, $base64_userbuf, $userbuf_enabled)
|
||||
{
|
||||
$content_to_be_signed = "TLS.identifier:" . $identifier . "\n"
|
||||
. "TLS.sdkappid:" . $this->sdkappid . "\n"
|
||||
. "TLS.time:" . $curr_time . "\n"
|
||||
. "TLS.expire:" . $expire . "\n";
|
||||
|
||||
if ($userbuf_enabled) {
|
||||
$content_to_be_signed .= "TLS.userbuf:" . $base64_userbuf . "\n";
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $content_to_be_signed, $this->key, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL安全编码
|
||||
*/
|
||||
private function base64_url_encode($input)
|
||||
{
|
||||
return str_replace(['+', '/', '='], ['*', '-', '_'], base64_encode($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL安全解码
|
||||
*/
|
||||
private function base64_url_decode($base64_url_string)
|
||||
{
|
||||
return base64_decode(str_replace(['*', '-', '_'], ['+', '/', '='], $base64_url_string));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证UserSig
|
||||
* @param string $sig UserSig
|
||||
* @param string $identifier 用户ID
|
||||
* @param int $init_time 初始化时间
|
||||
* @param int $expire_time 过期时间
|
||||
* @param string $userbuf 用户权限buffer
|
||||
* @param string $error_msg 错误信息
|
||||
* @return bool 是否有效
|
||||
*/
|
||||
public function verifySig($sig, $identifier, &$init_time, &$expire_time, &$userbuf, &$error_msg)
|
||||
{
|
||||
try {
|
||||
$error_msg = '';
|
||||
$compressed_sig = $this->base64_url_decode($sig);
|
||||
$pre_level = error_reporting(E_ERROR);
|
||||
$uncompressed_sig = gzuncompress($compressed_sig);
|
||||
error_reporting($pre_level);
|
||||
|
||||
if ($uncompressed_sig === false) {
|
||||
throw new \Exception('gzuncompress error');
|
||||
}
|
||||
|
||||
$sig_doc = json_decode($uncompressed_sig, true);
|
||||
if ($sig_doc === false) {
|
||||
throw new \Exception('json_decode error');
|
||||
}
|
||||
|
||||
if ($sig_doc['TLS.identifier'] !== $identifier) {
|
||||
throw new \Exception('identifier dosen\'t match');
|
||||
}
|
||||
|
||||
if ($sig_doc['TLS.sdkappid'] != $this->sdkappid) {
|
||||
throw new \Exception('sdkappid dosen\'t match');
|
||||
}
|
||||
|
||||
$sig = base64_decode($sig_doc['TLS.sig']);
|
||||
if ($sig === false) {
|
||||
throw new \Exception('sig base64_decode error');
|
||||
}
|
||||
|
||||
$init_time = $sig_doc['TLS.time'];
|
||||
$expire_time = $sig_doc['TLS.expire'];
|
||||
|
||||
$curr_time = time();
|
||||
if ($curr_time > $init_time + $expire_time) {
|
||||
throw new \Exception('sig expired');
|
||||
}
|
||||
|
||||
$userbuf_enabled = false;
|
||||
$base64_userbuf = '';
|
||||
if (isset($sig_doc['TLS.userbuf'])) {
|
||||
$base64_userbuf = $sig_doc['TLS.userbuf'];
|
||||
$userbuf = base64_decode($base64_userbuf);
|
||||
$userbuf_enabled = true;
|
||||
}
|
||||
|
||||
$sigCalculated = $this->hmacsha256($identifier, $init_time, $expire_time, $base64_userbuf, $userbuf_enabled);
|
||||
|
||||
if ($sig != $sigCalculated) {
|
||||
throw new \Exception('verify failed');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $ex) {
|
||||
$error_msg = $ex->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 腾讯云IM服务类
|
||||
* Class TencentImService
|
||||
* @package app\common\service
|
||||
*/
|
||||
class TencentImService
|
||||
{
|
||||
private $sdkAppId;
|
||||
private $secretKey;
|
||||
private $adminIdentifier = 'administrator'; // 管理员账号
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('project.trtc');
|
||||
$this->sdkAppId = $config['sdkAppId'];
|
||||
$this->secretKey = $config['secretKey'];
|
||||
|
||||
\think\facade\Log::info('TencentImService初始化 - sdkAppId: ' . $this->sdkAppId . ', secretKey长度: ' . strlen($this->secretKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 生成UserSig(使用腾讯云官方算法)
|
||||
* @param string $userId
|
||||
* @param int $expire
|
||||
* @return string|false
|
||||
*/
|
||||
private function generateUserSig(string $userId, int $expire = 86400)
|
||||
{
|
||||
try {
|
||||
$api = new TLSSigAPIv2($this->sdkAppId, $this->secretKey);
|
||||
$userSig = $api->genUserSig($userId, $expire);
|
||||
|
||||
\think\facade\Log::info('UserSig生成成功 - userId: ' . $userId . ', sig长度: ' . strlen($userSig));
|
||||
|
||||
return $userSig;
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('生成UserSig失败: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导入单个账号到IM
|
||||
* @param string $userId 用户ID
|
||||
* @param string $nick 昵称(可选)
|
||||
* @param string $faceUrl 头像URL(可选)
|
||||
* @return array|false
|
||||
*/
|
||||
public function importAccount(string $userId, string $nick = '', string $faceUrl = '')
|
||||
{
|
||||
try {
|
||||
\think\facade\Log::info('开始导入IM账号 - userId: ' . $userId . ', nick: ' . $nick . ', sdkAppId: ' . $this->sdkAppId);
|
||||
|
||||
// 生成管理员UserSig
|
||||
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
|
||||
|
||||
if (!$adminUserSig) {
|
||||
throw new \Exception('生成管理员UserSig失败');
|
||||
}
|
||||
|
||||
\think\facade\Log::info('管理员UserSig生成成功');
|
||||
|
||||
// 构建请求URL
|
||||
$random = rand(0, 4294967295);
|
||||
$url = sprintf(
|
||||
'https://console.tim.qq.com/v4/im_open_login_svc/account_import?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
|
||||
$this->sdkAppId,
|
||||
$this->adminIdentifier,
|
||||
urlencode($adminUserSig),
|
||||
$random
|
||||
);
|
||||
|
||||
\think\facade\Log::info('请求URL构建完成');
|
||||
|
||||
// 构建请求体
|
||||
$data = [
|
||||
'UserID' => $userId
|
||||
];
|
||||
|
||||
if ($nick) {
|
||||
$data['Nick'] = $nick;
|
||||
}
|
||||
|
||||
if ($faceUrl) {
|
||||
$data['FaceUrl'] = $faceUrl;
|
||||
}
|
||||
|
||||
\think\facade\Log::info('请求数据: ' . json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 发送请求
|
||||
$result = $this->httpPost($url, json_encode($data));
|
||||
|
||||
\think\facade\Log::info('HTTP响应: ' . substr($result, 0, 500));
|
||||
|
||||
if (!$result) {
|
||||
throw new \Exception('请求失败:无响应');
|
||||
}
|
||||
|
||||
$response = json_decode($result, true);
|
||||
|
||||
if (!$response) {
|
||||
throw new \Exception('响应解析失败:' . substr($result, 0, 200));
|
||||
}
|
||||
|
||||
\think\facade\Log::info('响应解析成功: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 检查响应状态
|
||||
if ($response['ActionStatus'] !== 'OK') {
|
||||
$errorMsg = sprintf(
|
||||
'导入账号失败 - ErrorCode: %s, ErrorInfo: %s',
|
||||
$response['ErrorCode'] ?? 'unknown',
|
||||
$response['ErrorInfo'] ?? '未知错误'
|
||||
);
|
||||
throw new \Exception($errorMsg);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'userId' => $userId,
|
||||
'message' => '账号导入成功'
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$errorMsg = sprintf(
|
||||
'导入IM账号失败 - userId: %s, error: %s',
|
||||
$userId,
|
||||
$e->getMessage()
|
||||
);
|
||||
\think\facade\Log::error($errorMsg);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'userId' => $userId,
|
||||
'message' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 批量导入账号
|
||||
* @param array $accounts 账号列表 [['userId' => 'xxx', 'nick' => 'xxx', 'faceUrl' => 'xxx'], ...]
|
||||
* @return array
|
||||
*/
|
||||
public function batchImportAccounts(array $accounts): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$userId = $account['userId'] ?? '';
|
||||
$nick = $account['nick'] ?? '';
|
||||
$faceUrl = $account['faceUrl'] ?? '';
|
||||
|
||||
if (!$userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->importAccount($userId, $nick, $faceUrl);
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除账号
|
||||
* @param array $userIds 用户ID列表
|
||||
* @return array|false
|
||||
*/
|
||||
public function deleteAccounts(array $userIds)
|
||||
{
|
||||
try {
|
||||
// 生成管理员UserSig
|
||||
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
|
||||
|
||||
if (!$adminUserSig) {
|
||||
throw new \Exception('生成管理员UserSig失败');
|
||||
}
|
||||
|
||||
// 构建请求URL
|
||||
$random = rand(0, 4294967295);
|
||||
$url = sprintf(
|
||||
'https://console.tim.qq.com/v4/im_open_login_svc/account_delete?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
|
||||
$this->sdkAppId,
|
||||
$this->adminIdentifier,
|
||||
urlencode($adminUserSig),
|
||||
$random
|
||||
);
|
||||
|
||||
// 构建请求体
|
||||
$data = [
|
||||
'DeleteItem' => array_map(function($userId) {
|
||||
return ['UserID' => $userId];
|
||||
}, $userIds)
|
||||
];
|
||||
|
||||
// 发送请求
|
||||
$result = $this->httpPost($url, json_encode($data));
|
||||
|
||||
if (!$result) {
|
||||
throw new \Exception('请求失败');
|
||||
}
|
||||
|
||||
$response = json_decode($result, true);
|
||||
|
||||
if (!$response) {
|
||||
throw new \Exception('响应解析失败');
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if ($response['ActionStatus'] !== 'OK') {
|
||||
throw new \Exception($response['ErrorInfo'] ?? '删除账号失败');
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '账号删除成功'
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('删除IM账号失败', [
|
||||
'userIds' => $userIds,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取单聊(C2C)漫游消息
|
||||
* @see https://cloud.tencent.com/document/product/269/2739
|
||||
*
|
||||
* @param string $operatorAccount 会话一方 UserID(如 doctor_1)
|
||||
* @param string $peerAccount 会话另一方 UserID(如 patient_2)
|
||||
* @return array{success:bool,msgList:array,complete:int,lastMsgKey:?string,lastMsgTime:?int,error:string,rawErrorCode:int}
|
||||
*/
|
||||
public function adminGetRoamMsg(
|
||||
string $operatorAccount,
|
||||
string $peerAccount,
|
||||
int $maxCnt = 100,
|
||||
int $minTime = 0,
|
||||
int $maxTime = 4294967295,
|
||||
?string $lastMsgKey = null,
|
||||
?int $lastMsgTime = null
|
||||
): array {
|
||||
$empty = [
|
||||
'success' => false,
|
||||
'msgList' => [],
|
||||
'complete' => 1,
|
||||
'lastMsgKey' => null,
|
||||
'lastMsgTime' => null,
|
||||
'error' => '',
|
||||
'rawErrorCode' => 0,
|
||||
];
|
||||
try {
|
||||
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
|
||||
if (!$adminUserSig) {
|
||||
$empty['error'] = '生成管理员UserSig失败';
|
||||
return $empty;
|
||||
}
|
||||
$random = rand(0, 4294967295);
|
||||
$url = sprintf(
|
||||
'https://console.tim.qq.com/v4/openim/admin_getroammsg?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
|
||||
$this->sdkAppId,
|
||||
$this->adminIdentifier,
|
||||
urlencode($adminUserSig),
|
||||
$random
|
||||
);
|
||||
$data = [
|
||||
'Operator_Account' => $operatorAccount,
|
||||
'Peer_Account' => $peerAccount,
|
||||
'MaxCnt' => $maxCnt,
|
||||
'MinTime' => $minTime,
|
||||
'MaxTime' => $maxTime,
|
||||
];
|
||||
if ($lastMsgKey !== null && $lastMsgKey !== '') {
|
||||
$data['LastMsgKey'] = $lastMsgKey;
|
||||
}
|
||||
if ($lastMsgTime !== null && $lastMsgTime > 0) {
|
||||
$data['LastMsgTime'] = $lastMsgTime;
|
||||
}
|
||||
// 增加超时时间到 60 秒
|
||||
$result = $this->httpPost($url, json_encode($data), 60);
|
||||
if (!$result) {
|
||||
$empty['error'] = 'IM接口无响应';
|
||||
return $empty;
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
if (!$response) {
|
||||
$empty['error'] = 'IM响应解析失败';
|
||||
return $empty;
|
||||
}
|
||||
$code = (int)($response['ErrorCode'] ?? -1);
|
||||
$empty['rawErrorCode'] = $code;
|
||||
if (($response['ActionStatus'] ?? '') !== 'OK') {
|
||||
$empty['error'] = $response['ErrorInfo'] ?? ('ErrorCode ' . $code);
|
||||
return $empty;
|
||||
}
|
||||
$msgList = $response['MsgList'] ?? [];
|
||||
if (!is_array($msgList)) {
|
||||
$msgList = [];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'msgList' => $msgList,
|
||||
'complete' => (int)($response['Complete'] ?? 1),
|
||||
'lastMsgKey' => $response['LastMsgKey'] ?? null,
|
||||
'lastMsgTime' => isset($response['LastMsgTime']) ? (int)$response['LastMsgTime'] : null,
|
||||
'error' => '',
|
||||
'rawErrorCode' => $code,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$empty['error'] = $e->getMessage();
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 发送HTTP POST请求
|
||||
* @param string $url
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
private function httpPost(string $url, string $data, int $timeout = 10)
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($data)
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
\think\facade\Log::error('HTTP请求失败', [
|
||||
'url' => $url,
|
||||
'error' => $error
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 腾讯云 TRTC 云端录制(CreateCloudRecording / DeleteCloudRecording)
|
||||
* 混流(合流)录制:在 RecordParams 中将 RecordMode 设为 2,并配合 MixLayoutParams / MixTranscodeParams。
|
||||
* 依赖:composer require tencentcloud/trtc
|
||||
*/
|
||||
class TrtcCloudRecordingService
|
||||
{
|
||||
/** RecordParams.RecordMode:混流录制(多路合成一个文件) */
|
||||
public const RECORD_MODE_MIX = 2;
|
||||
|
||||
/**
|
||||
* 在应用启动时调用,避免 Guzzle 首次 defaultCaBundle() 缓存错误路径(Windows 常见 cURL error 60)。
|
||||
* 也可在发起请求前再次调用(重复 ini_set 无害)。
|
||||
*/
|
||||
public static function applyRecordingSslCaBundleEarly(): void
|
||||
{
|
||||
$path = self::resolveRecordingCaBundlePath();
|
||||
if ($path === '') {
|
||||
if (\function_exists('putenv')) {
|
||||
@putenv('TRTC_RECORDING_SSL_CAFILE');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@ini_set('openssl.cafile', $path);
|
||||
@ini_set('curl.cainfo', $path);
|
||||
// 腾讯云 SDK 内 Guzzle 会忽略已缓存的 defaultCaBundle;通过环境变量让 HttpConnection 显式 verify
|
||||
if (\function_exists('putenv')) {
|
||||
@putenv('TRTC_RECORDING_SSL_CAFILE=' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolveRecordingCaBundlePath(): string
|
||||
{
|
||||
$raw = trim((string)config('trtc.recording_ssl_cafile', ''));
|
||||
$path = trim($raw, " \t\"'");
|
||||
if ($path !== '' && is_readable($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$root = rtrim((string)root_path(), '/\\');
|
||||
$candidate = $root . DIRECTORY_SEPARATOR . 'cacert.pem';
|
||||
|
||||
return is_readable($candidate) ? $candidate : '';
|
||||
}
|
||||
|
||||
public static function sdkAvailable(): bool
|
||||
{
|
||||
return class_exists(\TencentCloud\Trtc\V20190722\TrtcClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $vodUserDefineRecordId 云点播文件名前缀(仅字母数字下划线连字符,≤64),便于与控制台单流区分;混流文件会带此前缀
|
||||
* @return array{ok:bool,task_id?:string,message?:string}
|
||||
*/
|
||||
public static function startMixRecording(
|
||||
int $sdkAppId,
|
||||
string $roomId,
|
||||
string $botUserId,
|
||||
string $botUserSig,
|
||||
?string $vodUserDefineRecordId = null
|
||||
): array {
|
||||
if (!self::sdkAvailable()) {
|
||||
return ['ok' => false, 'message' => '未安装 tencentcloud/trtc,请在 server 目录执行 composer update'];
|
||||
}
|
||||
$secretId = trim((string)config('trtc.api_secret_id', ''));
|
||||
$secretKey = trim((string)config('trtc.api_secret_key', ''));
|
||||
if ($secretId === '' || $secretKey === '') {
|
||||
return ['ok' => false, 'message' => '未配置 trtc.api_secret_id / trtc.api_secret_key(CAM 密钥)'];
|
||||
}
|
||||
if ($sdkAppId <= 0) {
|
||||
return ['ok' => false, 'message' => 'SdkAppId 无效'];
|
||||
}
|
||||
if (self::trtcUserSigSecretKey() === '') {
|
||||
return ['ok' => false, 'message' => '未配置 TRTC UserSig 密钥(project.trtc.secretKey)'];
|
||||
}
|
||||
|
||||
$roomIdType = self::resolveRecordingRoomIdType((string)$roomId);
|
||||
$region = (string)config('trtc.recording_api_region', 'ap-guangzhou');
|
||||
|
||||
try {
|
||||
self::applyRecordingSslCaBundleEarly();
|
||||
$cred = new \TencentCloud\Common\Credential($secretId, $secretKey);
|
||||
$httpProfile = new \TencentCloud\Common\Profile\HttpProfile();
|
||||
$httpProfile->setEndpoint('trtc.tencentcloudapi.com');
|
||||
$clientProfile = new \TencentCloud\Common\Profile\ClientProfile();
|
||||
$clientProfile->setHttpProfile($httpProfile);
|
||||
$client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile);
|
||||
|
||||
$req = new \TencentCloud\Trtc\V20190722\Models\CreateCloudRecordingRequest();
|
||||
$req->SdkAppId = $sdkAppId;
|
||||
$req->RoomId = (string)$roomId;
|
||||
$req->RoomIdType = $roomIdType;
|
||||
$req->UserId = $botUserId;
|
||||
$req->UserSig = $botUserSig;
|
||||
|
||||
$recordParams = new \TencentCloud\Trtc\V20190722\Models\RecordParams();
|
||||
$recordParams->RecordMode = self::RECORD_MODE_MIX;
|
||||
$recordParams->StreamType = 0;
|
||||
$recordParams->MaxIdleTime = (int)config('trtc.recording_max_idle_time', 300);
|
||||
// COS 存储时 OutputFormat 决定输出格式:0=hls(默认)、1=hls+mp4、3=mp4。
|
||||
// 310 回调仅在产出 MP4 时触发;默认 hls 不会生成 MP4 → 永远收不到 310。
|
||||
$recordParams->OutputFormat = (int)config('trtc.recording_output_format', 3);
|
||||
// 不订阅录制机器人自身流,避免占混流画面;医患流仍默认全订阅
|
||||
$subscribe = new \TencentCloud\Trtc\V20190722\Models\SubscribeStreamUserIds();
|
||||
$subscribe->UnSubscribeAudioUserIds = [$botUserId];
|
||||
$subscribe->UnSubscribeVideoUserIds = [$botUserId];
|
||||
$recordParams->SubscribeStreamUserIds = $subscribe;
|
||||
$req->RecordParams = $recordParams;
|
||||
|
||||
// 使用 COS 对象存储
|
||||
$cloudStorage = new \TencentCloud\Trtc\V20190722\Models\CloudStorage();
|
||||
$cloudStorage->Vendor = (int)config('trtc.recording_cos_vendor', 0);
|
||||
$cosRegion = trim((string)config('trtc.recording_cos_region', ''));
|
||||
$cloudStorage->Region = $cosRegion !== '' ? $cosRegion : $region;
|
||||
$cloudStorage->Bucket = trim((string)config('trtc.recording_cos_bucket', ''));
|
||||
$cosAk = trim((string)config('trtc.recording_cos_access_key', ''));
|
||||
$cosSk = trim((string)config('trtc.recording_cos_secret_key', ''));
|
||||
$cloudStorage->AccessKey = $cosAk !== '' ? $cosAk : $secretId;
|
||||
$cloudStorage->SecretKey = $cosSk !== '' ? $cosSk : $secretKey;
|
||||
|
||||
$prefix = self::sanitizeVodUserDefineRecordId($vodUserDefineRecordId);
|
||||
if ($prefix === '') {
|
||||
$prefix = rtrim((string)config('trtc.recording_cos_prefix', 'trtc-recording'), '/');
|
||||
}
|
||||
$cloudStorage->FileNamePrefix = $prefix !== '' ? explode('/', $prefix) : [];
|
||||
|
||||
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
|
||||
$storage->CloudStorage = $cloudStorage;
|
||||
$req->StorageParams = $storage;
|
||||
|
||||
// MixTranscodeParams:SDK 说明「若设置该参数则内部字段须填全」。仅填 VideoParams 未填 AudioParams 可能导致合流异常或退化为非预期行为
|
||||
$videoParams = new \TencentCloud\Trtc\V20190722\Models\VideoParams();
|
||||
$videoParams->Width = (int)config('trtc.recording_mix_width', 1280);
|
||||
$videoParams->Height = (int)config('trtc.recording_mix_height', 720);
|
||||
$videoParams->Fps = (int)config('trtc.recording_mix_fps', 15);
|
||||
$videoParams->BitRate = (int)config('trtc.recording_mix_video_bitrate', 1500000);
|
||||
$videoParams->Gop = (int)config('trtc.recording_mix_gop', 2);
|
||||
|
||||
$audioParams = new \TencentCloud\Trtc\V20190722\Models\AudioParams();
|
||||
$audioParams->SampleRate = (int)config('trtc.recording_mix_audio_sample_rate', 1);
|
||||
$audioParams->Channel = (int)config('trtc.recording_mix_audio_channel', 2);
|
||||
$audioParams->BitRate = (int)config('trtc.recording_mix_audio_bitrate', 64000);
|
||||
|
||||
$mixTc = new \TencentCloud\Trtc\V20190722\Models\MixTranscodeParams();
|
||||
$mixTc->VideoParams = $videoParams;
|
||||
$mixTc->AudioParams = $audioParams;
|
||||
$req->MixTranscodeParams = $mixTc;
|
||||
|
||||
$mixLayout = new \TencentCloud\Trtc\V20190722\Models\MixLayoutParams();
|
||||
$layoutMode = (int)config('trtc.recording_mix_layout_mode', 3);
|
||||
if ($layoutMode < 1 || $layoutMode > 4) {
|
||||
$layoutMode = 3;
|
||||
}
|
||||
$mixLayout->MixLayoutMode = $layoutMode;
|
||||
$req->MixLayoutParams = $mixLayout;
|
||||
|
||||
$usedAk = (string)$cloudStorage->AccessKey;
|
||||
Log::info('CreateCloudRecording mix (COS)', [
|
||||
'sdkAppId' => $sdkAppId,
|
||||
'roomId' => $roomId,
|
||||
'roomIdType' => $roomIdType,
|
||||
'recordMode' => self::RECORD_MODE_MIX,
|
||||
'outputFormat' => $recordParams->OutputFormat,
|
||||
'mixLayoutMode' => $layoutMode,
|
||||
'bucket' => $cloudStorage->Bucket,
|
||||
'region' => $cloudStorage->Region,
|
||||
'fileNamePrefix' => implode('/', $cloudStorage->FileNamePrefix ?: []),
|
||||
'cosAkSource' => $cosAk !== '' ? 'cos_config' : 'api_secret_id',
|
||||
'cosAkPrefix' => substr($usedAk, 0, 8) . '***',
|
||||
]);
|
||||
|
||||
$resp = $client->CreateCloudRecording($req);
|
||||
$taskId = $resp->TaskId ?? '';
|
||||
if ($taskId === '') {
|
||||
return ['ok' => false, 'message' => 'CreateCloudRecording 未返回 TaskId'];
|
||||
}
|
||||
|
||||
self::logDescribeCloudRecordingHint($client, $sdkAppId, $taskId, (string)$roomId, $roomIdType);
|
||||
|
||||
return ['ok' => true, 'task_id' => $taskId];
|
||||
} catch (\Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
if (str_contains($msg, 'SSL certificate problem') || str_contains($msg, 'error 60')) {
|
||||
$msg .= ';请下载 https://curl.se/ca/cacert.pem 保存到本机,在 .env [trtc] 设置 RECORDING_SSL_CAFILE=绝对路径,或在 php.ini 配置 openssl.cafile / curl.cainfo';
|
||||
}
|
||||
Log::error('CreateCloudRecording failed: ' . $msg);
|
||||
|
||||
return ['ok' => false, 'message' => $msg];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,message?:string}
|
||||
*/
|
||||
public static function stopRecording(int $sdkAppId, string $taskId): array
|
||||
{
|
||||
if ($taskId === '') {
|
||||
return ['ok' => true];
|
||||
}
|
||||
if (!self::sdkAvailable()) {
|
||||
return ['ok' => false, 'message' => '未安装 tencentcloud/trtc'];
|
||||
}
|
||||
$secretId = trim((string)config('trtc.api_secret_id', ''));
|
||||
$secretKey = trim((string)config('trtc.api_secret_key', ''));
|
||||
if ($secretId === '' || $secretKey === '') {
|
||||
return ['ok' => false, 'message' => '未配置云 API 密钥'];
|
||||
}
|
||||
$region = (string)config('trtc.recording_api_region', 'ap-guangzhou');
|
||||
|
||||
try {
|
||||
self::applyRecordingSslCaBundleEarly();
|
||||
$cred = new \TencentCloud\Common\Credential($secretId, $secretKey);
|
||||
$httpProfile = new \TencentCloud\Common\Profile\HttpProfile();
|
||||
$httpProfile->setEndpoint('trtc.tencentcloudapi.com');
|
||||
$clientProfile = new \TencentCloud\Common\Profile\ClientProfile();
|
||||
$clientProfile->setHttpProfile($httpProfile);
|
||||
$client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile);
|
||||
|
||||
$req = new \TencentCloud\Trtc\V20190722\Models\DeleteCloudRecordingRequest();
|
||||
$req->SdkAppId = $sdkAppId;
|
||||
$req->TaskId = $taskId;
|
||||
$client->DeleteCloudRecording($req);
|
||||
|
||||
return ['ok' => true];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('DeleteCloudRecording: ' . $e->getMessage());
|
||||
|
||||
return ['ok' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public static function trtcSdkAppId(): int
|
||||
{
|
||||
$p = config('project.trtc');
|
||||
|
||||
return (int)(is_array($p) ? ($p['sdkAppId'] ?? 0) : 0) ?: (int)config('trtc.sdkAppId', 0);
|
||||
}
|
||||
|
||||
public static function trtcUserSigSecretKey(): string
|
||||
{
|
||||
$p = config('project.trtc');
|
||||
$fromProject = is_array($p) ? (string)($p['secretKey'] ?? '') : '';
|
||||
|
||||
return $fromProject !== '' ? $fromProject : (string)config('trtc.secretKey', '');
|
||||
}
|
||||
|
||||
public static function makeBotUserSig(string $botUserId): string
|
||||
{
|
||||
$sdkAppId = self::trtcSdkAppId();
|
||||
$key = self::trtcUserSigSecretKey();
|
||||
$api = new TLSSigAPIv2($sdkAppId, $key);
|
||||
$expire = (int)(config('project.trtc.expireTime') ?? config('trtc.expireTime', 86400));
|
||||
|
||||
return $api->genUserSig($botUserId, $expire > 0 ? $expire : 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomIdType 必须与通话实际房间类型一致,否则录制进错房或只能录到单路(见 CreateCloudRecording RoomIdType 说明)
|
||||
*/
|
||||
public static function resolveRecordingRoomIdType(string $roomId): int
|
||||
{
|
||||
$cfg = trim((string)config('trtc.recording_room_id_type', ''));
|
||||
if ($cfg !== '' && strtolower($cfg) !== 'auto') {
|
||||
return ((int)$cfg) === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
return ctype_digit($roomId) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* COS 存储路径前缀:仅 a-zA-Z0-9_-/
|
||||
*/
|
||||
private static function sanitizeVodUserDefineRecordId(?string $raw): string
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return '';
|
||||
}
|
||||
$s = preg_replace('/[^a-zA-Z0-9_\/-]/', '', $raw) ?? '';
|
||||
|
||||
return strlen($s) > 64 ? substr($s, 0, 64) : $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* COS 存储时 StorageFile 结构不同于 VOD。
|
||||
* CreateCloudRecording 后立刻查询常为 Idle,约 2s 后再查一次再下结论。
|
||||
*/
|
||||
private static function logDescribeCloudRecordingHint(
|
||||
\TencentCloud\Trtc\V20190722\TrtcClient $client,
|
||||
int $sdkAppId,
|
||||
string $taskId,
|
||||
string $roomId,
|
||||
int $roomIdType
|
||||
): void {
|
||||
try {
|
||||
$snap = function () use ($client, $sdkAppId, $taskId, $roomId, $roomIdType): array {
|
||||
$dreq = new \TencentCloud\Trtc\V20190722\Models\DescribeCloudRecordingRequest();
|
||||
$dreq->SdkAppId = $sdkAppId;
|
||||
$dreq->TaskId = $taskId;
|
||||
$dresp = $client->DescribeCloudRecording($dreq);
|
||||
$list = $dresp->StorageFileList ?? [];
|
||||
$firstUid = '';
|
||||
if (is_array($list) && isset($list[0]) && $list[0] instanceof \TencentCloud\Trtc\V20190722\Models\StorageFile) {
|
||||
$firstUid = (string)($list[0]->UserId ?? '');
|
||||
}
|
||||
|
||||
return [
|
||||
'taskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
'roomIdType' => $roomIdType,
|
||||
'status' => (string)($dresp->Status ?? ''),
|
||||
'storageFileCount' => is_array($list) ? count($list) : 0,
|
||||
'firstFileUserId' => $firstUid,
|
||||
];
|
||||
};
|
||||
|
||||
$first = $snap();
|
||||
if (strcasecmp($first['status'], 'Idle') === 0) {
|
||||
sleep(2);
|
||||
$second = $snap();
|
||||
if (strcasecmp($second['status'], 'Idle') !== 0) {
|
||||
Log::info('DescribeCloudRecording(合流校验-COS): 首次 Idle,约2s 后已非 Idle', $second + [
|
||||
'hint' => '启动瞬间 Idle 属常见;COS 文件以实际上传为准',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
Log::warning(
|
||||
'DescribeCloudRecording: 约2s 后仍为 Idle(未拉到流:多因 RoomIdType 与客户端房间类型不一致,或房内无上推)',
|
||||
$second
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('DescribeCloudRecording(合流校验-COS)', $first + [
|
||||
'hint' => 'COS 存储场景,文件将直接上传到对象存储桶',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('DescribeCloudRecording 跳过: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?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\common\service;
|
||||
|
||||
|
||||
use app\common\enum\FileEnum;
|
||||
use app\common\model\file\File;
|
||||
use app\common\service\storage\Driver as StorageDriver;
|
||||
use Exception;
|
||||
|
||||
|
||||
class UploadService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 上传图片
|
||||
* @param $cid
|
||||
* @param int $user_id
|
||||
* @param string $saveDir
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2021/12/29 16:30
|
||||
*/
|
||||
public static function image($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/images')
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'default' => ConfigService::get('storage', 'default', 'local'),
|
||||
'engine' => ConfigService::get('storage') ?? ['local'=>[]],
|
||||
];
|
||||
|
||||
// 2、执行文件上传
|
||||
$StorageDriver = new StorageDriver($config);
|
||||
$StorageDriver->setUploadFile('file');
|
||||
$fileName = $StorageDriver->getFileName();
|
||||
$fileInfo = $StorageDriver->getFileInfo();
|
||||
|
||||
// 校验上传文件后缀
|
||||
if (!in_array(strtolower($fileInfo['ext']), config('project.file_image'))) {
|
||||
throw new Exception("上传图片不允许上传". $fileInfo['ext'] . "文件");
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
$saveDir = self::getUploadUrl($saveDir);
|
||||
if (!$StorageDriver->upload($saveDir)) {
|
||||
throw new Exception($StorageDriver->getError());
|
||||
}
|
||||
|
||||
// 3、处理文件名称
|
||||
if (strlen($fileInfo['name']) > 128) {
|
||||
$name = substr($fileInfo['name'], 0, 123);
|
||||
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name'])-5, strlen($fileInfo['name']));
|
||||
$fileInfo['name'] = $name . $nameEnd;
|
||||
}
|
||||
|
||||
// 4、写入数据库中
|
||||
$file = File::create([
|
||||
'cid' => $cid,
|
||||
'type' => FileEnum::IMAGE_TYPE,
|
||||
'name' => $fileInfo['name'],
|
||||
'uri' => $saveDir . '/' . str_replace("\\","/", $fileName),
|
||||
'source' => $source,
|
||||
'source_id' => $sourceId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
// 5、返回结果
|
||||
return [
|
||||
'id' => $file['id'],
|
||||
'cid' => $file['cid'],
|
||||
'type' => $file['type'],
|
||||
'name' => $file['name'],
|
||||
'uri' => FileService::getFileUrl($file['uri']),
|
||||
'url' => FileService::getFileUrl($file['uri'])
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 视频上传
|
||||
* @param $cid
|
||||
* @param int $user_id
|
||||
* @param string $saveDir
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2021/12/29 16:32
|
||||
*/
|
||||
public static function video($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/video')
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'default' => ConfigService::get('storage', 'default', 'local'),
|
||||
'engine' => ConfigService::get('storage') ?? ['local'=>[]],
|
||||
];
|
||||
|
||||
// 2、执行文件上传
|
||||
$StorageDriver = new StorageDriver($config);
|
||||
$StorageDriver->setUploadFile('file');
|
||||
$fileName = $StorageDriver->getFileName();
|
||||
$fileInfo = $StorageDriver->getFileInfo();
|
||||
|
||||
// 校验上传文件后缀
|
||||
if (!in_array(strtolower($fileInfo['ext']), config('project.file_video'))) {
|
||||
throw new Exception("上传视频不允许上传". $fileInfo['ext'] . "文件");
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
$saveDir = self::getUploadUrl($saveDir);
|
||||
if (!$StorageDriver->upload($saveDir)) {
|
||||
throw new Exception($StorageDriver->getError());
|
||||
}
|
||||
|
||||
// 3、处理文件名称
|
||||
if (strlen($fileInfo['name']) > 128) {
|
||||
$name = substr($fileInfo['name'], 0, 123);
|
||||
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name'])-5, strlen($fileInfo['name']));
|
||||
$fileInfo['name'] = $name . $nameEnd;
|
||||
}
|
||||
|
||||
// 4、写入数据库中
|
||||
$file = File::create([
|
||||
'cid' => $cid,
|
||||
'type' => FileEnum::VIDEO_TYPE,
|
||||
'name' => $fileInfo['name'],
|
||||
'uri' => $saveDir . '/' . str_replace("\\","/", $fileName),
|
||||
'source' => $source,
|
||||
'source_id' => $sourceId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
// 5、返回结果
|
||||
return [
|
||||
'id' => $file['id'],
|
||||
'cid' => $file['cid'],
|
||||
'type' => $file['type'],
|
||||
'name' => $file['name'],
|
||||
'uri' => FileService::getFileUrl($file['uri']),
|
||||
'url' => $file['uri']
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 上传文件
|
||||
* @param $cid
|
||||
* @param int $sourceId
|
||||
* @param int $source
|
||||
* @param string $saveDir
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @author dw
|
||||
* @date 2023/06/26
|
||||
*/
|
||||
public static function file($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/file')
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'default' => ConfigService::get('storage', 'default', 'local'),
|
||||
'engine' => ConfigService::get('storage') ?? [ 'local' => [] ],
|
||||
];
|
||||
|
||||
// 2、执行文件上传
|
||||
$StorageDriver = new StorageDriver($config);
|
||||
$StorageDriver->setUploadFile('file');
|
||||
$fileName = $StorageDriver->getFileName();
|
||||
$fileInfo = $StorageDriver->getFileInfo();
|
||||
|
||||
// 校验上传文件后缀
|
||||
if (!in_array(strtolower($fileInfo['ext']), config('project.file_file'))) {
|
||||
throw new Exception("上传文件不允许上传" . $fileInfo['ext'] . "文件");
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
$saveDir = self::getUploadUrl($saveDir);
|
||||
if (!$StorageDriver->upload($saveDir)) {
|
||||
throw new Exception($StorageDriver->getError());
|
||||
}
|
||||
|
||||
// 3、处理文件名称
|
||||
if (strlen($fileInfo['name']) > 128) {
|
||||
$name = substr($fileInfo['name'], 0, 123);
|
||||
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name']) - 5, strlen($fileInfo['name']));
|
||||
$fileInfo['name'] = $name . $nameEnd;
|
||||
}
|
||||
|
||||
// 4、写入数据库中
|
||||
$file = File::create([
|
||||
'cid' => $cid,
|
||||
'type' => FileEnum::FILE_TYPE,
|
||||
'name' => $fileInfo['name'],
|
||||
'uri' => $saveDir . '/' . str_replace("\\", "/", $fileName),
|
||||
'source' => $source,
|
||||
'source_id' => $sourceId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
// 5、返回结果
|
||||
return [
|
||||
'id' => $file['id'],
|
||||
'cid' => $file['cid'],
|
||||
'type' => $file['type'],
|
||||
'name' => $file['name'],
|
||||
'uri' => FileService::getFileUrl($file['uri']),
|
||||
'url' => $file['uri']
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 上传地址
|
||||
* @param $saveDir
|
||||
* @return string
|
||||
* @author dw
|
||||
* @date 2023/06/26
|
||||
*/
|
||||
private static function getUploadUrl($saveDir):string
|
||||
{
|
||||
return $saveDir . '/' . date('Ymd');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\doctor;
|
||||
|
||||
use Overtrue\Pinyin\Pinyin;
|
||||
|
||||
/**
|
||||
* 药材名称 → 拼音首字母连写(小写),供列表检索;未安装 overtrue/pinyin 时返回空字符串。
|
||||
*/
|
||||
class MedicineNameAbbrService
|
||||
{
|
||||
public static function build(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
if (!class_exists(Pinyin::class)) {
|
||||
return '';
|
||||
}
|
||||
$abbr = (string) Pinyin::abbr($name)->join('');
|
||||
|
||||
return strtolower($abbr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\doctor;
|
||||
|
||||
/**
|
||||
* 排班时段:起止时间 + 号源间隔
|
||||
*/
|
||||
class RosterSegmentService
|
||||
{
|
||||
/**
|
||||
* 从排班记录解析 [开始 HH:mm, 结束 HH:mm],无效返回 null
|
||||
*/
|
||||
public static function resolveWindow(array $roster): ?array
|
||||
{
|
||||
$start = isset($roster['start_time']) ? trim((string) $roster['start_time']) : '';
|
||||
$end = isset($roster['end_time']) ? trim((string) $roster['end_time']) : '';
|
||||
|
||||
if ($start !== '' && $end !== ''
|
||||
&& preg_match('/^\d{2}:\d{2}$/', $start)
|
||||
&& preg_match('/^\d{2}:\d{2}$/', $end)
|
||||
&& $start < $end) {
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
$p = $roster['period'] ?? '';
|
||||
|
||||
if ($p == 1 || $p === 'morning' || $p === '上午') {
|
||||
return ['09:00', '12:00'];
|
||||
}
|
||||
if ($p == 2 || $p === 'afternoon' || $p === '下午') {
|
||||
return ['14:00', '18:00'];
|
||||
}
|
||||
if ($p === 'night' || $p === '夜班' || $p === 'evening') {
|
||||
return ['18:00', '22:00'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function normalizeSlotMinutes($raw): int
|
||||
{
|
||||
$n = (int) $raw;
|
||||
if ($n < 5) {
|
||||
return 15;
|
||||
}
|
||||
if ($n > 120) {
|
||||
return 120;
|
||||
}
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 左闭右开 [start, end) 按 slotMinutes 切分,得到每个号的开始时刻 HH:mm
|
||||
*/
|
||||
public static function generateSlotTimes(string $start, string $end, int $slotMinutes): array
|
||||
{
|
||||
$slotMinutes = self::normalizeSlotMinutes($slotMinutes);
|
||||
$base = '1970-01-01 ';
|
||||
$cur = strtotime($base . $start . ':00');
|
||||
$endTs = strtotime($base . $end . ':00');
|
||||
if ($cur === false || $endTs === false || $endTs <= $cur) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$times = [];
|
||||
$step = $slotMinutes * 60;
|
||||
while ($cur < $endTs) {
|
||||
$times[] = date('H:i', $cur);
|
||||
$cur += $step;
|
||||
}
|
||||
|
||||
return $times;
|
||||
}
|
||||
|
||||
/**
|
||||
* quota>0 时限制最大可约槽位数;quota=0 表示不限制(由时段长度决定)
|
||||
*/
|
||||
public static function applyQuotaCap(array $times, int $quota): array
|
||||
{
|
||||
if ($quota > 0 && count($times) > $quota) {
|
||||
return array_slice($times, 0, $quota);
|
||||
}
|
||||
|
||||
return $times;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
use app\common\model\ExpressQueryLog;
|
||||
use app\common\model\ExpressStateLog;
|
||||
use app\common\model\ExpressTrace;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 甘草 SCM「获取物流路由信息」(GET_TASK_ROUTE_LIST)
|
||||
*
|
||||
* 入参:app_order_no = 甘草处方订单号 recipel_order_no(不是我方 order_no),get_all=1
|
||||
* 出参 result.route_list[] = [{accept_time, remark, route_type, route_type_name}]
|
||||
*
|
||||
* 路由状态(route_type):
|
||||
* 0 任务创建
|
||||
* 10 已下单(待揽件)
|
||||
* 11 已揽件
|
||||
* 12 转运中
|
||||
* 13 已送达
|
||||
* 20 取消订单
|
||||
* 21 派件异常
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#%E8%8E%B7%E5%8F%96%E7%89%A9%E6%B5%81%E8%B7%AF%E7%94%B1%E4%BF%A1%E6%81%AF
|
||||
*/
|
||||
final class GancaoLogisticsRouteService
|
||||
{
|
||||
/**
|
||||
* 路由状态 → 系统物流状态(与 ExpressTracking 常量对齐)
|
||||
*/
|
||||
private const ROUTE_TYPE_TO_STATE = [
|
||||
0 => ExpressTracking::STATE_IN_TRANSIT, // 任务创建 → 在途
|
||||
10 => ExpressTracking::STATE_IN_TRANSIT, // 已下单待揽件 → 在途
|
||||
11 => ExpressTracking::STATE_COLLECTED, // 已揽件 → 揽收
|
||||
12 => ExpressTracking::STATE_IN_TRANSIT, // 转运中 → 在途
|
||||
13 => ExpressTracking::STATE_SIGNED, // 已送达 → 签收
|
||||
20 => ExpressTracking::STATE_RETURN_SIGNED, // 取消订单 → 退签
|
||||
21 => ExpressTracking::STATE_PROBLEM, // 派件异常 → 疑难
|
||||
];
|
||||
|
||||
private const ROUTE_TYPE_NAMES = [
|
||||
0 => '任务创建',
|
||||
10 => '已下单(待揽件)',
|
||||
11 => '已揽件',
|
||||
12 => '转运中',
|
||||
13 => '已送达',
|
||||
20 => '取消订单',
|
||||
21 => '派件异常',
|
||||
];
|
||||
|
||||
/**
|
||||
* 拉取并落库单个订单的物流路由
|
||||
*
|
||||
* @param PrescriptionOrder $order
|
||||
* @return array{success:bool, message:string, traces_count:int, state:string, raw?:array, assistant_sync?:array|null}
|
||||
*/
|
||||
public static function syncOne(PrescriptionOrder $order): array
|
||||
{
|
||||
$appOrderNo = trim((string) ($order->gancao_reciperl_order_no ?? ''));
|
||||
if ($appOrderNo === '') {
|
||||
return ['success' => false, 'message' => '订单未关联甘草处方单号', 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$resp = self::callRouteApi($appOrderNo);
|
||||
if (!$resp['success']) {
|
||||
self::logQuery($order, '', 'auto', false, (int) ($resp['runtime_ms'] ?? 0), $resp['message']);
|
||||
if (self::shouldFallbackToKuaidi100($resp)) {
|
||||
return self::syncOneViaKuaidi100($order, (string) $resp['message']);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $resp['message'], 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$result = $resp['result'];
|
||||
$apiTracking = trim((string) ($result['sp_order_no'] ?? ''));
|
||||
$localTracking = trim((string) ($order->tracking_number ?? ''));
|
||||
// 业务订单已录快递单号时:不改用甘草返回的 sp_order_no,轨迹仍写入本地单号对应的运单记录
|
||||
if ($localTracking !== '') {
|
||||
$trackingNumber = mb_substr($localTracking, 0, 80);
|
||||
} else {
|
||||
$trackingNumber = mb_substr($apiTracking, 0, 80);
|
||||
}
|
||||
$spName = trim((string) ($result['sp_name'] ?? ($order->gancao_shipping_name ?? '')));
|
||||
$routeList = is_array($result['route_list'] ?? null) ? $result['route_list'] : [];
|
||||
|
||||
if ($trackingNumber === '') {
|
||||
self::logQuery($order, '', 'auto', false, $resp['runtime_ms'], '甘草未返回 sp_order_no(快递单号),可能尚未发货');
|
||||
if (trim((string) ($order->tracking_number ?? '')) !== '') {
|
||||
return self::syncOneViaKuaidi100($order, '甘草未返回快递单号,业务单已填写运单号');
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => '尚未发货(无快递单号)', 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$tracking = self::ensureTracking($order, $trackingNumber, $spName);
|
||||
|
||||
// 回写订单的快递单号 / 物流公司(如果之前空着)+ 签收时升级 fulfillment_status
|
||||
$orderDirty = false;
|
||||
if ($localTracking === '') {
|
||||
$order->tracking_number = mb_substr($trackingNumber, 0, 80);
|
||||
$orderDirty = true;
|
||||
}
|
||||
if ($spName !== '' && trim((string) ($order->gancao_shipping_name ?? '')) === '') {
|
||||
$order->gancao_shipping_name = mb_substr($spName, 0, 50);
|
||||
$orderDirty = true;
|
||||
}
|
||||
|
||||
$tracesCount = self::saveRoutes($tracking, $routeList);
|
||||
$newState = self::resolveCurrentState($routeList);
|
||||
self::updateTrackingHead($tracking, $newState, $routeList, $result);
|
||||
|
||||
// 签收 → 订单 fulfillment_status = 6(已签收),仅在处于发货/履约中阶段时升级,避免覆盖已完成/已取消/已签收
|
||||
if ($newState === ExpressTracking::STATE_SIGNED) {
|
||||
$currentFs = (int) ($order->fulfillment_status ?? 0);
|
||||
if (in_array($currentFs, [1, 2, 5], true)) {
|
||||
$order->fulfillment_status = 6;
|
||||
$orderDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orderDirty) {
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: backfill order failed: ' . $e->getMessage(), ['order_id' => $order->id]);
|
||||
}
|
||||
}
|
||||
|
||||
self::logQuery($order, $trackingNumber, 'auto', true, $resp['runtime_ms'], '', count($routeList));
|
||||
|
||||
$assistantSync = ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'source' => 'gancao_route_sync',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'ok',
|
||||
'traces_count' => $tracesCount,
|
||||
'state' => $newState,
|
||||
'source' => 'gancao',
|
||||
'raw' => $result,
|
||||
'assistant_sync' => $assistantSync,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草侧无快递任务 / 路由拉取失败时,按业务单运单号降级快递100(自发货)
|
||||
*
|
||||
* @return array{success:bool, message:string, traces_count:int, state:string, source?:string, assistant_sync?:array|null}
|
||||
*/
|
||||
private static function syncOneViaKuaidi100(PrescriptionOrder $order, string $gancaoReason): array
|
||||
{
|
||||
$ret = ExpressTrackingService::queryKuaidiForPrescriptionOrder($order, true);
|
||||
if (!$ret['success']) {
|
||||
$ret['message'] = trim((string) $ret['message']) . ';甘草:' . mb_substr($gancaoReason, 0, 200);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否因甘草「快递任务」类错误降级走快递100
|
||||
*
|
||||
* @param array{success?:bool, message?:string, api_code?:string} $gancaoResp
|
||||
*/
|
||||
private static function shouldFallbackToKuaidi100(array $gancaoResp): bool
|
||||
{
|
||||
$code = trim((string) ($gancaoResp['api_code'] ?? ''));
|
||||
if ($code === '10101') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$msg = (string) ($gancaoResp['message'] ?? '');
|
||||
if ($msg === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$needles = ['快递任务', '任务id不存在', '任务不存在', '无快递任务', '物流任务'];
|
||||
foreach ($needles as $needle) {
|
||||
if (mb_strpos($msg, $needle) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用甘草 GET_TASK_ROUTE_LIST 接口
|
||||
*
|
||||
* @return array{success:bool, message:string, result?:array, runtime_ms:int}
|
||||
*/
|
||||
private static function callRouteApi(string $appOrderNo): array
|
||||
{
|
||||
$token = GancaoScmRecipelService::getToken();
|
||||
if ($token === null || $token === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '获取 token 失败:' . GancaoScmRecipelService::getLastGetTokenError(),
|
||||
'runtime_ms' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'token' => $token,
|
||||
'app_order_no' => $appOrderNo,
|
||||
'get_all' => 1,
|
||||
'package' => 'igc_scm.logistics.client_opt.pull',
|
||||
'class' => 'GET_TASK_ROUTE_LIST',
|
||||
];
|
||||
|
||||
$start = microtime(true);
|
||||
$ret = self::transport()->post($payload);
|
||||
$runtimeMs = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
if ((int) ($ret['state'] ?? 0) !== 1) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '通信失败:' . (string) ($ret['msg'] ?? ''),
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
$body = $ret['body'] ?? [];
|
||||
if (!GancaoScmRecipelService::isApiSuccess($body)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'API 错误:' . GancaoScmRecipelService::apiStatusMessage($body),
|
||||
'api_code' => (string) ($body['status']['code'] ?? ''),
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
$result = is_array($body['result'] ?? null) ? $body['result'] : [];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'ok',
|
||||
'result' => $result,
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
|
||||
private static function transport(): GancaoOpenApiTransport
|
||||
{
|
||||
$c = \think\facade\Config::get('gancao_scm', []);
|
||||
|
||||
return new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证 zyt_express_tracking 主记录存在
|
||||
*/
|
||||
private static function ensureTracking(PrescriptionOrder $order, string $trackingNumber, string $spName): ExpressTracking
|
||||
{
|
||||
$tracking = ExpressTracking::where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
$now = time();
|
||||
|
||||
if (!$tracking) {
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->tracking_number = $trackingNumber;
|
||||
$tracking->create_time = $now;
|
||||
$tracking->next_update_time = $now;
|
||||
}
|
||||
|
||||
$tracking->order_id = (int) $order->id;
|
||||
$tracking->order_type = 'prescription';
|
||||
$tracking->express_company = self::expressCodeFromGancaoName($spName) ?: 'auto';
|
||||
$tracking->express_company_name = $spName !== '' ? mb_substr($spName, 0, 100) : (string) $tracking->express_company_name;
|
||||
$tracking->recipient_phone = (string) ($order->recipient_phone ?? '');
|
||||
$tracking->recipient_name = (string) ($order->recipient_name ?? '');
|
||||
$tracking->recipient_address = (string) ($order->shipping_address ?? '');
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把甘草的 route_list 写入 zyt_express_trace(按 (tracking_id, trace_time, trace_context) 去重)
|
||||
*/
|
||||
private static function saveRoutes(ExpressTracking $tracking, array $routeList): int
|
||||
{
|
||||
$inserted = 0;
|
||||
foreach ($routeList as $route) {
|
||||
$time = trim((string) ($route['accept_time'] ?? ''));
|
||||
$remark = trim((string) ($route['remark'] ?? ''));
|
||||
if ($time === '' && $remark === '') {
|
||||
continue;
|
||||
}
|
||||
$routeType = (int) ($route['route_type'] ?? -1);
|
||||
$routeName = trim((string) ($route['route_type_name'] ?? '')) ?: (self::ROUTE_TYPE_NAMES[$routeType] ?? '');
|
||||
|
||||
$exists = ExpressTrace::where('tracking_id', $tracking->id)
|
||||
->where('trace_time', $time)
|
||||
->where('trace_context', $remark)
|
||||
->count();
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$model = new ExpressTrace();
|
||||
$model->tracking_id = (int) $tracking->id;
|
||||
$model->tracking_number = (string) $tracking->tracking_number;
|
||||
$model->trace_time = mb_substr($time, 0, 50);
|
||||
$model->trace_time_stamp = $time !== '' ? (strtotime($time) ?: time()) : time();
|
||||
$model->trace_context = mb_substr($remark, 0, 1000);
|
||||
$model->status = mb_substr($routeName, 0, 50);
|
||||
$model->status_code = (string) $routeType;
|
||||
$model->location = '';
|
||||
$model->extra_data = json_encode($route, JSON_UNESCAPED_UNICODE);
|
||||
$model->create_time = time();
|
||||
$model->save();
|
||||
$inserted++;
|
||||
}
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用最新的 route 更新 tracking 头部(current_state、最新轨迹、签收等)
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $routeList 原始路由数组
|
||||
* @param array<string, mixed> $result 完整 result(含 t_status 等)
|
||||
*/
|
||||
private static function updateTrackingHead(ExpressTracking $tracking, string $newState, array $routeList, array $result): void
|
||||
{
|
||||
$now = time();
|
||||
$oldState = (string) $tracking->current_state;
|
||||
|
||||
$tracking->current_state = $newState !== '' ? $newState : (string) $tracking->current_state;
|
||||
$tracking->current_state_text = ExpressTracking::getStateText($tracking->current_state);
|
||||
$tracking->data_source = 'gancao';
|
||||
$tracking->last_query_time = $now;
|
||||
$tracking->query_count = (int) $tracking->query_count + 1;
|
||||
|
||||
$latest = self::pickLatestRoute($routeList);
|
||||
if ($latest !== null) {
|
||||
$tracking->latest_trace_time = mb_substr((string) ($latest['accept_time'] ?? ''), 0, 50);
|
||||
$tracking->latest_trace_context = mb_substr((string) ($latest['remark'] ?? ''), 0, 500);
|
||||
$tracking->latest_location = '';
|
||||
}
|
||||
|
||||
$tracking->route_info = json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if (ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$tracking->is_signed = 1;
|
||||
$tracking->sign_time = $latest && !empty($latest['accept_time']) ? (strtotime((string) $latest['accept_time']) ?: $now) : $now;
|
||||
$tracking->auto_update = 0;
|
||||
}
|
||||
|
||||
if ((int) $tracking->auto_update === 1 && !ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$interval = (int) ($tracking->update_interval ?: 1800);
|
||||
$tracking->next_update_time = $now + $interval;
|
||||
}
|
||||
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
if ($oldState !== '' && $oldState !== $tracking->current_state) {
|
||||
$log = new ExpressStateLog();
|
||||
$log->tracking_id = (int) $tracking->id;
|
||||
$log->tracking_number = (string) $tracking->tracking_number;
|
||||
$log->old_state = $oldState;
|
||||
$log->old_state_text = ExpressTracking::getStateText($oldState);
|
||||
$log->new_state = (string) $tracking->current_state;
|
||||
$log->new_state_text = (string) $tracking->current_state_text;
|
||||
$log->change_time = $now;
|
||||
$log->change_reason = (string) $tracking->latest_trace_context;
|
||||
$log->create_time = $now;
|
||||
try {
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: state log save failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 route_list 中时间最新的一条
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $routeList
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function pickLatestRoute(array $routeList): ?array
|
||||
{
|
||||
if (empty($routeList)) {
|
||||
return null;
|
||||
}
|
||||
usort($routeList, function ($a, $b) {
|
||||
$ta = strtotime((string) ($a['accept_time'] ?? '')) ?: 0;
|
||||
$tb = strtotime((string) ($b['accept_time'] ?? '')) ?: 0;
|
||||
return $tb <=> $ta;
|
||||
});
|
||||
return $routeList[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据所有路由确定当前最终状态:取出现过的 route_type 中最高优先级
|
||||
*/
|
||||
private static function resolveCurrentState(array $routeList): string
|
||||
{
|
||||
$hasSigned = false;
|
||||
$hasProblem = false;
|
||||
$hasCancel = false;
|
||||
$hasCollected = false;
|
||||
$hasInTransit = false;
|
||||
foreach ($routeList as $r) {
|
||||
$t = (int) ($r['route_type'] ?? -1);
|
||||
if ($t === 13) $hasSigned = true;
|
||||
elseif ($t === 21) $hasProblem = true;
|
||||
elseif ($t === 20) $hasCancel = true;
|
||||
elseif ($t === 11) $hasCollected = true;
|
||||
elseif (in_array($t, [0, 10, 12], true)) $hasInTransit = true;
|
||||
}
|
||||
if ($hasSigned) return ExpressTracking::STATE_SIGNED;
|
||||
if ($hasCancel) return ExpressTracking::STATE_RETURN_SIGNED;
|
||||
if ($hasProblem) return ExpressTracking::STATE_PROBLEM;
|
||||
if ($hasCollected) return ExpressTracking::STATE_COLLECTED;
|
||||
if ($hasInTransit) return ExpressTracking::STATE_IN_TRANSIT;
|
||||
return ExpressTracking::STATE_IN_TRANSIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草 sp_name → 系统 express_company 编码(与 GancaoCallbackController::EXPRESS_MAP 对齐)
|
||||
*/
|
||||
private static function expressCodeFromGancaoName(string $name): string
|
||||
{
|
||||
$map = [
|
||||
'顺丰' => 'sf',
|
||||
'京东' => 'jd',
|
||||
'极兔' => 'jt',
|
||||
'圆通' => 'yt',
|
||||
'中通' => 'zt',
|
||||
'韵达' => 'yd',
|
||||
'申通' => 'st',
|
||||
'邮政' => 'yz',
|
||||
'EMS' => 'ems',
|
||||
];
|
||||
foreach ($map as $kw => $code) {
|
||||
if ($name !== '' && mb_strpos($name, $kw) !== false) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条 zyt_express_query_log(沿用现有结构,便于在管理后台统一查询)
|
||||
*/
|
||||
private static function logQuery(
|
||||
PrescriptionOrder $order,
|
||||
string $trackingNumber,
|
||||
string $queryType,
|
||||
bool $success,
|
||||
int $runtimeMs,
|
||||
string $errMsg,
|
||||
int $traceCount = 0
|
||||
): void {
|
||||
try {
|
||||
$log = new ExpressQueryLog();
|
||||
$log->tracking_number = $trackingNumber !== '' ? $trackingNumber : (string) ($order->tracking_number ?? '');
|
||||
$log->express_company = (string) ($order->express_company ?? 'auto');
|
||||
$log->query_type = $queryType;
|
||||
$log->query_source = 'gancao';
|
||||
$log->query_time = time();
|
||||
$log->is_success = $success ? 1 : 0;
|
||||
$log->error_code = $success ? '' : '500';
|
||||
$log->error_message = mb_substr($errMsg, 0, 500);
|
||||
$log->response_time = $runtimeMs;
|
||||
$log->trace_count = $traceCount;
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: query log save failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步:选取候选订单(已上传甘草、且未完成/未签收/未取消的)
|
||||
*
|
||||
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
|
||||
*/
|
||||
public static function syncBatch(int $limit = 100, ?int $onlyOrderId = null): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'assistant_cleared' => 0,
|
||||
'assistant_skipped_assign_log' => 0,
|
||||
'assistant_lines' => [],
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
$q = PrescriptionOrder::whereNull('delete_time')
|
||||
->where('gancao_reciperl_order_no', '<>', '');
|
||||
|
||||
if ($onlyOrderId !== null && $onlyOrderId > 0) {
|
||||
$q->where('id', $onlyOrderId);
|
||||
} else {
|
||||
// 仅同步未完成/未取消、且甘草已进入物流或更晚阶段的(state 110 / 20 / 30 / 90)
|
||||
// state 含义见 GancaoCallbackController::STATE_MAP
|
||||
$q->where(function ($w) {
|
||||
$w->whereIn('gancao_order_state', [110, 20, 30, 90])
|
||||
->whereOr('tracking_number', '<>', '');
|
||||
});
|
||||
// 排除已完成(3)、已取消(4)、已签收(6)
|
||||
$q->whereNotIn('fulfillment_status', [3, 4, 6]);
|
||||
}
|
||||
|
||||
$orders = $q->order('id', 'desc')->limit($limit)->select();
|
||||
foreach ($orders as $order) {
|
||||
self::appendSyncOneResult($stats, $order);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>} $stats
|
||||
*/
|
||||
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
|
||||
{
|
||||
$fs = (int) ($order->fulfillment_status ?? 0);
|
||||
if (in_array($fs, [3, 6], true)) {
|
||||
$stats['skipped']++;
|
||||
return;
|
||||
}
|
||||
|
||||
$stats['total']++;
|
||||
try {
|
||||
$r = self::syncOne($order);
|
||||
$detail = [
|
||||
'order_id' => (int) $order->id,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'app_order_no' => (string) $order->gancao_reciperl_order_no,
|
||||
'tracking_number' => (string) $order->tracking_number,
|
||||
'success' => $r['success'],
|
||||
'message' => $r['message'],
|
||||
'traces' => $r['traces_count'],
|
||||
'state' => $r['state'],
|
||||
'source' => $r['source'] ?? 'gancao',
|
||||
];
|
||||
if ($r['success']) {
|
||||
$stats['success']++;
|
||||
if (!empty($r['assistant_sync']) && is_array($r['assistant_sync'])) {
|
||||
$sync = $r['assistant_sync'];
|
||||
$act = (string) ($sync['action'] ?? '');
|
||||
if ($act === 'cleared') {
|
||||
$stats['assistant_cleared']++;
|
||||
$channel = (($r['source'] ?? '') === 'kuaidi100_fallback') ? '快递100降级' : '甘草路由';
|
||||
$stats['assistant_lines'][] = sprintf(
|
||||
'[移除医助+指派日志][%s] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
||||
$channel,
|
||||
(int) ($sync['diagnosis_id'] ?? 0),
|
||||
(int) ($sync['prescription_order_id'] ?? 0),
|
||||
(string) ($sync['tracking_number'] ?? ''),
|
||||
(string) ($sync['former_assistant_id'] ?? ''),
|
||||
(int) ($sync['fulfillment_status'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
$stats['details'][] = $detail;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
$stats['details'][] = [
|
||||
'order_id' => (int) $order->id,
|
||||
'success' => false,
|
||||
'message' => '异常:' . $e->getMessage(),
|
||||
];
|
||||
Log::error('Gancao route sync exception: ' . $e->getMessage(), [
|
||||
'order_id' => (int) $order->id,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
/**
|
||||
* 甘草开放平台网关传输(AES-128-ECB + HTTP 头),对齐官方 GcOpenApi.php。
|
||||
*/
|
||||
final class GancaoOpenApiTransport
|
||||
{
|
||||
private string $url;
|
||||
|
||||
private string $ak;
|
||||
|
||||
private string $sk;
|
||||
|
||||
private string $userAgent;
|
||||
|
||||
public function __construct(string $url, string $ak, string $sk, string $userAgent = 'zyt-admin/1.0')
|
||||
{
|
||||
$this->url = rtrim($url, '/');
|
||||
$this->ak = $ak;
|
||||
$this->sk = $sk;
|
||||
$this->userAgent = $userAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload 已含 package、class 及业务字段
|
||||
* @return array{state:int,msg:string,body?:array<string,mixed>,response?:string}
|
||||
*/
|
||||
public function post(array $payload): array
|
||||
{
|
||||
// 与甘草网关规范一致:签名/头里的时间戳须与 body 内业务字段 timestamp(若有)一致,避免跨秒不一致导致签名校验失败
|
||||
$sigTs = time();
|
||||
if (isset($payload['timestamp']) && is_numeric($payload['timestamp'])) {
|
||||
$sigTs = (int) $payload['timestamp'];
|
||||
}
|
||||
if ($sigTs <= 0) {
|
||||
$sigTs = time();
|
||||
}
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return ['state' => 0, 'msg' => 'json_encode 失败'];
|
||||
}
|
||||
$noise = self::randStr(8);
|
||||
$signature = sha1($json . $sigTs . $noise . $this->sk);
|
||||
$cipher = self::encrypt($json, $this->sk);
|
||||
if ($cipher === '') {
|
||||
return ['state' => 0, 'msg' => 'AES 加密失败'];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Connection: close',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'Content-length: ' . strlen($cipher),
|
||||
'Cache-Control: no-cache',
|
||||
'AK: ' . $this->ak,
|
||||
'Signature: ' . $signature,
|
||||
'UTC-Timestamp: ' . $sigTs,
|
||||
'NOISE: ' . $noise,
|
||||
'Expect:',
|
||||
];
|
||||
|
||||
// cURL 常量在部分精简构建里可能未注册,统一用 defined() 做优雅降级,值取自官方枚举
|
||||
$httpVer11 = defined('CURL_HTTP_VERSION_1_1') ? CURL_HTTP_VERSION_1_1 : 2;
|
||||
$ipv4Only = defined('CURL_IPRESOLVE_V4') ? CURL_IPRESOLVE_V4 : 1;
|
||||
|
||||
$ch = curl_init($this->url);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, $httpVer11); // 改用 HTTP/1.1
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_ENCODING, 'gzip');
|
||||
curl_setopt($ch, CURLOPT_IPRESOLVE, $ipv4Only);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 增加连接超时
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $cipher);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
if (str_starts_with($this->url, 'https:')) {
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 改为 0,完全禁用主机验证
|
||||
// 强制 TLS 1.2;常量值为 6,但部分 PHP/cURL 构建未注册该常量,运行时未定义时退化为默认 TLS
|
||||
$tls12 = defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6;
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, $tls12);
|
||||
// 可选 cipher list。默认不设(交给 cURL 自带默认,兼容性最好)。
|
||||
// 需要兼容弱加密服务器时,在 config/gancao_scm.php 或 .env 里设:
|
||||
// GANCAO_SCM_TLS_CIPHERS="DEFAULT@SECLEVEL=1" // 仅 OpenSSL 构建的 cURL 支持此语法
|
||||
// GANCAO_SCM_TLS_CIPHERS="DEFAULT:!aNULL:!eNULL" // 通用写法
|
||||
// cURL 是 LibreSSL/NSS/GnuTLS/BoringSSL 时,@SECLEVEL= 会直接报 CURLE_SSL_CIPHER(59)。
|
||||
$cipherList = trim((string) \think\facade\Config::get('gancao_scm.tls_ciphers', ''));
|
||||
if ($cipherList !== '') {
|
||||
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, $cipherList);
|
||||
}
|
||||
}
|
||||
// 添加 TCP keepalive(部分旧 libcurl 可能没注册 CURLOPT_TCP_KEEPALIVE,守一下)
|
||||
if (defined('CURLOPT_TCP_KEEPALIVE')) {
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$info = curl_getinfo($ch);
|
||||
$curlErr = curl_errno($ch);
|
||||
$curlMsg = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false || (int) ($info['http_code'] ?? 0) !== 200) {
|
||||
$http = (int) ($info['http_code'] ?? 0);
|
||||
$err = is_string($raw) ? $raw : '';
|
||||
|
||||
return [
|
||||
'state' => 0,
|
||||
'msg' => '通信失败:HTTP ' . $http . ($curlErr !== 0 ? ' curl#' . $curlErr . ' ' . $curlMsg : ''),
|
||||
'response' => $err,
|
||||
];
|
||||
}
|
||||
|
||||
$bodyRaw = substr($raw, strpos($raw, "\r\n\r\n") + 4);
|
||||
$plain = self::decrypt($bodyRaw, $this->sk);
|
||||
if ($plain === '') {
|
||||
$maybeJson = json_decode($bodyRaw, true);
|
||||
if (is_array($maybeJson) && isset($maybeJson['status'])) {
|
||||
return ['state' => 1, 'msg' => '成功(明文)', 'body' => $maybeJson];
|
||||
}
|
||||
|
||||
return ['state' => -1, 'msg' => '解密失败(请核对网关 SK 是否为 16 位且与 AK 匹配)', 'response' => mb_substr($bodyRaw, 0, 500)];
|
||||
}
|
||||
|
||||
$decoded = json_decode($plain, true);
|
||||
|
||||
return ['state' => 1, 'msg' => '成功', 'body' => is_array($decoded) ? $decoded : []];
|
||||
}
|
||||
|
||||
private static function encrypt(string $string, string $key): string
|
||||
{
|
||||
$out = openssl_encrypt($string, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||
|
||||
return $out !== false ? base64_encode($out) : '';
|
||||
}
|
||||
|
||||
private static function decrypt(string $string, string $key): string
|
||||
{
|
||||
$bin = base64_decode($string, true);
|
||||
if ($bin === false) {
|
||||
return '';
|
||||
}
|
||||
$out = openssl_decrypt($bin, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||
|
||||
return $out !== false ? $out : '';
|
||||
}
|
||||
|
||||
private static function randStr(int $length = 8): string
|
||||
{
|
||||
$chars = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890';
|
||||
$chars = str_shuffle($chars);
|
||||
$end = strlen($chars) - 1;
|
||||
$buf = [];
|
||||
while (true) {
|
||||
$c = $chars[random_int(0, $end)];
|
||||
if ($c !== '0') {
|
||||
$buf[] = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$n = 1;
|
||||
while ($n < $length) {
|
||||
$r = $chars[random_int(0, $end)];
|
||||
if ($r !== $buf[count($buf) - 1]) {
|
||||
$buf[] = $r;
|
||||
++$n;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('', $buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
use app\common\model\doctor\Medicine;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 甘草 SCM 处方:MAKE_TOKEN、CTM_PREVIEW、CTM_SUBMIT_RECIPEL。
|
||||
*/
|
||||
final class GancaoScmRecipelService
|
||||
{
|
||||
private const CACHE_KEY = 'gancao_scm_api_token';
|
||||
|
||||
private static string $lastGetTokenError = '';
|
||||
|
||||
public static function getLastGetTokenError(): string
|
||||
{
|
||||
return self::$lastGetTokenError;
|
||||
}
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return self::whyNotConfigured() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 未就绪时返回中文原因(多条用分号分隔),就绪返回空串。
|
||||
*/
|
||||
public static function whyNotConfigured(): string
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
if (empty($c['enabled'])) {
|
||||
return '未启用:请在 .env 顶层或 [GANCAO_SCM] 中设置 GANCAO_SCM_ENABLED=true(勿写在 [trtc] 等分区内,否则会变成 TRTC_GANCAO_SCM_* 读不到)';
|
||||
}
|
||||
$need = ['gateway_url', 'gateway_ak', 'gateway_sk', 'biz_ak', 'biz_sk', 'callback_url'];
|
||||
$labels = [
|
||||
'gateway_url' => 'GANCAO_SCM_GATEWAY_URL',
|
||||
'gateway_ak' => 'GANCAO_SCM_GATEWAY_AK',
|
||||
'gateway_sk' => 'GANCAO_SCM_GATEWAY_SK',
|
||||
'biz_ak' => 'GANCAO_SCM_BIZ_AK(可留空则与网关 AK 相同)',
|
||||
'biz_sk' => 'GANCAO_SCM_BIZ_SK(可留空则与网关 SK 相同)',
|
||||
'callback_url' => 'GANCAO_SCM_CALLBACK_URL(须 https,甘草订单状态回调)',
|
||||
];
|
||||
$miss = [];
|
||||
foreach ($need as $k) {
|
||||
if (trim((string) ($c[$k] ?? '')) === '') {
|
||||
$miss[] = $labels[$k] ?? $k;
|
||||
}
|
||||
}
|
||||
|
||||
return $miss === [] ? '' : '缺少或未配置:' . implode(';', $miss);
|
||||
}
|
||||
|
||||
public static function apiStatusMessage(?array $body): string
|
||||
{
|
||||
|
||||
if (!is_array($body)) {
|
||||
return '响应异常';
|
||||
}
|
||||
|
||||
$code = (string) ($body['status']['code'] ?? '');
|
||||
$msg = $body['status']['msg'] ?? '';
|
||||
|
||||
// 确保 msg 是字符串
|
||||
if (is_array($msg)) {
|
||||
$msg = json_encode($msg, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$msg = (string) $msg;
|
||||
}
|
||||
|
||||
return $code !== '' ? "[{$code}] {$msg}" : ($msg !== '' ? $msg : '未知错误');
|
||||
}
|
||||
|
||||
public static function isApiSuccess(?array $body): bool
|
||||
{
|
||||
return is_array($body) && (string) ($body['status']['code'] ?? '') === '00000';
|
||||
}
|
||||
|
||||
public static function getToken(bool $forceRefresh = false): ?string
|
||||
{
|
||||
self::$lastGetTokenError = '';
|
||||
if (!self::isConfigured()) {
|
||||
self::$lastGetTokenError = self::whyNotConfigured();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$forceRefresh) {
|
||||
$cached = Cache::get(self::CACHE_KEY);
|
||||
if (is_string($cached) && strlen($cached) >= 10) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$transport = new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
$ts = time();
|
||||
$bizAk = (string) $c['biz_ak'];
|
||||
$bizSk = (string) $c['biz_sk'];
|
||||
$gwAk = (string) $c['gateway_ak'];
|
||||
$gwSk = (string) $c['gateway_sk'];
|
||||
$pwd = md5($ts . $bizSk);
|
||||
$ret = $transport->post([
|
||||
'ak' => $bizAk,
|
||||
'timestamp' => $ts,
|
||||
'pwd' => $pwd,
|
||||
'package' => 'igc_scm.ops.api.auth',
|
||||
'class' => 'MAKE_TOKEN',
|
||||
]);
|
||||
|
||||
if ((int) ($ret['state'] ?? 0) !== 1) {
|
||||
$hint = (string) ($ret['msg'] ?? '');
|
||||
$tail = isset($ret['response']) ? mb_substr((string) $ret['response'], 0, 200) : '';
|
||||
self::$lastGetTokenError = '甘草网关通信失败:' . $hint . ($tail !== '' ? ';响应片段:' . $tail : '');
|
||||
Log::warning('Gancao MAKE_TOKEN transport failed', ['msg' => $hint, 'ret' => $ret]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = $ret['body'] ?? [];
|
||||
|
||||
if (!self::isApiSuccess($body)) {
|
||||
$apiMsg = self::apiStatusMessage($body);
|
||||
$code = (string) ($body['status']['code'] ?? '');
|
||||
self::$lastGetTokenError = 'MAKE_TOKEN 失败:' . $apiMsg;
|
||||
|
||||
// 确保 $apiMsg 是字符串,避免 Array to string conversion 错误
|
||||
$apiMsgStr = is_string($apiMsg) ? $apiMsg : json_encode($apiMsg, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($code === '10103' || str_contains($apiMsgStr, '10103')) {
|
||||
self::$lastGetTokenError .= '。多为「业务 ak/sk」与 pwd=md5(时间戳+业务sk) 不匹配:请在 .env 配置与网关 OpenAPI 不同的 GANCAO_SCM_BIZ_AK、GANCAO_SCM_BIZ_SK(甘草控制台「业务账号」)。若业务与网关确为同一套,再检查 BIZ 是否与网关一致。';
|
||||
}
|
||||
if ($code === '10101' || str_contains($apiMsgStr, '10101')) {
|
||||
self::$lastGetTokenError .= '。请核对 GANCAO_SCM_GATEWAY_AK 与甘草分配的 OpenAPI 网关账号一致。';
|
||||
}
|
||||
if ($bizAk === $gwAk && $bizSk === $gwSk) {
|
||||
self::$lastGetTokenError .= ' 当前 BIZ 与网关相同;若仍失败,请向甘草索取独立的业务层 ak/sk 并填入 GANCAO_SCM_BIZ_AK / GANCAO_SCM_BIZ_SK。';
|
||||
}
|
||||
Log::warning('Gancao MAKE_TOKEN api error', ['body' => $body, 'apiMsg' => $apiMsg]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = (string) ($body['result']['token'] ?? '');
|
||||
|
||||
if ($token === '') {
|
||||
self::$lastGetTokenError = 'MAKE_TOKEN 返回无 token 字段';
|
||||
|
||||
return null;
|
||||
}
|
||||
Cache::set(self::CACHE_KEY, $token, 50 * 60);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从医师药品库 `doctor_medicine`(name + gid)解析甘草药材 id,仅 status=1 且未删除。
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $herbs 处方 herbs
|
||||
* @return array<string, int> 药材名 => 甘草 id
|
||||
*/
|
||||
/**
|
||||
* 手机号脱敏处理
|
||||
*
|
||||
* @param string $phone 手机号
|
||||
* @return string 脱敏后的手机号(如:138****0000)
|
||||
*/
|
||||
public static function maskPhone(string $phone): string
|
||||
{
|
||||
$phone = trim($phone);
|
||||
if (strlen($phone) !== 11) {
|
||||
return $phone;
|
||||
}
|
||||
return substr($phone, 0, 3) . '****' . substr($phone, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏数据用于日志记录
|
||||
*
|
||||
* @param array<string,mixed> $data 原始数据
|
||||
* @return array<string,mixed> 脱敏后的数据
|
||||
*/
|
||||
public static function maskSensitiveData(array $data): array
|
||||
{
|
||||
$masked = $data;
|
||||
|
||||
// 脱敏手机号字段
|
||||
$phoneFields = ['phone', 'recipient_phone', 'patient_phone', 'doctor_phone'];
|
||||
foreach ($phoneFields as $field) {
|
||||
if (isset($masked[$field]) && is_string($masked[$field])) {
|
||||
$masked[$field] = self::maskPhone($masked[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理嵌套数组
|
||||
foreach ($masked as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$masked[$key] = self::maskSensitiveData($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $masked;
|
||||
}
|
||||
|
||||
public static function doctorMedicineGidMapForHerbs(array $herbs): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$n = trim((string) ($h['name'] ?? ''));
|
||||
if ($n !== '') {
|
||||
$names[] = $n;
|
||||
}
|
||||
}
|
||||
$names = array_values(array_unique($names));
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Medicine::whereNull('delete_time')
|
||||
->where('status', 1)
|
||||
->whereIn('name', $names)
|
||||
->column('gid', 'name');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach ($rows as $nameKey => $gidRaw) {
|
||||
$nameKey = trim((string) $nameKey);
|
||||
$g = trim((string) $gidRaw);
|
||||
if ($nameKey === '' || $g === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\d+$/', $g)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) $g;
|
||||
if ($id > 0) {
|
||||
$map[$nameKey] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $herbs
|
||||
* @param array<string, int|string> $nameToIdMap 药材名 => 甘草 id(config herb_id_map)
|
||||
* @param array<string, int> $doctorNameToGid 医师库 name => gid(甘草)
|
||||
* @return array{0: list<array{id:int,name:string,quantity:float|string,brief:string}>, 1: list<string>} [m_list, missing_names]
|
||||
*/
|
||||
public static function buildMList(array $herbs, array $nameToIdMap, array $doctorNameToGid = []): array
|
||||
{
|
||||
$mList = [];
|
||||
$missing = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($h['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($h['gc_id'] ?? $h['gancao_id'] ?? 0);
|
||||
if ($id <= 0 && isset($nameToIdMap[$name])) {
|
||||
$id = (int) $nameToIdMap[$name];
|
||||
}
|
||||
if ($id <= 0 && isset($doctorNameToGid[$name])) {
|
||||
$id = (int) $doctorNameToGid[$name];
|
||||
}
|
||||
if ($id <= 0) {
|
||||
$missing[] = $name;
|
||||
continue;
|
||||
}
|
||||
$qty = (float) ($h['dosage'] ?? $h['quantity'] ?? 0);
|
||||
if ($qty < 0.1) {
|
||||
$qty = 0.1;
|
||||
}
|
||||
$qty = round($qty, 1);
|
||||
$brief = trim((string) ($h['brief'] ?? $h['process'] ?? ''));
|
||||
|
||||
$mList[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'quantity' => $qty,
|
||||
'brief' => $brief,
|
||||
];
|
||||
}
|
||||
|
||||
return [$mList, $missing];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string} province, city, addr
|
||||
*/
|
||||
public static function splitCnAddress(string $full): array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '') {
|
||||
return ['', '', ''];
|
||||
}
|
||||
$province = '';
|
||||
$rest = $full;
|
||||
if (preg_match('/^(.*?(?:省|自治区))(.*)$/u', $full, $m)) {
|
||||
$province = $m[1];
|
||||
$rest = trim($m[2]);
|
||||
} elseif (preg_match('/^(北京市|天津市|上海市|重庆市)(.*)$/u', $full, $m2)) {
|
||||
$province = $m2[1];
|
||||
$rest = trim($m2[2]);
|
||||
}
|
||||
$city = '';
|
||||
$addr = $rest;
|
||||
if ($rest !== '') {
|
||||
if (preg_match('/^(.*?(?:市|州|盟|地区))(.*)$/u', $rest, $m3)) {
|
||||
$city = $m3[1];
|
||||
$addr = trim($m3[2]);
|
||||
}
|
||||
}
|
||||
if ($city === '' && $province !== '' && preg_match('/市$/u', $province)) {
|
||||
$city = $province;
|
||||
}
|
||||
if ($addr === '') {
|
||||
$addr = $full;
|
||||
}
|
||||
|
||||
return [$province, $city, $addr];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $previewPayload token、df_id、amount、m_list、df101ext…+ package/class 由调用方组装
|
||||
*/
|
||||
public static function ctmPreview(array $previewPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
return $transport->post($previewPayload);
|
||||
}
|
||||
|
||||
public static function ctmSubmit(array $submitPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
// Log the payload for debugging (with sensitive data masked)
|
||||
try {
|
||||
$maskedPayload = self::maskSensitiveData($submitPayload);
|
||||
//Log::info('Gancao CTM_SUBMIT_RECIPEL payload', ['payload' => json_encode($maskedPayload, JSON_UNESCAPED_UNICODE)]);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
// 忽略日志错误,不影响主流程
|
||||
// Log::warning('Failed to log Gancao payload: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
return $transport->post($submitPayload);
|
||||
}
|
||||
|
||||
private static function transport(): GancaoOpenApiTransport
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
|
||||
return new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rx 处方详情 toArray
|
||||
* @param array<string,mixed> $order 业务订单 toArray
|
||||
* @param string $token
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function buildPreviewPayload(array $rx, array $order, string $token): array
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$type=0;
|
||||
|
||||
if(array_key_exists($rx['prescription_type'], $c['df_ids'])){
|
||||
$type=$c['df_ids'][$rx['prescription_type']];
|
||||
}
|
||||
|
||||
$dfId =$type? (int) $type:(int) $c['df_id'];
|
||||
|
||||
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
|
||||
$docMap = self::doctorMedicineGidMapForHerbs($herbs);
|
||||
[$mList] = self::buildMList(
|
||||
$herbs,
|
||||
is_array($c['herb_id_map'] ?? null) ? $c['herb_id_map'] : [],
|
||||
$docMap
|
||||
);
|
||||
$amount = (int) ($order['dose_count']?$order['dose_count'] :$rx['dose_count'] );
|
||||
if ($amount < 1) {
|
||||
$amount =3;
|
||||
}
|
||||
|
||||
$base = [
|
||||
'token' => $token,
|
||||
'df_id' => $dfId,
|
||||
'amount' => $amount,
|
||||
'm_list' => $mList,
|
||||
'package' => 'igc_scm.ops.api.order',
|
||||
'class' => 'CTM_PREVIEW',
|
||||
];
|
||||
|
||||
if ($dfId === 101) {
|
||||
|
||||
$base['df101ext'] = [
|
||||
'times_per_day' =>$rx['times_per_day'],
|
||||
'is_decoct' => $rx['need_decoction'],
|
||||
'num_per_pack' => $rx['bags_per_dose'],
|
||||
'is_special_writing' => $rx['bags_per_dose']==$rx['times_per_day']?0:1,
|
||||
'dose' => $rx['dosage_amount'],
|
||||
'usage_mode' => 'ORAL',
|
||||
'ds_type' => 1
|
||||
];
|
||||
|
||||
$base['doct_advice']=[
|
||||
'taboo'=>$rx['dietary_taboo'],
|
||||
'usage_time'=>$rx['usage_time'],
|
||||
'usage_brief'=>$rx['usage_instruction'],
|
||||
'others'=>$rx['usage_notes'],
|
||||
'notes_doctor'=>$order['remark_extra']
|
||||
];
|
||||
$base['express_type']="general";
|
||||
$base['cradle_store']="线上接诊";
|
||||
$base['app_order_no']=$order['order_no'];
|
||||
$base['express_to']=[
|
||||
'name'=>$order['recipient_name'],
|
||||
'phone'=>$order['recipient_phone'],
|
||||
'province'=>$order['shipping_province'],
|
||||
'city'=>$order['shipping_city'],
|
||||
'addr'=>$order['shipping_province'].$order['shipping_city'].$order['shipping_city'].$order['shipping_address']
|
||||
];
|
||||
$base['callback_url']= $c['callback_url'];
|
||||
$base['diagnosis']= $rx['clinical_diagnosis']||'无';
|
||||
$base['disease']= $rx['clinical_diagnosis']||'无';
|
||||
$base['doctor']=[
|
||||
'name'=>$rx['doctor_name'],
|
||||
'phone'=>''
|
||||
];
|
||||
|
||||
$base['patient']=[
|
||||
'name'=>$rx['patient_name'],
|
||||
'age'=>$rx['age'],
|
||||
'sex'=>$rx['gender_desc']=='男'?1:0
|
||||
];
|
||||
}
|
||||
|
||||
if ($dfId === 102) {
|
||||
$base['df102ext'] = [
|
||||
'times_per_day' =>$rx['times_per_day'],
|
||||
'take_days'=>$order['medication_days']?$order['medication_days']:$rx['usage_days'],
|
||||
"pill_type"=>$rx['prescription_type']?'WATER':'HONEY',
|
||||
"dose"=>$rx['dosage_amount']
|
||||
];
|
||||
$base['doct_advice']=[
|
||||
'taboo'=>$rx['dietary_taboo'],
|
||||
'usage_time'=>$rx['usage_time'],
|
||||
'usage_brief'=>$rx['usage_instruction'],
|
||||
'others'=>$rx['usage_notes'],
|
||||
'notes_doctor'=>$order['remark_extra']
|
||||
];
|
||||
|
||||
$base['express_type']="general";
|
||||
$base['cradle_store']="线上接诊";
|
||||
$base['app_order_no']=$order['order_no'];
|
||||
$base['express_to']=[
|
||||
'name'=>$order['recipient_name'],
|
||||
'phone'=>$order['recipient_phone'],
|
||||
'province'=>$order['shipping_province'],
|
||||
'city'=>$order['shipping_city'],
|
||||
'addr'=>$order['shipping_province'].$order['shipping_city'].$order['shipping_city'].$order['shipping_address']
|
||||
];
|
||||
$base['callback_url']= $c['callback_url'];
|
||||
$base['diagnosis']= $rx['clinical_diagnosis']||'无';
|
||||
$base['disease']= $rx['clinical_diagnosis']||'无';
|
||||
$base['doctor']=[
|
||||
'name'=>$rx['doctor_name'],
|
||||
'phone'=>''
|
||||
];
|
||||
|
||||
$base['patient']=[
|
||||
'name'=>$rx['patient_name'],
|
||||
'age'=>$rx['age'],
|
||||
'sex'=>$rx['gender_desc']=='男'?1:0
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rx
|
||||
* @param array<string,mixed> $order
|
||||
* @param array{0:string,1:string,2:string} $addrParts province,city,addr
|
||||
*/
|
||||
public static function buildSubmitPayload(
|
||||
array $rx,
|
||||
array $order,
|
||||
string $token,
|
||||
array $addrParts,
|
||||
string $appOrderNo
|
||||
): array {
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$preview = self::buildPreviewPayload($rx, $order, $token);
|
||||
unset($preview['class']);
|
||||
$preview['class'] = 'CTM_SUBMIT_RECIPEL';
|
||||
|
||||
$phone = preg_replace('/\D/', '', (string) ($order['recipient_phone'] ?? ''));
|
||||
if (strlen($phone) !== 11) {
|
||||
$phone = preg_replace('/\D/', '', (string) ($rx['phone'] ?? ''));
|
||||
}
|
||||
if (strlen($phone) !== 11) {
|
||||
$phone = '';
|
||||
}
|
||||
|
||||
[$p, $ct, $ad] = $addrParts;
|
||||
if (mb_strlen($p) < 2) {
|
||||
$p = '四川省';
|
||||
}
|
||||
if (mb_strlen($ct) < 2) {
|
||||
$ct = '成都市';
|
||||
}
|
||||
if (mb_strlen($ad) < 4) {
|
||||
$ad = (string) ($order['shipping_address'] ?? '');
|
||||
}
|
||||
|
||||
$patientName = mb_substr(trim((string) ($rx['patient_name'] ?? $order['recipient_name'] ?? '患者')), 0, 30);
|
||||
if ($patientName === '') {
|
||||
$patientName = '患者';
|
||||
}
|
||||
$ageInt = (int) ($rx['age'] ?? 30);
|
||||
if ($ageInt < 0) {
|
||||
$ageInt = 0;
|
||||
}
|
||||
if ($ageInt > 120) {
|
||||
$ageInt = 120;
|
||||
}
|
||||
$patientAge = (string) $ageInt;
|
||||
$sex = (int) ($rx['gender'] ?? 0) === 1 ? 1 : 0;
|
||||
$patientPhone = preg_replace('/\D/', '', (string) ($rx['phone'] ?? ''));
|
||||
if (strlen($patientPhone) !== 11) {
|
||||
$patientPhone = $phone;
|
||||
}
|
||||
|
||||
$clinical = trim((string) ($rx['clinical_diagnosis'] ?? ''));
|
||||
if ($clinical === '') {
|
||||
$clinical = '中医辨证论治';
|
||||
}
|
||||
$clinical = mb_substr($clinical, 0, 128);
|
||||
|
||||
$doctorName = mb_substr(trim((string) ($rx['doctor_name'] ?? '医师')), 0, 10);
|
||||
$doctorBlock = ['name' => $doctorName];
|
||||
$docPhone = preg_replace('/\D/', '', (string) ($rx['doctor_phone'] ?? ''));
|
||||
if (strlen($docPhone) === 11) {
|
||||
$doctorBlock['phone'] = $docPhone;
|
||||
}
|
||||
|
||||
$usageTime = trim((string) ($rx['usage_time'] ?? '饭后半小时服用'));
|
||||
if ($usageTime === '') {
|
||||
$usageTime = '饭后半小时服用';
|
||||
}
|
||||
$usageTime = mb_substr($usageTime, 0, 32);
|
||||
|
||||
$taboo = mb_substr(trim((string) ($rx['dietary_taboo'] ?? '')), 0, 128);
|
||||
$usageBrief = trim((string) ($rx['usage_way'] ?? '') . ' ' . (string) ($rx['usage_instruction'] ?? ''));
|
||||
$usageBrief = mb_substr(trim($usageBrief), 0, 128);
|
||||
|
||||
// 确保 express_type 是字符串
|
||||
$expressType = isset($c['express_type']) ? (string) $c['express_type'] : 'sf';
|
||||
if ($expressType === '' || is_array($c['express_type'] ?? null)) {
|
||||
$expressType = 'sf'; // 默认顺丰
|
||||
}
|
||||
|
||||
$preview['express_type'] = $expressType;
|
||||
// $preview['express_to'] = [
|
||||
// 'name' => mb_substr(trim((string) ($order['recipient_name'] ?? $patientName)), 0, 16),
|
||||
// 'phone' => $phone,
|
||||
// 'province' => mb_substr($p, 0, 16),
|
||||
// 'city' => mb_substr($ct, 0, 16),
|
||||
// 'addr' => mb_substr($ad, 0, 64),
|
||||
// ];
|
||||
$preview['app_order_no'] = mb_substr($appOrderNo, 0, 32);
|
||||
|
||||
$preview['cradle_store'] = mb_substr(trim((string) ($c['cradle_store'] ?? '')), 0, 32);
|
||||
if ($preview['cradle_store'] === '') {
|
||||
$preview['cradle_store'] = 'default';
|
||||
}
|
||||
|
||||
// 确保 callback_url 是字符串
|
||||
$callbackUrl = isset($c['callback_url']) ? (string) $c['callback_url'] : '';
|
||||
if ($callbackUrl === '' || is_array($c['callback_url'] ?? null)) {
|
||||
Log::error('Gancao callback_url is invalid', ['callback_url' => $c['callback_url'] ?? null]);
|
||||
$callbackUrl = 'https://example.com/callback'; // 临时默认值,实际应该配置正确
|
||||
}
|
||||
$preview['callback_url'] = $callbackUrl;
|
||||
$preview['disease'] = $clinical;
|
||||
$preview['diagnosis'] = $clinical;
|
||||
|
||||
// Ensure all doct_advice fields are strings, not empty
|
||||
$tabooStr = $taboo !== '' ? $taboo : '无';
|
||||
$usageTimeStr = $usageTime;
|
||||
$usageBriefStr = $usageBrief !== '' ? $usageBrief : '遵医嘱';
|
||||
$othersStr = trim((string) ($rx['usage_notes'] ?? ''));
|
||||
$notesDoctorStr = trim((string) ($order['remark_extra'] ?? ''));
|
||||
|
||||
$preview['doct_advice'] = [
|
||||
'taboo' => $tabooStr,
|
||||
'usage_time' => $usageTimeStr,
|
||||
'usage_brief' => $usageBriefStr,
|
||||
'others' => $othersStr !== '' ? $othersStr : '',
|
||||
'notes_doctor' => $notesDoctorStr !== '' ? $notesDoctorStr : '',
|
||||
];
|
||||
$preview['doctor'] = $doctorBlock;
|
||||
$preview['patient'] = [
|
||||
'name' => $patientName,
|
||||
'age' => $patientAge,
|
||||
'sex' => $sex,
|
||||
'phone' => $patientPhone,
|
||||
];
|
||||
return $preview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?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\common\service\generator;
|
||||
|
||||
|
||||
use app\common\service\generator\core\ControllerGenerator;
|
||||
use app\common\service\generator\core\ListsGenerator;
|
||||
use app\common\service\generator\core\LogicGenerator;
|
||||
use app\common\service\generator\core\ModelGenerator;
|
||||
use app\common\service\generator\core\SqlGenerator;
|
||||
use app\common\service\generator\core\ValidateGenerator;
|
||||
use app\common\service\generator\core\VueApiGenerator;
|
||||
use app\common\service\generator\core\VueEditGenerator;
|
||||
use app\common\service\generator\core\VueIndexGenerator;
|
||||
|
||||
|
||||
/**
|
||||
* 生成器
|
||||
* Class GenerateService
|
||||
* @package app\common\service\generator
|
||||
*/
|
||||
class GenerateService
|
||||
{
|
||||
|
||||
// 标记
|
||||
protected $flag;
|
||||
|
||||
// 生成文件路径
|
||||
protected $generatePath;
|
||||
|
||||
// runtime目录
|
||||
protected $runtimePath;
|
||||
|
||||
// 压缩包名称
|
||||
protected $zipTempName;
|
||||
|
||||
// 压缩包临时路径
|
||||
protected $zipTempPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->generatePath = root_path() . 'runtime/generate/';
|
||||
$this->runtimePath = root_path() . 'runtime/';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除生成文件夹内容
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:52
|
||||
*/
|
||||
public function delGenerateDirContent()
|
||||
{
|
||||
// 删除runtime目录制定文件夹
|
||||
!is_dir($this->generatePath) && mkdir($this->generatePath, 0755, true);
|
||||
del_target_dir($this->generatePath, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置生成状态
|
||||
* @param $name
|
||||
* @param false $status
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:53
|
||||
*/
|
||||
public function setGenerateFlag($name, $status = false)
|
||||
{
|
||||
$this->flag = $name;
|
||||
cache($name, (int)$status, 3600);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取生成状态标记
|
||||
* @return mixed|object|\think\App
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:53
|
||||
*/
|
||||
public function getGenerateFlag()
|
||||
{
|
||||
return cache($this->flag);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除标记时间
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:53
|
||||
*/
|
||||
public function delGenerateFlag()
|
||||
{
|
||||
cache($this->flag, null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成器相关类
|
||||
* @return string[]
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 17:17
|
||||
*/
|
||||
public function getGeneratorClass()
|
||||
{
|
||||
return [
|
||||
ControllerGenerator::class,
|
||||
ListsGenerator::class,
|
||||
ModelGenerator::class,
|
||||
ValidateGenerator::class,
|
||||
LogicGenerator::class,
|
||||
VueApiGenerator::class,
|
||||
VueIndexGenerator::class,
|
||||
VueEditGenerator::class,
|
||||
SqlGenerator::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成文件
|
||||
* @param array $tableData
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:52
|
||||
*/
|
||||
public function generate(array $tableData)
|
||||
{
|
||||
foreach ($this->getGeneratorClass() as $item) {
|
||||
$generator = app()->make($item);
|
||||
$generator->initGenerateData($tableData);
|
||||
$generator->generate();
|
||||
// 是否为压缩包下载
|
||||
if ($generator->isGenerateTypeZip()) {
|
||||
$this->setGenerateFlag($this->flag, true);
|
||||
}
|
||||
// 是否构建菜单
|
||||
if ($item == 'app\common\service\generator\core\SqlGenerator') {
|
||||
$generator->isBuildMenu() && $generator->buildMenuHandle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 预览文件
|
||||
* @param array $tableData
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 18:52
|
||||
*/
|
||||
public function preview(array $tableData)
|
||||
{
|
||||
$data = [];
|
||||
foreach ($this->getGeneratorClass() as $item) {
|
||||
$generator = app()->make($item);
|
||||
$generator->initGenerateData($tableData);
|
||||
$data[] = $generator->fileInfo();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 压缩文件
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 19:02
|
||||
*/
|
||||
public function zipFile()
|
||||
{
|
||||
$fileName = 'curd-' . date('YmdHis') . '.zip';
|
||||
$this->zipTempName = $fileName;
|
||||
$this->zipTempPath = $this->generatePath . $fileName;
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($this->zipTempPath, \ZipArchive::CREATE);
|
||||
$this->addFileZip($this->runtimePath, 'generate', $zip);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 往压缩包写入文件
|
||||
* @param $basePath
|
||||
* @param $dirName
|
||||
* @param $zip
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 19:02
|
||||
*/
|
||||
public function addFileZip($basePath, $dirName, $zip)
|
||||
{
|
||||
$handler = opendir($basePath . $dirName);
|
||||
while (($filename = readdir($handler)) !== false) {
|
||||
if ($filename != '.' && $filename != '..') {
|
||||
if (is_dir($basePath . $dirName . '/' . $filename)) {
|
||||
// 当前路径是文件夹
|
||||
$this->addFileZip($basePath, $dirName . '/' . $filename, $zip);
|
||||
} else {
|
||||
// 写入文件到压缩包
|
||||
$zip->addFile($basePath . $dirName . '/' . $filename, $dirName . '/' . $filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir($handler);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 返回压缩包临时路径
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/24 9:41
|
||||
*/
|
||||
public function getDownloadUrl()
|
||||
{
|
||||
$vars = ['file' => $this->zipTempName];
|
||||
cache('curd_file_name' . $this->zipTempName, $this->zipTempName, 3600);
|
||||
return (string)url("adminapi/tools.generator/download", $vars, false, true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
use think\helper\Str;
|
||||
use app\common\enum\GeneratorEnum;
|
||||
|
||||
|
||||
/**
|
||||
* 生成器基类
|
||||
* Class BaseGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
abstract class BaseGenerator
|
||||
{
|
||||
|
||||
/**
|
||||
* 模板文件夹
|
||||
* @var string
|
||||
*/
|
||||
protected $templateDir;
|
||||
|
||||
/**
|
||||
* 模块名
|
||||
* @var string
|
||||
*/
|
||||
protected $moduleName;
|
||||
|
||||
/**
|
||||
* 类目录
|
||||
* @var string
|
||||
*/
|
||||
protected $classDir;
|
||||
|
||||
/**
|
||||
* 表信息
|
||||
* @var array
|
||||
*/
|
||||
protected $tableData;
|
||||
|
||||
/**
|
||||
* 表字段信息
|
||||
* @var array
|
||||
*/
|
||||
protected $tableColumn;
|
||||
|
||||
/**
|
||||
* 文件内容
|
||||
* @var string
|
||||
*/
|
||||
protected $content;
|
||||
|
||||
/**
|
||||
* basePath
|
||||
* @var string
|
||||
*/
|
||||
protected $basePath;
|
||||
|
||||
/**
|
||||
* rootPath
|
||||
* @var string
|
||||
*/
|
||||
protected $rootPath;
|
||||
|
||||
/**
|
||||
* 生成的文件夹
|
||||
* @var string
|
||||
*/
|
||||
protected $generatorDir;
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
* @var array
|
||||
*/
|
||||
protected $deleteConfig;
|
||||
|
||||
/**
|
||||
* 菜单配置
|
||||
* @var array
|
||||
*/
|
||||
protected $menuConfig;
|
||||
|
||||
/**
|
||||
* 模型关联配置
|
||||
* @var array
|
||||
*/
|
||||
protected $relationConfig;
|
||||
|
||||
/**
|
||||
* 树表配置
|
||||
* @var array
|
||||
*/
|
||||
protected $treeConfig;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->basePath = base_path();
|
||||
$this->rootPath = root_path();
|
||||
$this->templateDir = $this->basePath . 'common/service/generator/stub/';
|
||||
$this->generatorDir = $this->rootPath . 'runtime/generate/';
|
||||
$this->checkDir($this->generatorDir);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 初始化表表数据
|
||||
* @param array $tableData
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:03
|
||||
*/
|
||||
public function initGenerateData(array $tableData)
|
||||
{
|
||||
// 设置当前表信息
|
||||
$this->setTableData($tableData);
|
||||
// 设置模块名
|
||||
$this->setModuleName($tableData['module_name']);
|
||||
// 设置类目录
|
||||
$this->setClassDir($tableData['class_dir'] ?? '');
|
||||
// 替换模板变量
|
||||
$this->replaceVariables();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 菜单配置
|
||||
* @author 段誉
|
||||
* @date 2022/12/13 15:14
|
||||
*/
|
||||
public function setMenuConfig()
|
||||
{
|
||||
$this->menuConfig = [
|
||||
'pid' => $this->tableData['menu']['pid'] ?? 0,
|
||||
'type' => $this->tableData['menu']['type'] ?? GeneratorEnum::DELETE_TRUE,
|
||||
'name' => $this->tableData['menu']['name'] ?? $this->tableData['table_comment']
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除配置
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/12/13 15:09
|
||||
*/
|
||||
public function setDeleteConfig()
|
||||
{
|
||||
$this->deleteConfig = [
|
||||
'type' => $this->tableData['delete']['type'] ?? GeneratorEnum::DELETE_TRUE,
|
||||
'name' => $this->tableData['delete']['name'] ?? GeneratorEnum::DELETE_NAME,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 关联模型配置
|
||||
* @author 段誉
|
||||
* @date 2022/12/14 11:28
|
||||
*/
|
||||
public function setRelationConfig()
|
||||
{
|
||||
$this->relationConfig = empty($this->tableData['relations']) ? [] : $this->tableData['relations'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置树表配置
|
||||
* @author 段誉
|
||||
* @date 2022/12/20 14:30
|
||||
*/
|
||||
public function setTreeConfig()
|
||||
{
|
||||
$this->treeConfig = [
|
||||
'tree_id' => $this->tableData['tree']['tree_id'] ?? '',
|
||||
'tree_pid' => $this->tableData['tree']['tree_pid'] ?? '',
|
||||
'tree_name' => $this->tableData['tree']['tree_name'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成文件到模块或runtime目录
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:03
|
||||
*/
|
||||
public function generate()
|
||||
{
|
||||
//生成方式 0-压缩包下载 1-生成到模块
|
||||
if ($this->tableData['generate_type']) {
|
||||
// 生成路径
|
||||
$path = $this->getModuleGenerateDir() . $this->getGenerateName();
|
||||
} else {
|
||||
// 生成到runtime目录
|
||||
$path = $this->getRuntimeGenerateDir() . $this->getGenerateName();
|
||||
}
|
||||
// 写入内容
|
||||
file_put_contents($path, $this->content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:05
|
||||
*/
|
||||
abstract public function getModuleGenerateDir();
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:05
|
||||
*/
|
||||
abstract public function getRuntimeGenerateDir();
|
||||
|
||||
|
||||
/**
|
||||
* @notes 替换模板变量
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:06
|
||||
*/
|
||||
abstract public function replaceVariables();
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成文件名
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:17
|
||||
*/
|
||||
abstract public function getGenerateName();
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件夹不存在则创建
|
||||
* @param string $path
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:07
|
||||
*/
|
||||
public function checkDir(string $path)
|
||||
{
|
||||
!is_dir($path) && mkdir($path, 0755, true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置表信息
|
||||
* @param $tableData
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:07
|
||||
*/
|
||||
public function setTableData($tableData)
|
||||
{
|
||||
$this->tableData = !empty($tableData) ? $tableData : [];
|
||||
$this->tableColumn = $tableData['table_column'] ?? [];
|
||||
// 菜单配置
|
||||
$this->setMenuConfig();
|
||||
// 删除配置
|
||||
$this->setDeleteConfig();
|
||||
// 关联模型配置
|
||||
$this->setRelationConfig();
|
||||
// 设置树表配置
|
||||
$this->setTreeConfig();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置模块名
|
||||
* @param string $moduleName
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:07
|
||||
*/
|
||||
public function setModuleName(string $moduleName): void
|
||||
{
|
||||
$this->moduleName = strtolower($moduleName);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置类目录
|
||||
* @param string $classDir
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:08
|
||||
*/
|
||||
public function setClassDir(string $classDir): void
|
||||
{
|
||||
$this->classDir = $classDir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置生成文件内容
|
||||
* @param string $content
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:08
|
||||
*/
|
||||
public function setContent(string $content): void
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取模板路径
|
||||
* @param string $templateName
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function getTemplatePath(string $templateName): string
|
||||
{
|
||||
return $this->templateDir . $templateName . '.stub';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小驼峰命名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/27 18:44
|
||||
*/
|
||||
public function getLowerCamelName()
|
||||
{
|
||||
return Str::camel($this->getTableName());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 大驼峰命名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function getUpperCamelName()
|
||||
{
|
||||
return Str::studly($this->getTableName());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 表名小写
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/7/12 10:41
|
||||
*/
|
||||
public function getLowerTableName()
|
||||
{
|
||||
return Str::lower($this->getTableName());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取表名
|
||||
* @return array|string|string[]
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function getTableName()
|
||||
{
|
||||
return get_no_prefix_table_name($this->tableData['table_name']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取表主键
|
||||
* @return mixed|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function getPkContent()
|
||||
{
|
||||
$pk = 'id';
|
||||
if (empty($this->tableColumn)) {
|
||||
return $pk;
|
||||
}
|
||||
|
||||
foreach ($this->tableColumn as $item) {
|
||||
if ($item['is_pk']) {
|
||||
$pk = $item['column_name'];
|
||||
}
|
||||
}
|
||||
return $pk;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取作者信息
|
||||
* @return mixed|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/24 10:18
|
||||
*/
|
||||
public function getAuthorContent()
|
||||
{
|
||||
return empty($this->tableData['author']) ? 'likeadmin' : $this->tableData['author'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 代码生成备注时间
|
||||
* @return false|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/24 10:28
|
||||
*/
|
||||
public function getNoteDateContent()
|
||||
{
|
||||
return date('Y/m/d H:i');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置空额占位符
|
||||
* @param $content
|
||||
* @param $blankpace
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function setBlankSpace($content, $blankpace)
|
||||
{
|
||||
$content = explode(PHP_EOL, $content);
|
||||
foreach ($content as $line => $text) {
|
||||
$content[$line] = $blankpace . $text;
|
||||
}
|
||||
return (implode(PHP_EOL, $content));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 替换内容
|
||||
* @param $needReplace
|
||||
* @param $waitReplace
|
||||
* @param $template
|
||||
* @return array|false|string|string[]
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 9:52
|
||||
*/
|
||||
public function replaceFileData($needReplace, $waitReplace, $template)
|
||||
{
|
||||
return str_replace($needReplace, $waitReplace, file_get_contents($template));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成方式是否为压缩包
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 17:02
|
||||
*/
|
||||
public function isGenerateTypeZip()
|
||||
{
|
||||
return $this->tableData['generate_type'] == GeneratorEnum::GENERATE_TYPE_ZIP;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 是否为树表crud
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/12/23 11:25
|
||||
*/
|
||||
public function isTreeCrud()
|
||||
{
|
||||
return $this->tableData['template_type'] == GeneratorEnum::TEMPLATE_TYPE_TREE;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
/**
|
||||
* 控制器生成器
|
||||
* Class ControllerGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class ControllerGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:09
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{NAMESPACE}',
|
||||
'{USE}',
|
||||
'{CLASS_COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{MODULE_NAME}',
|
||||
'{PACKAGE_NAME}',
|
||||
'{EXTENDS_CONTROLLER}',
|
||||
'{NOTES}',
|
||||
'{AUTHOR}',
|
||||
'{DATE}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getNameSpaceContent(),
|
||||
$this->getUseContent(),
|
||||
$this->getClassCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->moduleName,
|
||||
$this->getPackageNameContent(),
|
||||
$this->getExtendsControllerContent(),
|
||||
$this->tableData['class_comment'],
|
||||
$this->getAuthorContent(),
|
||||
$this->getNoteDateContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/controller');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取命名空间内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getNameSpaceContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return "namespace app\\" . $this->moduleName . "\\controller\\" . $this->classDir . ';';
|
||||
}
|
||||
return "namespace app\\" . $this->moduleName . "\\controller;";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取use模板内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getUseContent()
|
||||
{
|
||||
if ($this->moduleName == 'adminapi') {
|
||||
$tpl = "use app\\" . $this->moduleName . "\\controller\\BaseAdminController;" . PHP_EOL;
|
||||
} else {
|
||||
$tpl = "use app\\common\\controller\\BaseLikeAdminController;" . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($this->classDir)) {
|
||||
$tpl .= "use app\\" . $this->moduleName . "\\lists\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Lists;" . PHP_EOL .
|
||||
"use app\\" . $this->moduleName . "\\logic\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Logic;" . PHP_EOL .
|
||||
"use app\\" . $this->moduleName . "\\validate\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Validate;";
|
||||
} else {
|
||||
$tpl .= "use app\\" . $this->moduleName . "\\lists\\" . $this->getUpperCamelName() . "Lists;" . PHP_EOL .
|
||||
"use app\\" . $this->moduleName . "\\logic\\" . $this->getUpperCamelName() . "Logic;" . PHP_EOL .
|
||||
"use app\\" . $this->moduleName . "\\validate\\" . $this->getUpperCamelName() . "Validate;";
|
||||
}
|
||||
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取类描述内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getClassCommentContent()
|
||||
{
|
||||
if (!empty($this->tableData['class_comment'])) {
|
||||
$tpl = $this->tableData['class_comment'] . '控制器';
|
||||
} else {
|
||||
$tpl = $this->getUpperCamelName() . '控制器';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取包名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getPackageNameContent()
|
||||
{
|
||||
return !empty($this->classDir) ? '\\' . $this->classDir : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取继承控制器
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getExtendsControllerContent()
|
||||
{
|
||||
$tpl = 'BaseAdminController';
|
||||
if ($this->moduleName != 'adminapi') {
|
||||
$tpl = 'BaseLikeAdminController';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:10
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->basePath . $this->moduleName . '/controller/';
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:11
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/controller/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:11
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getUpperCamelName() . 'Controller.php';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'php',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
interface GenerateInterface
|
||||
{
|
||||
public function generate();
|
||||
|
||||
public function fileInfo();
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
use app\common\enum\GeneratorEnum;
|
||||
|
||||
/**
|
||||
* 列表生成器
|
||||
* Class ListsGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class ListsGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{NAMESPACE}',
|
||||
'{USE}',
|
||||
'{CLASS_COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{MODULE_NAME}',
|
||||
'{PACKAGE_NAME}',
|
||||
'{EXTENDS_LISTS}',
|
||||
'{PK}',
|
||||
'{QUERY_CONDITION}',
|
||||
'{FIELD_DATA}',
|
||||
'{NOTES}',
|
||||
'{AUTHOR}',
|
||||
'{DATE}',
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getNameSpaceContent(),
|
||||
$this->getUseContent(),
|
||||
$this->getClassCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->moduleName,
|
||||
$this->getPackageNameContent(),
|
||||
$this->getExtendsListsContent(),
|
||||
$this->getPkContent(),
|
||||
$this->getQueryConditionContent(),
|
||||
$this->getFieldDataContent(),
|
||||
$this->tableData['class_comment'],
|
||||
$this->getAuthorContent(),
|
||||
$this->getNoteDateContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/lists');
|
||||
if ($this->isTreeCrud()) {
|
||||
// 插入树表相关
|
||||
array_push($needReplace, '{TREE_ID}', '{TREE_PID}');
|
||||
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_pid']);
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/tree_lists');
|
||||
}
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取命名空间内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getNameSpaceContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return "namespace app\\" . $this->moduleName . "\\lists\\" . $this->classDir . ';';
|
||||
}
|
||||
return "namespace app\\" . $this->moduleName . "\\lists;";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取use内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getUseContent()
|
||||
{
|
||||
if ($this->moduleName == 'adminapi') {
|
||||
$tpl = "use app\\" . $this->moduleName . "\\lists\\BaseAdminDataLists;" . PHP_EOL;
|
||||
} else {
|
||||
$tpl = "use app\\common\\lists\\BaseDataLists;" . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($this->classDir)) {
|
||||
$tpl .= "use app\\common\\model\\" . $this->classDir . "\\" . $this->getUpperCamelName() . ';';
|
||||
} else {
|
||||
$tpl .= "use app\\common\\model\\" . $this->getUpperCamelName() . ';';
|
||||
}
|
||||
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取类描述
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getClassCommentContent()
|
||||
{
|
||||
if (!empty($this->tableData['class_comment'])) {
|
||||
$tpl = $this->tableData['class_comment'] . '列表';
|
||||
} else {
|
||||
$tpl = $this->getUpperCamelName() . '列表';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取包名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getPackageNameContent()
|
||||
{
|
||||
return !empty($this->classDir) ? $this->classDir : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取继承控制器
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getExtendsListsContent()
|
||||
{
|
||||
$tpl = 'BaseAdminDataLists';
|
||||
if ($this->moduleName != 'adminapi') {
|
||||
$tpl = 'BaseDataLists';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取查询条件内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:12
|
||||
*/
|
||||
public function getQueryConditionContent()
|
||||
{
|
||||
$columnQuery = array_column($this->tableColumn, 'query_type');
|
||||
$query = array_unique($columnQuery);
|
||||
|
||||
$conditon = '';
|
||||
|
||||
$specQueryHandle = ['between', 'like'];
|
||||
|
||||
foreach ($query as $queryName) {
|
||||
$columnValue = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['query_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if ($queryName == $column['query_type'] && $column['is_query'] && !in_array($queryName, $specQueryHandle)) {
|
||||
$columnValue .= "'" . $column['column_name'] . "', ";
|
||||
}
|
||||
}
|
||||
if (!empty($columnValue)) {
|
||||
$columnValue = substr($columnValue, 0, -2);
|
||||
$conditon .= "'$queryName' => [" . trim($columnValue) . "]," . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
$likeColumn = '';
|
||||
$betweenColumn = '';
|
||||
$betweenTimeColumn = '';
|
||||
|
||||
// 另外处理between,like 等查询条件
|
||||
foreach ($this->tableColumn as $item) {
|
||||
if (!$item['is_query']) {
|
||||
continue;
|
||||
}
|
||||
// like
|
||||
if ($item['query_type'] == 'like') {
|
||||
$likeColumn .= "'" . $item['column_name'] . "', ";
|
||||
continue;
|
||||
}
|
||||
// between
|
||||
if ($item['query_type'] == 'between') {
|
||||
if ($item['view_type'] == 'datetime') {
|
||||
$betweenTimeColumn .= "'" . $item['column_name'] . "', ";
|
||||
} else {
|
||||
$betweenColumn .= "'" . $item['column_name'] . "', ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($likeColumn)) {
|
||||
$likeColumn = substr($likeColumn, 0, -2);
|
||||
$conditon .= "'%like%' => " . "[" . trim($likeColumn) . "]," . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($betweenColumn)) {
|
||||
$betweenColumn = substr($betweenColumn, 0, -2);
|
||||
$conditon .= "'between' => " . "[" . trim($betweenColumn) . "]," . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($betweenTimeColumn)) {
|
||||
$betweenTimeColumn = substr($betweenTimeColumn, 0, -2);
|
||||
$conditon .= "'between_time' => " . "[" . trim($betweenTimeColumn) . "]," . PHP_EOL;
|
||||
}
|
||||
|
||||
$content = substr($conditon, 0, -1);
|
||||
return $this->setBlankSpace($content, " ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取查询字段
|
||||
* @return false|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:13
|
||||
*/
|
||||
public function getFieldDataContent()
|
||||
{
|
||||
$content = "'" . $this->getPkContent() . "', ";
|
||||
$isExist = [$this->getPkContent()];
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if ($column['is_lists'] && !in_array($column['column_name'], $isExist)) {
|
||||
$content .= "'" . $column['column_name'] . "', ";
|
||||
$isExist[] = $column['column_name'];
|
||||
}
|
||||
|
||||
if ($this->isTreeCrud() && !in_array($column['column_name'], $isExist)
|
||||
&& in_array($column['column_name'], [$this->treeConfig['tree_id'], $this->treeConfig['tree_pid']])
|
||||
) {
|
||||
$content .= "'" . $column['column_name'] . "', ";
|
||||
}
|
||||
}
|
||||
return substr($content, 0, -2);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:13
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->basePath . $this->moduleName . '/lists/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:13
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/lists/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:13
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getUpperCamelName() . 'Lists.php';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'php',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
/**
|
||||
* 逻辑生成器
|
||||
* Class LogicGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class LogicGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{NAMESPACE}',
|
||||
'{USE}',
|
||||
'{CLASS_COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{MODULE_NAME}',
|
||||
'{PACKAGE_NAME}',
|
||||
'{PK}',
|
||||
'{CREATE_DATA}',
|
||||
'{UPDATE_DATA}',
|
||||
'{NOTES}',
|
||||
'{AUTHOR}',
|
||||
'{DATE}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getNameSpaceContent(),
|
||||
$this->getUseContent(),
|
||||
$this->getClassCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->moduleName,
|
||||
$this->getPackageNameContent(),
|
||||
$this->getPkContent(),
|
||||
$this->getCreateDataContent(),
|
||||
$this->getUpdateDataContent(),
|
||||
$this->tableData['class_comment'],
|
||||
$this->getAuthorContent(),
|
||||
$this->getNoteDateContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/logic');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getCreateDataContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_insert']) {
|
||||
continue;
|
||||
}
|
||||
$content .= $this->addEditColumn($column);
|
||||
}
|
||||
if (empty($content)) {
|
||||
return $content;
|
||||
}
|
||||
$content = substr($content, 0, -2);
|
||||
return $this->setBlankSpace($content, " ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getUpdateDataContent()
|
||||
{
|
||||
$columnContent = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_update']) {
|
||||
continue;
|
||||
}
|
||||
$columnContent .= $this->addEditColumn($column);
|
||||
}
|
||||
|
||||
if (empty($columnContent)) {
|
||||
return $columnContent;
|
||||
}
|
||||
|
||||
$columnContent = substr($columnContent, 0, -2);
|
||||
$content = $columnContent;
|
||||
return $this->setBlankSpace($content, " ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加编辑字段内容
|
||||
* @param $column
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/27 15:37
|
||||
*/
|
||||
public function addEditColumn($column)
|
||||
{
|
||||
if ($column['column_type'] == 'int' && $column['view_type'] == 'datetime') {
|
||||
// 物理类型为int,显示类型选择日期的情况
|
||||
$content = "'" . $column['column_name'] . "' => " . 'strtotime($params[' . "'" . $column['column_name'] . "'" . ']),' . PHP_EOL;
|
||||
} else {
|
||||
$content = "'" . $column['column_name'] . "' => " . '$params[' . "'" . $column['column_name'] . "'" . '],' . PHP_EOL;
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取命名空间内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getNameSpaceContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return "namespace app\\" . $this->moduleName . "\\logic\\" . $this->classDir . ';';
|
||||
}
|
||||
return "namespace app\\" . $this->moduleName . "\\logic;";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取use内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getUseContent()
|
||||
{
|
||||
$tpl = "use app\\common\\model\\" . $this->getUpperCamelName() . ';';
|
||||
if (!empty($this->classDir)) {
|
||||
$tpl = "use app\\common\\model\\" . $this->classDir . "\\" . $this->getUpperCamelName() . ';';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取类描述
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getClassCommentContent()
|
||||
{
|
||||
if (!empty($this->tableData['class_comment'])) {
|
||||
$tpl = $this->tableData['class_comment'] . '逻辑';
|
||||
} else {
|
||||
$tpl = $this->getUpperCamelName() . '逻辑';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取包名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:14
|
||||
*/
|
||||
public function getPackageNameContent()
|
||||
{
|
||||
return !empty($this->classDir) ? '\\' . $this->classDir : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:15
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->basePath . $this->moduleName . '/logic/';
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:15
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/logic/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:15
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getUpperCamelName() . 'Logic.php';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'php',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
/**
|
||||
* 模型生成器
|
||||
* Class ModelGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class ModelGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:16
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{NAMESPACE}',
|
||||
'{CLASS_COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{PACKAGE_NAME}',
|
||||
'{TABLE_NAME}',
|
||||
'{USE}',
|
||||
'{DELETE_USE}',
|
||||
'{DELETE_TIME}',
|
||||
'{RELATION_MODEL}',
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getNameSpaceContent(),
|
||||
$this->getClassCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->getPackageNameContent(),
|
||||
$this->getTableName(),
|
||||
$this->getUseContent(),
|
||||
$this->getDeleteUseContent(),
|
||||
$this->getDeleteTimeContent(),
|
||||
$this->getRelationModel(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/model');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取命名空间模板内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:16
|
||||
*/
|
||||
public function getNameSpaceContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return "namespace app\\common\\model\\" . $this->classDir . ';';
|
||||
}
|
||||
return "namespace app\\common\\model;";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取类描述
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:16
|
||||
*/
|
||||
public function getClassCommentContent()
|
||||
{
|
||||
if (!empty($this->tableData['class_comment'])) {
|
||||
$tpl = $this->tableData['class_comment'] . '模型';
|
||||
} else {
|
||||
$tpl = $this->getUpperCamelName() . '模型';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取包名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:16
|
||||
*/
|
||||
public function getPackageNameContent()
|
||||
{
|
||||
return !empty($this->classDir) ? '\\' . $this->classDir : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 引用内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/12 17:32
|
||||
*/
|
||||
public function getUseContent()
|
||||
{
|
||||
$tpl = "";
|
||||
if ($this->deleteConfig['type']) {
|
||||
$tpl = "use think\\model\\concern\\SoftDelete;";
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 软删除引用
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/12 17:34
|
||||
*/
|
||||
public function getDeleteUseContent()
|
||||
{
|
||||
$tpl = "";
|
||||
if ($this->deleteConfig['type']) {
|
||||
$tpl = "use SoftDelete;";
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 软删除时间字段定义
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/12 17:38
|
||||
*/
|
||||
public function getDeleteTimeContent()
|
||||
{
|
||||
$tpl = "";
|
||||
if ($this->deleteConfig['type']) {
|
||||
$deleteTime = $this->deleteConfig['name'];
|
||||
$tpl = 'protected $deleteTime = ' . "'". $deleteTime ."';";
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 关联模型
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/14 14:46
|
||||
*/
|
||||
public function getRelationModel()
|
||||
{
|
||||
$tpl = '';
|
||||
if (empty($this->relationConfig)) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
// 遍历关联配置
|
||||
foreach ($this->relationConfig as $config) {
|
||||
if (empty($config) || empty($config['name']) || empty($config['model'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$needReplace = [
|
||||
'{RELATION_NAME}',
|
||||
'{AUTHOR}',
|
||||
'{DATE}',
|
||||
'{RELATION_MODEL}',
|
||||
'{FOREIGN_KEY}',
|
||||
'{LOCAL_KEY}',
|
||||
];
|
||||
|
||||
$waitReplace = [
|
||||
$config['name'],
|
||||
$this->getAuthorContent(),
|
||||
$this->getNoteDateContent(),
|
||||
$config['model'],
|
||||
$config['foreign_key'],
|
||||
$config['local_key'],
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/model/' . $config['type']);
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
$tpl .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
|
||||
}
|
||||
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:16
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->basePath . 'common/model/';
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:17
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'php/app/common/model/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:17
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getUpperCamelName() . '.php';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'php',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
use app\common\enum\GeneratorEnum;
|
||||
use think\facade\Db;
|
||||
use think\helper\Str;
|
||||
|
||||
/**
|
||||
* sql文件生成器
|
||||
* Class SqlGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class SqlGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{MENU_TABLE}',
|
||||
'{PARTNER_ID}',
|
||||
'{LISTS_NAME}',
|
||||
'{PERMS_NAME}',
|
||||
'{PATHS_NAME}',
|
||||
'{COMPONENT_NAME}',
|
||||
'{CREATE_TIME}',
|
||||
'{UPDATE_TIME}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getMenuTableNameContent(),
|
||||
$this->menuConfig['pid'],
|
||||
$this->menuConfig['name'],
|
||||
$this->getPermsNameContent(),
|
||||
$this->getLowerTableName(),
|
||||
$this->getLowerTableName(),
|
||||
time(),
|
||||
time()
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('sql/sql');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 路由权限内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/8/11 17:18
|
||||
*/
|
||||
public function getPermsNameContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return $this->classDir . '.' . Str::lower($this->getTableName());
|
||||
}
|
||||
return Str::lower($this->getTableName());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取菜单表内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/7/7 15:57
|
||||
*/
|
||||
public function getMenuTableNameContent()
|
||||
{
|
||||
$tablePrefix = config('database.connections.mysql.prefix');
|
||||
return $tablePrefix . 'system_menu';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 是否构建菜单
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/7/8 14:24
|
||||
*/
|
||||
public function isBuildMenu()
|
||||
{
|
||||
return $this->menuConfig['type'] == GeneratorEnum::GEN_AUTO;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 构建菜单
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2022/7/8 15:27
|
||||
*/
|
||||
public function buildMenuHandle()
|
||||
{
|
||||
if (empty($this->content)) {
|
||||
return false;
|
||||
}
|
||||
$sqls = explode(';', trim($this->content));
|
||||
//执行sql
|
||||
foreach ($sqls as $sql) {
|
||||
if (!empty(trim($sql))) {
|
||||
Db::execute($sql . ';');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'sql/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'sql/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return 'menu.sql';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'sql',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
/**
|
||||
* 验证器生成器
|
||||
* Class ValidateGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class ValidateGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{NAMESPACE}',
|
||||
'{CLASS_COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{MODULE_NAME}',
|
||||
'{PACKAGE_NAME}',
|
||||
'{PK}',
|
||||
'{RULE}',
|
||||
'{NOTES}',
|
||||
'{AUTHOR}',
|
||||
'{DATE}',
|
||||
'{ADD_PARAMS}',
|
||||
'{EDIT_PARAMS}',
|
||||
'{FIELD}',
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getNameSpaceContent(),
|
||||
$this->getClassCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->moduleName,
|
||||
$this->getPackageNameContent(),
|
||||
$this->getPkContent(),
|
||||
$this->getRuleContent(),
|
||||
$this->tableData['class_comment'],
|
||||
$this->getAuthorContent(),
|
||||
$this->getNoteDateContent(),
|
||||
$this->getAddParamsContent(),
|
||||
$this->getEditParamsContent(),
|
||||
$this->getFiledContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('php/validate');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 验证规则
|
||||
* @return mixed|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getRuleContent()
|
||||
{
|
||||
$content = "'" . $this->getPkContent() . "' => 'require'," . PHP_EOL;
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if ($column['is_required'] == 1) {
|
||||
$content .= "'" . $column['column_name'] . "' => 'require'," . PHP_EOL;
|
||||
}
|
||||
}
|
||||
$content = substr($content, 0, -1);
|
||||
return $this->setBlankSpace($content, " ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加场景验证参数
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/7 15:26
|
||||
*/
|
||||
public function getAddParamsContent()
|
||||
{
|
||||
$content = "";
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if ($column['is_required'] == 1 && $column['column_name'] != $this->getPkContent()) {
|
||||
$content .= "'" . $column['column_name'] . "',";
|
||||
}
|
||||
}
|
||||
$content = substr($content, 0, -1);
|
||||
|
||||
// 若无设置添加场景校验字段时, 排除主键
|
||||
if (!empty($content)) {
|
||||
$content = 'return $this->only([' . $content . ']);';
|
||||
} else {
|
||||
$content = 'return $this->remove(' . "'". $this->getPkContent() . "'" . ', true);';
|
||||
}
|
||||
|
||||
return $this->setBlankSpace($content, "");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑场景验证参数
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/7 15:20
|
||||
*/
|
||||
public function getEditParamsContent()
|
||||
{
|
||||
$content = "'" . $this->getPkContent() . "'," ;
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if ($column['is_required'] == 1) {
|
||||
$content .= "'" . $column['column_name'] . "',";
|
||||
}
|
||||
}
|
||||
$content = substr($content, 0, -1);
|
||||
if (!empty($content)) {
|
||||
$content = 'return $this->only([' . $content . ']);';
|
||||
}
|
||||
return $this->setBlankSpace($content, "");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 验证字段描述
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/9 15:09
|
||||
*/
|
||||
public function getFiledContent()
|
||||
{
|
||||
$content = "'" . $this->getPkContent() . "' => '" . $this->getPkContent() . "'," . PHP_EOL;
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if ($column['is_required'] == 1) {
|
||||
$columnComment = $column['column_comment'];
|
||||
if (empty($column['column_comment'])) {
|
||||
$columnComment = $column['column_name'];
|
||||
}
|
||||
$content .= "'" . $column['column_name'] . "' => '" . $columnComment . "'," . PHP_EOL;
|
||||
}
|
||||
}
|
||||
$content = substr($content, 0, -1);
|
||||
return $this->setBlankSpace($content, " ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取命名空间模板内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getNameSpaceContent()
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
return "namespace app\\" . $this->moduleName . "\\validate\\" . $this->classDir . ';';
|
||||
}
|
||||
return "namespace app\\" . $this->moduleName . "\\validate;";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取类描述
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getClassCommentContent()
|
||||
{
|
||||
if (!empty($this->tableData['class_comment'])) {
|
||||
$tpl = $this->tableData['class_comment'] . '验证器';
|
||||
} else {
|
||||
$tpl = $this->getUpperCamelName() . '验证器';
|
||||
}
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取包名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getPackageNameContent()
|
||||
{
|
||||
return !empty($this->classDir) ? '\\' . $this->classDir : '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = $this->basePath . $this->moduleName . '/validate/';
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:18
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/validate/';
|
||||
$this->checkDir($dir);
|
||||
if (!empty($this->classDir)) {
|
||||
$dir .= $this->classDir . '/';
|
||||
$this->checkDir($dir);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getUpperCamelName() . 'Validate.php';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'php',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
use think\helper\Str;
|
||||
|
||||
/**
|
||||
* vue-api生成器
|
||||
* Class VueApiGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class VueApiGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{COMMENT}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{ROUTE}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getCommentContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->getRouteContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/api');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 描述
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getCommentContent()
|
||||
{
|
||||
return $this->tableData['table_comment'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 路由名称
|
||||
* @return array|string|string[]
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getRouteContent()
|
||||
{
|
||||
$content = $this->getTableName();
|
||||
if (!empty($this->classDir)) {
|
||||
$content = $this->classDir . '.' . $this->getTableName();
|
||||
}
|
||||
return Str::lower($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = dirname(app()->getRootPath()) . '/admin/src/api/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'vue/src/api/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return $this->getLowerTableName() . '.ts';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'ts',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
use app\common\enum\GeneratorEnum;
|
||||
|
||||
/**
|
||||
* vue-edit生成器
|
||||
* Class VueEditGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class VueEditGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{FORM_VIEW}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{DICT_DATA}',
|
||||
'{DICT_DATA_API}',
|
||||
'{FORM_DATA}',
|
||||
'{FORM_VALIDATE}',
|
||||
'{TABLE_COMMENT}',
|
||||
'{PK}',
|
||||
'{API_DIR}',
|
||||
'{CHECKBOX_JOIN}',
|
||||
'{CHECKBOX_SPLIT}',
|
||||
'{FORM_DATE}',
|
||||
'{SETUP_NAME}',
|
||||
'{IMPORT_LISTS}',
|
||||
'{TREE_CONST}',
|
||||
'{GET_TREE_LISTS}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getFormViewContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->getDictDataContent(),
|
||||
$this->getDictDataApiContent(),
|
||||
$this->getFormDataContent(),
|
||||
$this->getFormValidateContent(),
|
||||
$this->tableData['table_comment'],
|
||||
$this->getPkContent(),
|
||||
$this->getTableName(),
|
||||
$this->getCheckBoxJoinContent(),
|
||||
$this->getCheckBoxSplitContent(),
|
||||
$this->getFormDateContent(),
|
||||
$this->getLowerCamelName(),
|
||||
$this->getImportListsContent(),
|
||||
$this->getTreeConstContent(),
|
||||
$this->getTreeListsContent(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/edit');
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 复选框处理
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/24 19:30
|
||||
*/
|
||||
public function getCheckBoxJoinContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['view_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if ($column['view_type'] != 'checkbox') {
|
||||
continue;
|
||||
}
|
||||
$content .= $column['column_name'] . ': formData.' . $column['column_name'] . '.join(",")' . PHP_EOL;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 复选框处理
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/24 19:30
|
||||
*/
|
||||
public function getCheckBoxSplitContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['view_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if ($column['view_type'] != 'checkbox') {
|
||||
continue;
|
||||
}
|
||||
$content .= '//@ts-ignore' . PHP_EOL;
|
||||
$content .= 'data.' . $column['column_name'] . ' && ' .'(formData.' . $column['column_name'] . ' = String(data.' . $column['column_name'] . ').split(","))' . PHP_EOL;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 树表contst
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/22 18:19
|
||||
*/
|
||||
public function getTreeConstContent()
|
||||
{
|
||||
$content = "";
|
||||
if ($this->isTreeCrud()) {
|
||||
$content = file_get_contents($this->getTemplatePath('vue/other_item/editTreeConst'));
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取树表列表
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/12/22 18:26
|
||||
*/
|
||||
public function getTreeListsContent()
|
||||
{
|
||||
$content = '';
|
||||
if (!$this->isTreeCrud()) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$needReplace = [
|
||||
'{TREE_ID}',
|
||||
'{TREE_NAME}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$this->treeConfig['tree_id'],
|
||||
$this->treeConfig['tree_name'],
|
||||
$this->getUpperCamelName(),
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/other_item/editTreeLists');
|
||||
if (file_exists($templatePath)) {
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 表单日期处理
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/27 16:45
|
||||
*/
|
||||
public function getFormDateContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['view_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if ($column['view_type'] != 'datetime' || $column['column_type'] != 'int') {
|
||||
continue;
|
||||
}
|
||||
$content .= '//@ts-ignore' . PHP_EOL;
|
||||
$content .= 'formData.' . $column['column_name'] . ' = timeFormat(formData.' . $column['column_name'] . ','."'yyyy-mm-dd hh:MM:ss'".') ' . PHP_EOL;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取表单内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:57
|
||||
*/
|
||||
public function getFormViewContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_insert'] || !$column['is_update'] || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
$needReplace = [
|
||||
'{COLUMN_COMMENT}',
|
||||
'{COLUMN_NAME}',
|
||||
'{DICT_TYPE}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$column['column_comment'],
|
||||
$column['column_name'],
|
||||
$column['dict_type'],
|
||||
];
|
||||
|
||||
$viewType = $column['view_type'];
|
||||
// 树表,树状结构下拉框
|
||||
if ($this->isTreeCrud() && $column['column_name'] == $this->treeConfig['tree_pid']) {
|
||||
$viewType = 'treeSelect';
|
||||
array_push($needReplace, '{TREE_ID}', '{TREE_NAME}');
|
||||
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_name']);
|
||||
}
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/form_item/' . $viewType);
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 单选框值处理
|
||||
if ($column['view_type'] == 'radio' || $column['view_type'] == 'select') {
|
||||
$stubItemValue = 'item.value';
|
||||
$intFieldValue = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint'];
|
||||
if (in_array($column['column_type'], $intFieldValue)) {
|
||||
$stubItemValue = 'parseInt(item.value)';
|
||||
}
|
||||
array_push($needReplace, '{ITEM_VALUE}');
|
||||
array_push($waitReplace, $stubItemValue);
|
||||
}
|
||||
|
||||
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
|
||||
$content = $this->setBlankSpace($content, ' ');
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取字典数据内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:58
|
||||
*/
|
||||
public function getDictDataContent()
|
||||
{
|
||||
$content = '';
|
||||
$isExist = [];
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['dict_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($column['dict_type'], $isExist)) {
|
||||
continue;
|
||||
}
|
||||
$content .= $column['dict_type'] . ': ' . "[]," . PHP_EOL;
|
||||
$isExist[] = $column['dict_type'];
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取字典数据api内容
|
||||
* @return false|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:58
|
||||
*/
|
||||
public function getDictDataApiContent()
|
||||
{
|
||||
$content = '';
|
||||
$isExist = [];
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['dict_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($column['dict_type'], $isExist)) {
|
||||
continue;
|
||||
}
|
||||
$needReplace = [
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{DICT_TYPE}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$this->getUpperCamelName(),
|
||||
$column['dict_type'],
|
||||
];
|
||||
$templatePath = $this->getTemplatePath('vue/other_item/dictDataApi');
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . '' . PHP_EOL;
|
||||
|
||||
$isExist[] = $column['dict_type'];
|
||||
}
|
||||
$content = substr($content, 0, -1);
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取表单默认字段内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:15
|
||||
*/
|
||||
public function getFormDataContent()
|
||||
{
|
||||
$content = '';
|
||||
$isExist = [];
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_insert'] || !$column['is_update'] || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($column['column_name'], $isExist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 复选框类型返回数组
|
||||
if ($column['view_type'] == 'checkbox') {
|
||||
$content .= $column['column_name'] . ': ' . "[]," . PHP_EOL;
|
||||
} else {
|
||||
$content .= $column['column_name'] . ': ' . "''," . PHP_EOL;
|
||||
}
|
||||
|
||||
$isExist[] = $column['column_name'];
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 表单验证内容
|
||||
* @return false|string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:16
|
||||
*/
|
||||
public function getFormValidateContent()
|
||||
{
|
||||
$content = '';
|
||||
$isExist = [];
|
||||
$specDictType = ['input', 'textarea', 'editor'];
|
||||
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_required'] || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($column['column_name'], $isExist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validateMsg = in_array($column['view_type'], $specDictType) ? '请输入' : '请选择';
|
||||
$validateMsg .= $column['column_comment'];
|
||||
|
||||
$needReplace = [
|
||||
'{COLUMN_NAME}',
|
||||
'{VALIDATE_MSG}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$column['column_name'],
|
||||
$validateMsg,
|
||||
];
|
||||
$templatePath = $this->getTemplatePath('vue/other_item/formValidate');
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . ',' . PHP_EOL;
|
||||
|
||||
$isExist[] = $column['column_name'];
|
||||
}
|
||||
$content = substr($content, 0, -2);
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 树表时导入列表
|
||||
* @author 段誉
|
||||
* @date 2022/12/23 9:56
|
||||
*/
|
||||
public function getImportListsContent()
|
||||
{
|
||||
$content = "";
|
||||
if ($this->isTreeCrud()) {
|
||||
$content = "api". $this->getUpperCamelName(). 'Lists,';
|
||||
}
|
||||
|
||||
if (empty($content)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = dirname(app()->getRootPath()) . '/admin/src/views/' . $this->getTableName() . '/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'vue/src/views/' . $this->getTableName() . '/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return 'edit.vue';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'vue',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?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\common\service\generator\core;
|
||||
|
||||
|
||||
|
||||
use app\common\enum\GeneratorEnum;
|
||||
|
||||
/**
|
||||
* vue-index生成器
|
||||
* Class VueIndexGenerator
|
||||
* @package app\common\service\generator\core
|
||||
*/
|
||||
class VueIndexGenerator extends BaseGenerator implements GenerateInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 替换变量
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function replaceVariables()
|
||||
{
|
||||
// 需要替换的变量
|
||||
$needReplace = [
|
||||
'{SEARCH_VIEW}',
|
||||
'{LISTS_VIEW}',
|
||||
'{UPPER_CAMEL_NAME}',
|
||||
'{QUERY_PARAMS}',
|
||||
'{DICT_DATA}',
|
||||
'{PK}',
|
||||
'{API_DIR}',
|
||||
'{PERMS_ADD}',
|
||||
'{PERMS_EDIT}',
|
||||
'{PERMS_DELETE}',
|
||||
'{SETUP_NAME}'
|
||||
];
|
||||
|
||||
// 等待替换的内容
|
||||
$waitReplace = [
|
||||
$this->getSearchViewContent(),
|
||||
$this->getListsViewContent(),
|
||||
$this->getUpperCamelName(),
|
||||
$this->getQueryParamsContent(),
|
||||
$this->getDictDataContent(),
|
||||
$this->getPkContent(),
|
||||
$this->getTableName(),
|
||||
$this->getPermsContent(),
|
||||
$this->getPermsContent('edit'),
|
||||
$this->getPermsContent('delete'),
|
||||
$this->getLowerCamelName()
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/index');
|
||||
|
||||
if ($this->isTreeCrud()) {
|
||||
// 插入树表相关
|
||||
array_push($needReplace, '{TREE_ID}', '{TREE_PID}');
|
||||
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_pid']);
|
||||
$templatePath = $this->getTemplatePath('vue/index-tree');
|
||||
}
|
||||
|
||||
// 替换内容
|
||||
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
|
||||
|
||||
$this->setContent($content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取搜索内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:57
|
||||
*/
|
||||
public function getSearchViewContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_query'] || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$needReplace = [
|
||||
'{COLUMN_COMMENT}',
|
||||
'{COLUMN_NAME}',
|
||||
'{DICT_TYPE}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$column['column_comment'],
|
||||
$column['column_name'],
|
||||
$column['dict_type'],
|
||||
];
|
||||
|
||||
$searchStubType = $column['view_type'];
|
||||
if ($column['view_type'] == 'radio') {
|
||||
$searchStubType = 'select';
|
||||
}
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/search_item/' . $searchStubType);
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
|
||||
}
|
||||
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
|
||||
$content = $this->setBlankSpace($content, ' ');
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取列表内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:57
|
||||
*/
|
||||
public function getListsViewContent()
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_lists']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$needReplace = [
|
||||
'{COLUMN_COMMENT}',
|
||||
'{COLUMN_NAME}',
|
||||
'{DICT_TYPE}',
|
||||
];
|
||||
$waitReplace = [
|
||||
$column['column_comment'],
|
||||
$column['column_name'],
|
||||
$column['dict_type'],
|
||||
];
|
||||
|
||||
$templatePath = $this->getTemplatePath('vue/table_item/default');
|
||||
if ($column['view_type'] == 'imageSelect') {
|
||||
$templatePath = $this->getTemplatePath('vue/table_item/image');
|
||||
}
|
||||
if (in_array($column['view_type'], ['select', 'radio', 'checkbox'])) {
|
||||
$templatePath = $this->getTemplatePath('vue/table_item/options');
|
||||
}
|
||||
if ($column['column_type'] == 'int' && $column['view_type'] == 'datetime') {
|
||||
$templatePath = $this->getTemplatePath('vue/table_item/datetime');
|
||||
}
|
||||
if (!file_exists($templatePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取查询条件内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:57
|
||||
*/
|
||||
public function getQueryParamsContent()
|
||||
{
|
||||
$content = '';
|
||||
$queryDate = false;
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (!$column['is_query'] || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
$content .= $column['column_name'] . ": ''," . PHP_EOL;
|
||||
if ($column['query_type'] == 'between' && $column['view_type'] == 'datetime') {
|
||||
$queryDate = true;
|
||||
}
|
||||
}
|
||||
if ($queryDate) {
|
||||
$content .= "start_time: ''," . PHP_EOL;
|
||||
$content .= "end_time: ''," . PHP_EOL;
|
||||
}
|
||||
$content = substr($content, 0, -2);
|
||||
return $this->setBlankSpace($content, ' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取字典数据内容
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 11:58
|
||||
*/
|
||||
public function getDictDataContent()
|
||||
{
|
||||
$content = '';
|
||||
$isExist = [];
|
||||
foreach ($this->tableColumn as $column) {
|
||||
if (empty($column['dict_type']) || $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($column['dict_type'], $isExist)) {
|
||||
continue;
|
||||
}
|
||||
$content .= $column['dict_type'] .",";
|
||||
$isExist[] = $column['dict_type'];
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$content = substr($content, 0, -1);
|
||||
}
|
||||
return $this->setBlankSpace($content, '');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 权限规则
|
||||
* @param string $type
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/7/7 9:47
|
||||
*/
|
||||
public function getPermsContent($type = 'add')
|
||||
{
|
||||
if (!empty($this->classDir)) {
|
||||
$classDir = $this->classDir . '.';
|
||||
} else {
|
||||
$classDir = '';
|
||||
}
|
||||
return trim($classDir . $this->getLowerTableName() . '/' . $type);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到模块的文件夹路径
|
||||
* @return mixed|void
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:19
|
||||
*/
|
||||
public function getModuleGenerateDir()
|
||||
{
|
||||
$dir = dirname(app()->getRootPath()) . '/admin/src/views/' . $this->getLowerTableName() . '/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取文件生成到runtime的文件夹路径
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getRuntimeGenerateDir()
|
||||
{
|
||||
$dir = $this->generatorDir . 'vue/src/views/' . $this->getLowerTableName() . '/';
|
||||
$this->checkDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成的文件名
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/6/22 18:20
|
||||
*/
|
||||
public function getGenerateName()
|
||||
{
|
||||
return 'index.vue';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 文件信息
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/6/23 15:57
|
||||
*/
|
||||
public function fileInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->getGenerateName(),
|
||||
'type' => 'vue',
|
||||
'content' => $this->content
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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}
|
||||
|
||||
|
||||
{USE}
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}Controller
|
||||
* @package app\{MODULE_NAME}\controller{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME}Controller extends {EXTENDS_CONTROLLER}
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}列表
|
||||
* @return \think\response\Json
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new {UPPER_CAMEL_NAME}Lists());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加{NOTES}
|
||||
* @return \think\response\Json
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('add');
|
||||
$result = {UPPER_CAMEL_NAME}Logic::add($params);
|
||||
if (true === $result) {
|
||||
return $this->success('添加成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail({UPPER_CAMEL_NAME}Logic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑{NOTES}
|
||||
* @return \think\response\Json
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('edit');
|
||||
$result = {UPPER_CAMEL_NAME}Logic::edit($params);
|
||||
if (true === $result) {
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail({UPPER_CAMEL_NAME}Logic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除{NOTES}
|
||||
* @return \think\response\Json
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('delete');
|
||||
{UPPER_CAMEL_NAME}Logic::delete($params);
|
||||
return $this->success('删除成功', [], 1, 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}详情
|
||||
* @return \think\response\Json
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$params = (new {UPPER_CAMEL_NAME}Validate())->goCheck('detail');
|
||||
$result = {UPPER_CAMEL_NAME}Logic::detail($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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}
|
||||
|
||||
|
||||
{USE}
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}Lists
|
||||
* @package app\{MODULE_NAME}\lists{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME}Lists extends {EXTENDS_LISTS} implements ListsSearchInterface
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件
|
||||
* @return \string[][]
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
{QUERY_CONDITION}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
return {UPPER_CAMEL_NAME}::where($this->searchWhere)
|
||||
->field([{FIELD_DATA}])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->order(['{PK}' => 'desc'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}数量
|
||||
* @return int
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return {UPPER_CAMEL_NAME}::where($this->searchWhere)->count();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?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}
|
||||
|
||||
|
||||
{USE}
|
||||
use app\common\logic\BaseLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}Logic
|
||||
* @package app\{MODULE_NAME}\logic{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME}Logic extends BaseLogic
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加{NOTES}
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public static function add(array $params): bool
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
{UPPER_CAMEL_NAME}::create([
|
||||
{CREATE_DATA}
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑{NOTES}
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public static function edit(array $params): bool
|
||||
{
|
||||
Db::startTrans();
|
||||
try {
|
||||
{UPPER_CAMEL_NAME}::where('{PK}', $params['{PK}'])->update([
|
||||
{UPDATE_DATA}
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除{NOTES}
|
||||
* @param array $params
|
||||
* @return bool
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public static function delete(array $params): bool
|
||||
{
|
||||
return {UPPER_CAMEL_NAME}::destroy($params['{PK}']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}详情
|
||||
* @param $params
|
||||
* @return array
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public static function detail($params): array
|
||||
{
|
||||
return {UPPER_CAMEL_NAME}::findOrEmpty($params['{PK}'])->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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}
|
||||
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
{USE}
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}
|
||||
* @package app\common\model{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME} extends BaseModel
|
||||
{
|
||||
{DELETE_USE}
|
||||
protected $name = '{TABLE_NAME}';
|
||||
{DELETE_TIME}
|
||||
|
||||
{RELATION_MODEL}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
/**
|
||||
* @notes 关联{RELATION_NAME}
|
||||
* @return \think\model\relation\HasMany
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function {RELATION_NAME}()
|
||||
{
|
||||
return $this->hasMany({RELATION_MODEL}::class, '{FOREIGN_KEY}', '{LOCAL_KEY}');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
/**
|
||||
* @notes 关联{RELATION_NAME}
|
||||
* @return \think\model\relation\HasOne
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function {RELATION_NAME}()
|
||||
{
|
||||
return $this->hasOne({RELATION_MODEL}::class, '{FOREIGN_KEY}', '{LOCAL_KEY}');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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}
|
||||
|
||||
|
||||
{USE}
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}Lists
|
||||
* @package app\{MODULE_NAME}\lists{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME}Lists extends {EXTENDS_LISTS} implements ListsSearchInterface
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件
|
||||
* @return \string[][]
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
{QUERY_CONDITION}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}列表
|
||||
* @return array
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$lists = {UPPER_CAMEL_NAME}::where($this->searchWhere)
|
||||
->field([{FIELD_DATA}])
|
||||
->order(['{PK}' => 'desc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return linear_to_tree($lists, 'children', '{TREE_ID}', '{TREE_PID}');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取{NOTES}数量
|
||||
* @return int
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return {UPPER_CAMEL_NAME}::where($this->searchWhere)->count();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
|
||||
/**
|
||||
* {CLASS_COMMENT}
|
||||
* Class {UPPER_CAMEL_NAME}Validate
|
||||
* @package app\{MODULE_NAME}\validate{PACKAGE_NAME}
|
||||
*/
|
||||
class {UPPER_CAMEL_NAME}Validate extends BaseValidate
|
||||
{
|
||||
|
||||
/**
|
||||
* 设置校验规则
|
||||
* @var string[]
|
||||
*/
|
||||
protected $rule = [
|
||||
{RULE}
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* 参数描述
|
||||
* @var string[]
|
||||
*/
|
||||
protected $field = [
|
||||
{FIELD}
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加场景
|
||||
* @return {UPPER_CAMEL_NAME}Validate
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function sceneAdd()
|
||||
{
|
||||
{ADD_PARAMS}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 编辑场景
|
||||
* @return {UPPER_CAMEL_NAME}Validate
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function sceneEdit()
|
||||
{
|
||||
{EDIT_PARAMS}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 删除场景
|
||||
* @return {UPPER_CAMEL_NAME}Validate
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function sceneDelete()
|
||||
{
|
||||
return $this->only(['{PK}']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 详情场景
|
||||
* @return {UPPER_CAMEL_NAME}Validate
|
||||
* @author {AUTHOR}
|
||||
* @date {DATE}
|
||||
*/
|
||||
public function sceneDetail()
|
||||
{
|
||||
return $this->only(['{PK}']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
INSERT INTO `{MENU_TABLE}`(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||
VALUES ({PARTNER_ID}, 'C', '{LISTS_NAME}', '', 1, '{PERMS_NAME}/lists', '{PATHS_NAME}', '{COMPONENT_NAME}/index', '', '', 0, 1, 0, {CREATE_TIME}, {UPDATE_TIME});
|
||||
|
||||
SELECT @pid := LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `{MENU_TABLE}`(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||
VALUES (@pid, 'A', '添加', '', 1, '{PERMS_NAME}/add', '', '', '', '', 0, 1, 0, {CREATE_TIME}, {UPDATE_TIME});
|
||||
|
||||
INSERT INTO `{MENU_TABLE}`(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||
VALUES (@pid, 'A', '编辑', '', 1, '{PERMS_NAME}/edit', '', '', '', '', 0, 1, 0, {CREATE_TIME}, {UPDATE_TIME});
|
||||
|
||||
INSERT INTO `{MENU_TABLE}`(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||
VALUES (@pid, 'A', '删除', '', 1, '{PERMS_NAME}/delete', '', '', '', '', 0, 1, 0, {CREATE_TIME}, {UPDATE_TIME});
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// {COMMENT}列表
|
||||
export function api{UPPER_CAMEL_NAME}Lists(params: any) {
|
||||
return request.get({ url: '/{ROUTE}/lists', params })
|
||||
}
|
||||
|
||||
// 添加{COMMENT}
|
||||
export function api{UPPER_CAMEL_NAME}Add(params: any) {
|
||||
return request.post({ url: '/{ROUTE}/add', params })
|
||||
}
|
||||
|
||||
// 编辑{COMMENT}
|
||||
export function api{UPPER_CAMEL_NAME}Edit(params: any) {
|
||||
return request.post({ url: '/{ROUTE}/edit', params })
|
||||
}
|
||||
|
||||
// 删除{COMMENT}
|
||||
export function api{UPPER_CAMEL_NAME}Delete(params: any) {
|
||||
return request.post({ url: '/{ROUTE}/delete', params })
|
||||
}
|
||||
|
||||
// {COMMENT}详情
|
||||
export function api{UPPER_CAMEL_NAME}Detail(params: any) {
|
||||
return request.get({ url: '/{ROUTE}/detail', params })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="550px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="140px" :rules="formRules">
|
||||
{FORM_VIEW}
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="{SETUP_NAME}Edit">
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
import {{IMPORT_LISTS} api{UPPER_CAMEL_NAME}Add, api{UPPER_CAMEL_NAME}Edit, api{UPPER_CAMEL_NAME}Detail } from '@/api/{API_DIR}'
|
||||
import { timeFormat } from '@/utils/util'
|
||||
import type { PropType } from 'vue'
|
||||
defineProps({
|
||||
dictData: {
|
||||
type: Object as PropType<Record<string, any[]>>,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref('add')
|
||||
{TREE_CONST}
|
||||
|
||||
// 弹窗标题
|
||||
const popupTitle = computed(() => {
|
||||
return mode.value == 'edit' ? '编辑{TABLE_COMMENT}' : '新增{TABLE_COMMENT}'
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
{PK}: '',
|
||||
{FORM_DATA}
|
||||
})
|
||||
|
||||
|
||||
// 表单验证
|
||||
const formRules = reactive<any>({
|
||||
{FORM_VALIDATE}
|
||||
})
|
||||
|
||||
|
||||
// 获取详情
|
||||
const setFormData = async (data: Record<any, any>) => {
|
||||
for (const key in formData) {
|
||||
if (data[key] != null && data[key] != undefined) {
|
||||
//@ts-ignore
|
||||
formData[key] = data[key]
|
||||
}
|
||||
}
|
||||
{CHECKBOX_SPLIT}
|
||||
{FORM_DATE}
|
||||
}
|
||||
|
||||
const getDetail = async (row: Record<string, any>) => {
|
||||
const data = await api{UPPER_CAMEL_NAME}Detail({
|
||||
{PK}: row.{PK}
|
||||
})
|
||||
setFormData(data)
|
||||
}
|
||||
|
||||
|
||||
// 提交按钮
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
const data = { ...formData, {CHECKBOX_JOIN} }
|
||||
mode.value == 'edit'
|
||||
? await api{UPPER_CAMEL_NAME}Edit(data)
|
||||
: await api{UPPER_CAMEL_NAME}Add(data)
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
//打开弹窗
|
||||
const open = (type = 'add') => {
|
||||
mode.value = type
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
// 关闭回调
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
{GET_TREE_LISTS}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
getDetail
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-checkbox-group v-model="formData.{COLUMN_NAME}" placeholder="请选择{COLUMN_COMMENT}">
|
||||
<el-checkbox
|
||||
v-for="(item, index) in dictData.{DICT_TYPE}"
|
||||
:key="index"
|
||||
:label="item.value"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,10 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-date-picker
|
||||
class="flex-1 !flex"
|
||||
v-model="formData.{COLUMN_NAME}"
|
||||
clearable
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择{COLUMN_COMMENT}">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,6 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<daterange-picker
|
||||
v-model:startTime="formData.start_{COLUMN_NAME}"
|
||||
v-model:endTime="formData.end_{COLUMN_NAME}"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,3 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<editor class="flex-1" v-model="formData.{COLUMN_NAME}" :height="500" />
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,3 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<material-picker v-model="formData.{COLUMN_NAME}" />
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,3 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-input v-model="formData.{COLUMN_NAME}" clearable placeholder="请输入{COLUMN_COMMENT}" />
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,11 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-radio-group v-model="formData.{COLUMN_NAME}" placeholder="请选择{COLUMN_COMMENT}">
|
||||
<el-radio
|
||||
v-for="(item, index) in dictData.{DICT_TYPE}"
|
||||
:key="index"
|
||||
:label="{ITEM_VALUE}"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,10 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-select class="flex-1" v-model="formData.{COLUMN_NAME}" clearable placeholder="请选择{COLUMN_COMMENT}">
|
||||
<el-option
|
||||
v-for="(item, index) in dictData.{DICT_TYPE}"
|
||||
:key="index"
|
||||
:label="item.name"
|
||||
:value="{ITEM_VALUE}"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,3 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-input class="flex-1" v-model="formData.{COLUMN_NAME}" type="textarea" rows="4" clearable placeholder="请输入{COLUMN_COMMENT}" />
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,13 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-tree-select
|
||||
class="flex-1"
|
||||
v-model="formData.{COLUMN_NAME}"
|
||||
:data="treeList"
|
||||
clearable
|
||||
node-key="{TREE_ID}"
|
||||
:props="{ label: '{TREE_NAME}', value: '{TREE_ID}', children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择{COLUMN_COMMENT}"
|
||||
check-strictly
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none mb-4" shadow="never">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
class="mb-[-16px]"
|
||||
:model="queryParams"
|
||||
inline
|
||||
>
|
||||
{SEARCH_VIEW}
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getLists">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<el-button v-perms="['{PERMS_ADD}']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="handleExpand"> 展开/折叠 </el-button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
ref="tableRef"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
:data="lists"
|
||||
row-key="{TREE_ID}"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
>
|
||||
{LISTS_VIEW}
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['{PERMS_ADD}']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleAdd(row.{TREE_ID})"
|
||||
>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['{PERMS_EDIT}']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['{PERMS_DELETE}']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.{PK})"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<edit-popup v-if="showEdit" ref="editRef" :dict-data="dictData" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="{SETUP_NAME}Lists">
|
||||
import { timeFormat } from '@/utils/util'
|
||||
import { useDictData } from '@/hooks/useDictOptions'
|
||||
import { api{UPPER_CAMEL_NAME}Lists, api{UPPER_CAMEL_NAME}Delete } from '@/api/{API_DIR}'
|
||||
import feedback from '@/utils/feedback'
|
||||
import EditPopup from './edit.vue'
|
||||
import type { ElTable, FormInstance } from 'element-plus'
|
||||
|
||||
const tableRef = shallowRef<InstanceType<typeof ElTable>>()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
let isExpand = false
|
||||
|
||||
// 是否显示编辑框
|
||||
const showEdit = ref(false)
|
||||
const loading = ref(false)
|
||||
const lists = ref<any[]>([])
|
||||
|
||||
// 查询条件
|
||||
const queryParams = reactive({
|
||||
{QUERY_PARAMS}
|
||||
})
|
||||
|
||||
const resetParams = () => {
|
||||
formRef.value?.resetFields()
|
||||
getLists()
|
||||
}
|
||||
|
||||
const getLists = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api{UPPER_CAMEL_NAME}Lists(queryParams)
|
||||
lists.value = data.lists
|
||||
loading.value = false
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选中数据
|
||||
const selectData = ref<any[]>([])
|
||||
|
||||
// 表格选择后回调事件
|
||||
const handleSelectionChange = (val: any[]) => {
|
||||
selectData.value = val.map(({ {PK} }) => {PK})
|
||||
}
|
||||
|
||||
// 获取字典数据
|
||||
const { dictData } = useDictData('{DICT_DATA}')
|
||||
|
||||
// 添加
|
||||
const handleAdd = async ({TREE_ID}?: number) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
if ({TREE_ID}) {
|
||||
editRef.value?.setFormData({
|
||||
{TREE_PID}: {TREE_ID}
|
||||
})
|
||||
}
|
||||
editRef.value?.open('add')
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = async (data: any) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('edit')
|
||||
editRef.value?.setFormData(data)
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async ({PK}: number | any[]) => {
|
||||
await feedback.confirm('确定要删除?')
|
||||
await api{UPPER_CAMEL_NAME}Delete({ {PK} })
|
||||
getLists()
|
||||
}
|
||||
|
||||
const handleExpand = () => {
|
||||
isExpand = !isExpand
|
||||
toggleExpand(lists.value, isExpand)
|
||||
}
|
||||
|
||||
const toggleExpand = (children: any[], unfold = true) => {
|
||||
for (const key in children) {
|
||||
tableRef.value?.toggleRowExpansion(children[key], unfold)
|
||||
if (children[key].children) {
|
||||
toggleExpand(children[key].children!, unfold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none mb-4" shadow="never">
|
||||
<el-form
|
||||
class="mb-[-16px]"
|
||||
:model="queryParams"
|
||||
inline
|
||||
>
|
||||
{SEARCH_VIEW}
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none" v-loading="pager.loading" shadow="never">
|
||||
<el-button v-perms="['{PERMS_ADD}']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['{PERMS_DELETE}']"
|
||||
:disabled="!selectData.length"
|
||||
@click="handleDelete(selectData)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<div class="mt-4">
|
||||
<el-table :data="pager.lists" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
{LISTS_VIEW}
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['{PERMS_EDIT}']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['{PERMS_DELETE}']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.{PK})"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex mt-4 justify-end">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
<edit-popup v-if="showEdit" ref="editRef" :dict-data="dictData" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="{SETUP_NAME}Lists">
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { useDictData } from '@/hooks/useDictOptions'
|
||||
import { api{UPPER_CAMEL_NAME}Lists, api{UPPER_CAMEL_NAME}Delete } from '@/api/{API_DIR}'
|
||||
import { timeFormat } from '@/utils/util'
|
||||
import feedback from '@/utils/feedback'
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
// 是否显示编辑框
|
||||
const showEdit = ref(false)
|
||||
|
||||
|
||||
// 查询条件
|
||||
const queryParams = reactive({
|
||||
{QUERY_PARAMS}
|
||||
})
|
||||
|
||||
// 选中数据
|
||||
const selectData = ref<any[]>([])
|
||||
|
||||
// 表格选择后回调事件
|
||||
const handleSelectionChange = (val: any[]) => {
|
||||
selectData.value = val.map(({ {PK} }) => {PK})
|
||||
}
|
||||
|
||||
// 获取字典数据
|
||||
const { dictData } = useDictData('{DICT_DATA}')
|
||||
|
||||
// 分页相关
|
||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
fetchFun: api{UPPER_CAMEL_NAME}Lists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
// 添加
|
||||
const handleAdd = async () => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('add')
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = async (data: any) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('edit')
|
||||
editRef.value?.setFormData(data)
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async ({PK}: number | any[]) => {
|
||||
await feedback.confirm('确定要删除?')
|
||||
await api{UPPER_CAMEL_NAME}Delete({ {PK} })
|
||||
getLists()
|
||||
}
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
dictDataLists({
|
||||
type_value: '{DICT_TYPE}',
|
||||
page_type: 0
|
||||
}).then((res: any) => {
|
||||
dictData.{DICT_TYPE} = res.lists
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
const treeList = ref<any[]>([])
|
||||
@@ -0,0 +1,8 @@
|
||||
const getLists = async () => {
|
||||
const data: any = await api{UPPER_CAMEL_NAME}Lists()
|
||||
const item = { {TREE_ID}: 0, {TREE_NAME}: '顶级', children: [] }
|
||||
item.children = data.lists
|
||||
treeList.value.push(item)
|
||||
}
|
||||
|
||||
getLists()
|
||||
@@ -0,0 +1,5 @@
|
||||
{COLUMN_NAME}: [{
|
||||
required: true,
|
||||
message: '{VALIDATE_MSG}',
|
||||
trigger: ['blur']
|
||||
}]
|
||||
@@ -0,0 +1,6 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_time"
|
||||
v-model:endTime="queryParams.end_time"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,3 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-input class="w-[280px]" v-model="queryParams.{COLUMN_NAME}" clearable placeholder="请输入{COLUMN_COMMENT}" />
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,11 @@
|
||||
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<el-select class="w-[280px]" v-model="queryParams.{COLUMN_NAME}" clearable placeholder="请选择{COLUMN_COMMENT}">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option
|
||||
v-for="(item, index) in dictData.{DICT_TYPE}"
|
||||
:key="index"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -0,0 +1,5 @@
|
||||
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.{COLUMN_NAME} ? timeFormat(row.{COLUMN_NAME}, 'yyyy-mm-dd hh:MM:ss') : '' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -0,0 +1 @@
|
||||
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}" show-overflow-tooltip />
|
||||
@@ -0,0 +1,5 @@
|
||||
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<template #default="{ row }">
|
||||
<el-image style="width:50px;height:50px;" :src="row.{COLUMN_NAME}" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -0,0 +1,5 @@
|
||||
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
|
||||
<template #default="{ row }">
|
||||
<dict-value :options="dictData.{DICT_TYPE}" :value="row.{COLUMN_NAME}" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -0,0 +1,372 @@
|
||||
<?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\common\service\pay;
|
||||
|
||||
use Alipay\EasySDK\Kernel\Factory;
|
||||
use Alipay\EasySDK\Kernel\Config;
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\logic\PayNotifyLogic;
|
||||
use app\common\model\member\MemberOrder;
|
||||
use app\common\model\pay\PayConfig;
|
||||
use app\common\model\recharge\RechargeOrder;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 支付宝支付
|
||||
* Class AliPlsayService
|
||||
* @package app\common\server
|
||||
*/
|
||||
class AliPayService extends BasePayService
|
||||
{
|
||||
|
||||
/**
|
||||
* 用户客户端
|
||||
* @var
|
||||
*/
|
||||
protected $terminal;
|
||||
|
||||
/**
|
||||
* 支付实例
|
||||
* @var
|
||||
*/
|
||||
protected $pay;
|
||||
|
||||
/**
|
||||
* 初始化设置
|
||||
* AliPayService constructor.
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct($terminal = null)
|
||||
{
|
||||
//设置用户终端
|
||||
$this->terminal = $terminal;
|
||||
//初始化支付配置
|
||||
Factory::setOptions($this->getOptions());
|
||||
$this->pay = Factory::payment();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 支付设置
|
||||
* @return Config
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:43
|
||||
*/
|
||||
public function getOptions()
|
||||
{
|
||||
$config = (new PayConfig())->where(['pay_way' => PayEnum::ALI_PAY])->find();
|
||||
if (empty($config)) {
|
||||
throw new \Exception('请配置好支付设置');
|
||||
}
|
||||
$options = new Config();
|
||||
$options->protocol = 'https';
|
||||
$options->gatewayHost = 'openapi.alipay.com';
|
||||
// $options->gatewayHost = 'openapi.alipaydev.com'; //测试沙箱地址
|
||||
$options->signType = 'RSA2';
|
||||
$options->appId = $config['config']['app_id'] ?? '';
|
||||
// 应用私钥
|
||||
$options->merchantPrivateKey = $config['config']['private_key'] ?? '';
|
||||
//接口加签方式
|
||||
// 秘钥模式
|
||||
if ($config['config']['mode'] == 'normal_mode') {
|
||||
//支付宝公钥
|
||||
$options->alipayPublicKey = $config['config']['ali_public_key'] ?? '';
|
||||
}
|
||||
|
||||
//证书模式
|
||||
if ($config['config']['mode'] == 'certificate') {
|
||||
//判断是否已经存在证书文件夹,不存在则新建
|
||||
if (!file_exists(app()->getRootPath() . 'runtime/certificate')) {
|
||||
mkdir(app()->getRootPath() . 'runtime/certificate', 0775, true);
|
||||
}
|
||||
//写入文件
|
||||
$publicCert = $config['config']['public_cert'] ?? '';
|
||||
$aliPublicCert = $config['config']['ali_public_cert'] ?? '';
|
||||
$aliRootCert = $config['config']['ali_root_cert'] ?? '';
|
||||
$publicCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($publicCert) . '.crt';
|
||||
$aliPublicCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($aliPublicCert) . '.crt';
|
||||
$aliRootCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($aliRootCert) . '.crt';
|
||||
if (!file_exists($publicCertPath)) {
|
||||
$fopenPublicCertPath = fopen($publicCertPath, 'w');
|
||||
fwrite($fopenPublicCertPath, $publicCert);
|
||||
fclose($fopenPublicCertPath);
|
||||
}
|
||||
if (!file_exists($aliPublicCertPath)) {
|
||||
$fopenAliPublicCertPath = fopen($aliPublicCertPath, 'w');
|
||||
fwrite($fopenAliPublicCertPath, $aliPublicCert);
|
||||
fclose($fopenAliPublicCertPath);
|
||||
}
|
||||
if (!file_exists($aliRootCertPath)) {
|
||||
$fopenAliRootCertPath = fopen($aliRootCertPath, 'w');
|
||||
fwrite($fopenAliRootCertPath, $aliRootCert);
|
||||
fclose($fopenAliRootCertPath);
|
||||
}
|
||||
//应用公钥证书路径
|
||||
$options->merchantCertPath = $publicCertPath;
|
||||
//支付宝公钥证书路径
|
||||
$options->alipayCertPath = $aliPublicCertPath;
|
||||
//支付宝根证书路径
|
||||
$options->alipayRootCertPath = $aliRootCertPath;
|
||||
}
|
||||
//回调地址
|
||||
$options->notifyUrl = (string)url('pay/aliNotify', [], false, true);
|
||||
return $options;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 支付
|
||||
* @param $from //订单来源;order-商品订单;recharge-充值订单
|
||||
* @param $order //订单信息
|
||||
* @return false|string[]
|
||||
* @author 段誉
|
||||
* @date 2021/8/13 17:08
|
||||
*/
|
||||
public function pay($from, $order): array|bool
|
||||
{
|
||||
try {
|
||||
$result = match ($this->terminal) {
|
||||
UserTerminalEnum::PC => $this->pagePay($from, $order),
|
||||
UserTerminalEnum::IOS, UserTerminalEnum::ANDROID => $this->appPay($from, $order),
|
||||
UserTerminalEnum::WECHAT_OA, UserTerminalEnum::H5 => $this->wapPay($from, $order),
|
||||
default => throw new \Exception('支付方式错误'),
|
||||
};
|
||||
return [
|
||||
'config' => $result,
|
||||
'pay_way' => PayEnum::ALI_PAY
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes: 支付回调
|
||||
* @param $data
|
||||
* @return bool
|
||||
* @author 段誉(2021/3/22 17:22)
|
||||
*/
|
||||
public function notify($data)
|
||||
{
|
||||
try {
|
||||
$verify = $this->pay->common()->verifyNotify($data);
|
||||
if (false === $verify) {
|
||||
throw new \Exception('异步通知验签失败');
|
||||
}
|
||||
if (!in_array($data['trade_status'], ['TRADE_SUCCESS', 'TRADE_FINISHED'])) {
|
||||
return true;
|
||||
}
|
||||
$extra['transaction_id'] = $data['trade_no'];
|
||||
//验证订单是否已支付
|
||||
switch ($data['passback_params']) {
|
||||
case 'recharge':
|
||||
$order = RechargeOrder::where(['sn' => $data['out_trade_no']])->findOrEmpty();
|
||||
if ($order->isEmpty() || $order->pay_status == PayEnum::ISPAID) {
|
||||
return true;
|
||||
}
|
||||
PayNotifyLogic::handle('recharge', $data['out_trade_no'], $extra);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$record = [
|
||||
__CLASS__,
|
||||
__FUNCTION__,
|
||||
$e->getFile(),
|
||||
$e->getLine(),
|
||||
$e->getMessage()
|
||||
];
|
||||
Log::write(implode('-', $record));
|
||||
$this->setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes PC支付
|
||||
* @param $attach //附加参数(在回调时会返回)
|
||||
* @param $order //订单信息
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:34
|
||||
*/
|
||||
public function pagePay($attach, $order)
|
||||
{
|
||||
$domain = request()->domain();
|
||||
$result = $this->pay->page()->optional('passback_params', $attach)->pay(
|
||||
'订单:' . $order['sn'],
|
||||
$order['sn'],
|
||||
$order['order_amount'],
|
||||
$domain . $order['redirect_url']
|
||||
);
|
||||
return $result->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes APP支付
|
||||
* @param $attach //附加参数(在回调时会返回)
|
||||
* @param $order //订单信息
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:34
|
||||
*/
|
||||
public function appPay($attach, $order)
|
||||
{
|
||||
$result = $this->pay->app()->optional('passback_params', $attach)->pay(
|
||||
$order['sn'],
|
||||
$order['sn'],
|
||||
$order['order_amount']
|
||||
);
|
||||
return $result->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 手机网页支付
|
||||
* @param $attach //附加参数(在回调时会返回)
|
||||
* @param $order //订单信息
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:34
|
||||
*/
|
||||
public function wapPay($attach, $order)
|
||||
{
|
||||
$domain = request()->domain();
|
||||
$url = $domain . '/mobile' . $order['redirect_url'] .'?id=' . $order['id'] . '&from='. $attach . '&checkPay=true';;
|
||||
$result = $this->pay->wap()->optional('passback_params', $attach)->pay(
|
||||
'订单:' . $order['sn'],
|
||||
$order['sn'],
|
||||
$order['order_amount'],
|
||||
$url,
|
||||
$url
|
||||
);
|
||||
return $result->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 查询订单
|
||||
* @param $orderSn
|
||||
* @return \Alipay\EasySDK\Payment\Common\Models\AlipayTradeQueryResponse
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:36
|
||||
*/
|
||||
public function checkPay($orderSn)
|
||||
{
|
||||
return $this->pay->common()->query($orderSn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 退款
|
||||
* @param $orderSn
|
||||
* @param $orderAmount
|
||||
* @param $outRequestNo
|
||||
* @return \Alipay\EasySDK\Payment\Common\Models\AlipayTradeRefundResponse
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:37
|
||||
*/
|
||||
public function refund($orderSn, $orderAmount, $outRequestNo)
|
||||
{
|
||||
return $this->pay->common()->optional('out_request_no', $outRequestNo)->refund($orderSn, $orderAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 查询退款
|
||||
* @author Tab
|
||||
* @date 2021/9/13 11:38
|
||||
*/
|
||||
public function queryRefund($orderSn, $refundSn)
|
||||
{
|
||||
return $this->pay->common()->queryRefund($orderSn, $refundSn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 捕获错误
|
||||
* @param $result
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:09
|
||||
*/
|
||||
public function checkResultFail($result)
|
||||
{
|
||||
if (isset($result['alipay_trade_precreate_response']['code']) && 10000 != $result['alipay_trade_precreate_response']['code']) {
|
||||
throw new \Exception('支付宝:' . $result['alipay_trade_precreate_response']['msg']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 转账到支付宝账号
|
||||
* @param $withdraw
|
||||
* @return mixed
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @author ljj
|
||||
* @date 2023/10/9 10:58 上午
|
||||
*/
|
||||
public function transfer($withdraw)
|
||||
{
|
||||
//请求参数
|
||||
$data = [
|
||||
'out_biz_no' => $withdraw['sn'],//商家侧唯一订单号,由商家自定义。对于不同转账请求,商家需保证该订单号在自身系统唯一。
|
||||
'trans_amount' => $withdraw['left_money'],//订单总金额,单位为元,不支持千位分隔符,精确到小数点后两位
|
||||
'product_code' => 'TRANS_ACCOUNT_NO_PWD',//销售产品码。单笔无密转账固定为 TRANS_ACCOUNT_NO_PWD。
|
||||
'biz_scene' => 'DIRECT_TRANSFER',//业务场景。单笔无密转账固定为 DIRECT_TRANSFER。
|
||||
'order_title' => '佣金提现',//转账业务的标题
|
||||
'payee_info' => [//收款方信息
|
||||
'identity' => $withdraw['account'],//参与方的标识 ID。当 identity_type=ALIPAY_USER_ID 时,填写支付宝用户 UID;当 identity_type=ALIPAY_LOGON_ID 时,填写支付宝登录号。
|
||||
'identity_type' => 'ALIPAY_LOGON_ID',//参与方的标识类型。ALIPAY_USER_ID:支付宝会员的用户 ID;ALIPAY_LOGON_ID:支付宝登录号;
|
||||
'name' => $withdraw['real_name'],//参与方真实姓名。如果非空,将校验收款支付宝账号姓名一致性。当 identity_type=ALIPAY_LOGON_ID 时,本字段必填。
|
||||
],
|
||||
'remark' => '',//业务备注
|
||||
];
|
||||
|
||||
$result = Factory::util()->generic()->execute("alipay.fund.trans.uni.transfer", [], $data);
|
||||
$result = json_decode($result->httpBody, true);
|
||||
$result = $result['alipay_fund_trans_uni_transfer_response'] ?? [];
|
||||
if ($result['code'] != 10000) {//接口调用失败
|
||||
throw new \Exception($result['sub_msg'] ?? $result['msg']);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 转账查询
|
||||
* @param $withdraw
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
* @author ljj
|
||||
* @date 2023/10/9 11:47 上午
|
||||
*/
|
||||
public function transferQuery($withdraw)
|
||||
{
|
||||
//请求参数
|
||||
$data = [
|
||||
'out_biz_no' => $withdraw['sn'],//商户转账唯一订单号:发起转账来源方定义的转账单据 ID。
|
||||
'product_code' => 'TRANS_ACCOUNT_NO_PWD',//销售产品码,如果传了 out_biz_no,则该字段必传。单笔无密转账固定为TRANS_ACCOUNT_NO_PWD。
|
||||
'biz_scene' => 'DIRECT_TRANSFER',//描述特定的业务场景,如果传递了out_biz_no 则该字段为必传。单笔无密转账固定为DIRECT_TRANSFER。
|
||||
];
|
||||
|
||||
$result = Factory::util()->generic()->execute("alipay.fund.trans.common.query", [], $data);
|
||||
$result = json_decode($result->httpBody, true);
|
||||
return $result['alipay_fund_trans_common_query_response'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?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\common\service\pay;
|
||||
|
||||
|
||||
use think\facade\Log;
|
||||
|
||||
class BasePayService
|
||||
{
|
||||
/**
|
||||
* 错误信息
|
||||
* @var string
|
||||
*/
|
||||
protected $error;
|
||||
|
||||
/**
|
||||
* 返回状态码
|
||||
* @var int
|
||||
*/
|
||||
protected $returnCode = 0;
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取错误信息
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2021/7/21 18:23
|
||||
*/
|
||||
public function getError()
|
||||
{
|
||||
if (false === self::hasError()) {
|
||||
return '系统错误';
|
||||
}
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置错误信息
|
||||
* @param $error
|
||||
* @author 段誉
|
||||
* @date 2021/7/21 18:20
|
||||
*/
|
||||
public function setError($error)
|
||||
{
|
||||
$this->error = $error;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 是否存在错误
|
||||
* @return bool
|
||||
* @author 段誉
|
||||
* @date 2021/7/21 18:32
|
||||
*/
|
||||
public function hasError()
|
||||
{
|
||||
return !empty($this->error);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 设置状态码
|
||||
* @param $code
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 17:05
|
||||
*/
|
||||
public function setReturnCode($code)
|
||||
{
|
||||
$this->returnCode = $code;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 特殊场景返回指定状态码,默认为0
|
||||
* @return int
|
||||
* @author 段誉
|
||||
* @date 2021/7/28 15:14
|
||||
*/
|
||||
public function getReturnCode()
|
||||
{
|
||||
return $this->returnCode;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?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\common\service\pay;
|
||||
|
||||
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\logic\PayNotifyLogic;
|
||||
use app\common\model\recharge\RechargeOrder;
|
||||
use app\common\model\user\UserAuth;
|
||||
use app\common\service\wechat\WeChatConfigService;
|
||||
use EasyWeChat\Pay\Application;
|
||||
use EasyWeChat\Pay\Message;
|
||||
|
||||
|
||||
/**
|
||||
* 微信支付
|
||||
* Class WeChatPayService
|
||||
* @package app\common\server
|
||||
*/
|
||||
class WeChatPayService extends BasePayService
|
||||
{
|
||||
/**
|
||||
* 授权信息
|
||||
* @var UserAuth|array|\think\Model
|
||||
*/
|
||||
protected $auth;
|
||||
|
||||
|
||||
/**
|
||||
* 微信配置
|
||||
* @var
|
||||
*/
|
||||
protected $config;
|
||||
|
||||
|
||||
/**
|
||||
* easyWeChat实例
|
||||
* @var
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
|
||||
/**
|
||||
* 当前使用客户端
|
||||
* @var
|
||||
*/
|
||||
protected $terminal;
|
||||
|
||||
|
||||
/**
|
||||
* 初始化微信支付配置
|
||||
* @param $terminal //用户终端
|
||||
* @param null $userId //用户id(获取授权openid)
|
||||
*/
|
||||
public function __construct($terminal, $userId = null)
|
||||
{
|
||||
$this->terminal = $terminal;
|
||||
$this->config = WeChatConfigService::getPayConfigByTerminal($terminal);
|
||||
$this->app = new Application($this->config);
|
||||
if ($userId !== null) {
|
||||
$this->auth = UserAuth::where(['user_id' => $userId, 'terminal' => $terminal])->findOrEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 发起微信支付统一下单
|
||||
* @param $from
|
||||
* @param $order
|
||||
* @return array|false|string
|
||||
* @author 段誉
|
||||
* @date 2021/8/4 15:05
|
||||
*/
|
||||
public function pay($from, $order)
|
||||
{
|
||||
try {
|
||||
switch ($this->terminal) {
|
||||
case UserTerminalEnum::WECHAT_MMP:
|
||||
$config = WeChatConfigService::getMnpConfig();
|
||||
$result = $this->jsapiPay($from, $order, $config['app_id']);
|
||||
break;
|
||||
case UserTerminalEnum::WECHAT_OA:
|
||||
$config = WeChatConfigService::getOaConfig();
|
||||
$result = $this->jsapiPay($from, $order, $config['app_id']);
|
||||
break;
|
||||
case UserTerminalEnum::IOS:
|
||||
case UserTerminalEnum::ANDROID:
|
||||
$config = WeChatConfigService::getOpConfig();
|
||||
$result = $this->appPay($from, $order, $config['app_id']);
|
||||
break;
|
||||
case UserTerminalEnum::H5:
|
||||
$config = WeChatConfigService::getOaConfig();
|
||||
$result = $this->mwebPay($from, $order, $config['app_id']);
|
||||
break;
|
||||
case UserTerminalEnum::PC:
|
||||
$config = WeChatConfigService::getOaConfig();
|
||||
$result = $this->nativePay($from, $order, $config['app_id']);
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('支付方式错误');
|
||||
}
|
||||
|
||||
return [
|
||||
'config' => $result,
|
||||
'pay_way' => PayEnum::WECHAT_PAY
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes jsapiPay
|
||||
* @param $from
|
||||
* @param $order
|
||||
* @param $appId
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:12
|
||||
*/
|
||||
public function jsapiPay($from, $order, $appId)
|
||||
{
|
||||
$response = $this->app->getClient()->postJson("v3/pay/transactions/jsapi", [
|
||||
"appid" => $appId,
|
||||
"mchid" => $this->config['mch_id'],
|
||||
"description" => $this->payDesc($from),
|
||||
"out_trade_no" => $order['pay_sn'],
|
||||
"notify_url" => $this->config['notify_url'],
|
||||
"amount" => [
|
||||
"total" => intval($order['order_amount'] * 100),
|
||||
],
|
||||
"payer" => [
|
||||
"openid" => $this->auth['openid']
|
||||
],
|
||||
'attach' => $from
|
||||
]);
|
||||
|
||||
$result = $response->toArray(false);
|
||||
$this->checkResultFail($result);
|
||||
return $this->getPrepayConfig($result['prepay_id'], $appId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 网站native
|
||||
* @param $from
|
||||
* @param $order
|
||||
* @param $appId
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:12
|
||||
*/
|
||||
public function nativePay($from, $order, $appId)
|
||||
{
|
||||
$response = $this->app->getClient()->postJson('v3/pay/transactions/native', [
|
||||
'appid' => $appId,
|
||||
'mchid' => $this->config['mch_id'],
|
||||
'description' => $this->payDesc($from),
|
||||
'out_trade_no' => $order['pay_sn'],
|
||||
'notify_url' => $this->config['notify_url'],
|
||||
'amount' => [
|
||||
'total' => intval($order['order_amount'] * 100),
|
||||
],
|
||||
'attach' => $from
|
||||
]);
|
||||
$result = $response->toArray(false);
|
||||
$this->checkResultFail($result);
|
||||
return $result['code_url'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes appPay
|
||||
* @param $from
|
||||
* @param $order
|
||||
* @param $appId
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:12
|
||||
*/
|
||||
public function appPay($from, $order, $appId)
|
||||
{
|
||||
$response = $this->app->getClient()->postJson('v3/pay/transactions/app', [
|
||||
'appid' => $appId,
|
||||
'mchid' => $this->config['mch_id'],
|
||||
'description' => $this->payDesc($from),
|
||||
'out_trade_no' => $order['pay_sn'],
|
||||
'notify_url' => $this->config['notify_url'],
|
||||
'amount' => [
|
||||
'total' => intval($order['order_amount'] * 100),
|
||||
],
|
||||
'attach' => $from
|
||||
]);
|
||||
$result = $response->toArray(false);
|
||||
$this->checkResultFail($result);
|
||||
return $result['prepay_id'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes h5
|
||||
* @param $from
|
||||
* @param $order
|
||||
* @param $appId
|
||||
* @param $redirectUrl
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:13
|
||||
*/
|
||||
public function mwebPay($from, $order, $appId)
|
||||
{
|
||||
$ip = request()->ip();
|
||||
if (!empty(env('project.test_web_ip')) && env('APP_DEBUG')) {
|
||||
$ip = env('project.test_web_ip');
|
||||
}
|
||||
|
||||
$response = $this->app->getClient()->postJson('v3/pay/transactions/h5', [
|
||||
'appid' => $appId,
|
||||
'mchid' => $this->config['mch_id'],
|
||||
'description' => $this->payDesc($from),
|
||||
'out_trade_no' => $order['pay_sn'],
|
||||
'notify_url' => $this->config['notify_url'],
|
||||
'amount' => [
|
||||
'total' => intval(strval($order['order_amount'] * 100)),
|
||||
],
|
||||
'attach' => $from,
|
||||
'scene_info' => [
|
||||
'payer_client_ip' => $ip,
|
||||
'h5_info' => [
|
||||
'type' => 'Wap',
|
||||
]
|
||||
]
|
||||
]);
|
||||
$result = $response->toArray(false);
|
||||
$this->checkResultFail($result);
|
||||
|
||||
$domain = request()->domain();
|
||||
if (!empty(env('project.test_web_domain')) && env('APP_DEBUG')) {
|
||||
$domain = env('project.test_web_domain');
|
||||
}
|
||||
$redirectUrl = $domain . '/mobile'. $order['redirect_url'] .'?id=' . $order['id'] . '&from='. $from . '&checkPay=true';
|
||||
return $result['h5_url'] . '&redirect_url=' . urlencode($redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 退款
|
||||
* @param array $refundData
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 16:53
|
||||
*/
|
||||
public function refund(array $refundData)
|
||||
{
|
||||
$response = $this->app->getClient()->postJson('v3/refund/domestic/refunds', [
|
||||
'transaction_id' => $refundData['transaction_id'],
|
||||
'out_refund_no' => $refundData['refund_sn'],
|
||||
'amount' => [
|
||||
'refund' => intval($refundData['refund_amount'] * 100),
|
||||
'total' => intval($refundData['total_amount'] * 100),
|
||||
'currency' => 'CNY',
|
||||
]
|
||||
]);
|
||||
$result = $response->toArray(false);
|
||||
$this->checkResultFail($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 查询退款
|
||||
* @param $refundSn
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 11:16
|
||||
*/
|
||||
public function queryRefund($refundSn)
|
||||
{
|
||||
$response = $this->app->getClient()->get("v3/refund/domestic/refunds/{$refundSn}");
|
||||
return $response->toArray(false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 支付描述
|
||||
* @param $from
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 17:54
|
||||
*/
|
||||
public function payDesc($from)
|
||||
{
|
||||
$desc = [
|
||||
'order' => '商品',
|
||||
'recharge' => '充值',
|
||||
];
|
||||
return $desc[$from] ?? '商品';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 捕获错误
|
||||
* @param $result
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 12:09
|
||||
*/
|
||||
public function checkResultFail($result)
|
||||
{
|
||||
if (!empty($result['code']) || !empty($result['message'])) {
|
||||
throw new \Exception('微信:'. $result['code'] . '-' . $result['message']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 预支付配置
|
||||
* @param $prepayId
|
||||
* @param $appId
|
||||
* @return mixed[]
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
|
||||
* @author 段誉
|
||||
* @date 2023/2/28 17:38
|
||||
*/
|
||||
public function getPrepayConfig($prepayId, $appId)
|
||||
{
|
||||
return $this->app->getUtils()->buildBridgeConfig($prepayId, $appId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @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:20
|
||||
*/
|
||||
public function notify()
|
||||
{
|
||||
$server = $this->app->getServer();
|
||||
// 支付通知
|
||||
$server->handlePaid(function (Message $message) {
|
||||
if ($message['trade_state'] === 'SUCCESS') {
|
||||
$extra['transaction_id'] = $message['transaction_id'];
|
||||
$attach = $message['attach'];
|
||||
switch ($attach) {
|
||||
case 'recharge':
|
||||
$outTradeNo = mb_substr($message['out_trade_no'], 0, 18);
|
||||
$order = RechargeOrder::where(['sn' => $outTradeNo])->findOrEmpty();
|
||||
if($order->isEmpty() || $order->pay_status == PayEnum::ISPAID) {
|
||||
return true;
|
||||
}
|
||||
PayNotifyLogic::handle('recharge', $outTradeNo, $extra);
|
||||
break;
|
||||
case 'order':
|
||||
$outTradeNo = mb_substr($message['out_trade_no'], 0, 23);
|
||||
$orderModel = \app\common\model\Order::where(['order_no' => $outTradeNo])->findOrEmpty();
|
||||
if($orderModel->isEmpty() || $orderModel->status == 2) {
|
||||
return true;
|
||||
}
|
||||
PayNotifyLogic::handle('order', $outTradeNo, $extra);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 退款通知
|
||||
$server->handleRefunded(function (Message $message) {
|
||||
return true;
|
||||
});
|
||||
return $server->serve();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjMedicineBootstrapItem
|
||||
{
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
public static function fromRow(array $row): array
|
||||
{
|
||||
$sourceId = self::sourceId($row['id'] ?? null);
|
||||
$name = self::text($row['name'] ?? null, '药材名称', 120);
|
||||
$unit = self::text($row['unit'] ?? null, '药材单位', 24);
|
||||
$status = $row['status'] ?? null;
|
||||
if (!in_array($status, [0, 1, '0', '1'], true)) {
|
||||
throw new InvalidArgumentException("本地药材 {$sourceId} 状态必须为 0 或 1");
|
||||
}
|
||||
|
||||
return [
|
||||
'source_medicine_id' => $sourceId,
|
||||
'name' => $name,
|
||||
'brand' => '',
|
||||
'unit' => $unit,
|
||||
'settlement_price' => self::roundPrice($row['settlement_price'] ?? null),
|
||||
'retail_price' => self::roundPrice($row['retail_price'] ?? null),
|
||||
'status' => (int) $status,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
public static function fromRows(array $rows): array
|
||||
{
|
||||
$items = array_map([self::class, 'fromRow'], $rows);
|
||||
usort($items, static fn (array $left, array $right): int => self::compareIds(
|
||||
(string) $left['source_medicine_id'],
|
||||
(string) $right['source_medicine_id']
|
||||
));
|
||||
|
||||
$previousId = null;
|
||||
foreach ($items as $item) {
|
||||
$sourceId = (string) $item['source_medicine_id'];
|
||||
if ($previousId !== null && hash_equals($previousId, $sourceId)) {
|
||||
throw new InvalidArgumentException("本地药材 source_medicine_id 重复:{$sourceId}");
|
||||
}
|
||||
$previousId = $sourceId;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function roundPrice(mixed $value): string
|
||||
{
|
||||
if (!is_string($value) || preg_match('/^(0|[1-9]\d*)\.(\d{1,6})$/D', $value, $matches) !== 1) {
|
||||
throw new InvalidArgumentException('药材价格必须是最多六位小数的非负十进制字符串');
|
||||
}
|
||||
$whole = ltrim($matches[1], '0');
|
||||
$whole = $whole === '' ? '0' : $whole;
|
||||
$fraction = str_pad($matches[2], 6, '0');
|
||||
$fourDecimals = substr($fraction, 0, 4);
|
||||
if ((int) $fraction[4] < 5) {
|
||||
return $whole . '.' . $fourDecimals;
|
||||
}
|
||||
|
||||
$digits = self::addOne($whole . $fourDecimals);
|
||||
if (strlen($digits) < 5) {
|
||||
$digits = str_pad($digits, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
return substr($digits, 0, -4) . '.' . substr($digits, -4);
|
||||
}
|
||||
|
||||
public static function compareIds(string $left, string $right): int
|
||||
{
|
||||
return strlen($left) <=> strlen($right) ?: strcmp($left, $right);
|
||||
}
|
||||
|
||||
private static function sourceId(mixed $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
throw new InvalidArgumentException('本地药材 id 必须是正整数');
|
||||
}
|
||||
$value = ltrim($value, '0');
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('本地药材 id 必须是正整数');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function text(mixed $value, string $field, int $maxLength): string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException("{$field}必须是字符串");
|
||||
}
|
||||
$trimmed = preg_replace('/\A[\s\p{Z}\p{Cf}]+|[\s\p{Z}\p{Cf}]+\z/u', '', $value);
|
||||
if (!is_string($trimmed) || $trimmed === '' || mb_strlen($trimmed) > $maxLength) {
|
||||
throw new InvalidArgumentException("{$field}不能为空且不能超过 {$maxLength} 个字符");
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function addOne(string $digits): string
|
||||
{
|
||||
$characters = str_split($digits);
|
||||
for ($index = count($characters) - 1; $index >= 0; --$index) {
|
||||
if ($characters[$index] !== '9') {
|
||||
$characters[$index] = (string) ((int) $characters[$index] + 1);
|
||||
return implode('', $characters);
|
||||
}
|
||||
$characters[$index] = '0';
|
||||
}
|
||||
return '1' . implode('', $characters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class EjMedicineBootstrapService
|
||||
{
|
||||
private const SOURCE_SYSTEM = 'zyt';
|
||||
private const BOOTSTRAP_RUN_ID = 'zyt-medicine-bootstrap-v1';
|
||||
private const EXPECTED_MEDICINE_COUNT = 654;
|
||||
|
||||
public static function assertCommandGate(bool $replace, string $confirm, int $batchSize): void
|
||||
{
|
||||
if (!$replace) {
|
||||
throw new InvalidArgumentException('必须显式提供 --replace 才能替换恩济药材投影');
|
||||
}
|
||||
if (!hash_equals('RESET_TEST_CATALOG', $confirm)) {
|
||||
throw new InvalidArgumentException('必须提供 --confirm=RESET_TEST_CATALOG');
|
||||
}
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
|
||||
public static function buildBatches(array $items, int $batchSize): array
|
||||
{
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
(string) ($left['source_medicine_id'] ?? ''),
|
||||
(string) ($right['source_medicine_id'] ?? '')
|
||||
));
|
||||
|
||||
$batches = [];
|
||||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||||
$ordinal = $index + 1;
|
||||
$contentJson = json_encode(
|
||||
$batchItems,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
$contentIdentity = substr(hash('sha256', $contentJson), 0, 32);
|
||||
$batches[] = [
|
||||
'source_system' => self::SOURCE_SYSTEM,
|
||||
'import_id' => sprintf('%s-%04d-%s', self::BOOTSTRAP_RUN_ID, $ordinal, $contentIdentity),
|
||||
'items' => $batchItems,
|
||||
];
|
||||
}
|
||||
return $batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $seenCodes
|
||||
* @param array<int,bool> $seenVersions
|
||||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||||
*/
|
||||
public static function validateImportResponse(
|
||||
array $response,
|
||||
array $payload,
|
||||
array &$seenCodes,
|
||||
array &$seenVersions
|
||||
): array {
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
$body = $response['body'] ?? null;
|
||||
if (!in_array($httpStatus, [200, 201], true) || !is_array($body) || (int) ($body['code'] ?? -1) !== 0) {
|
||||
$message = is_array($body) ? trim((string) ($body['message'] ?? '')) : '';
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材导入失败 HTTP %d%s',
|
||||
$httpStatus,
|
||||
$message === '' ? '' : ':' . $message
|
||||
));
|
||||
}
|
||||
$data = $body['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('恩济药材导入响应缺少 data');
|
||||
}
|
||||
$expectedImportId = (string) ($payload['import_id'] ?? '');
|
||||
if (!hash_equals($expectedImportId, (string) ($data['import_id'] ?? ''))) {
|
||||
throw new RuntimeException('恩济药材导入响应 import_id 不匹配');
|
||||
}
|
||||
|
||||
$expectedItems = $payload['items'] ?? null;
|
||||
$responseItems = $data['items'] ?? null;
|
||||
if (!is_array($expectedItems) || !is_array($responseItems)) {
|
||||
throw new RuntimeException('恩济药材导入响应 items 无效');
|
||||
}
|
||||
$itemCount = count($expectedItems);
|
||||
$createdCount = (int) ($data['created_count'] ?? -1);
|
||||
$existingCount = (int) ($data['existing_count'] ?? -1);
|
||||
if (
|
||||
(int) ($data['item_count'] ?? -1) !== $itemCount
|
||||
|| count($responseItems) !== $itemCount
|
||||
|| $createdCount < 0
|
||||
|| $existingCount < 0
|
||||
|| $createdCount + $existingCount !== $itemCount
|
||||
|| !is_bool($data['idempotent'] ?? null)
|
||||
) {
|
||||
throw new RuntimeException('恩济药材导入响应计数或幂等标记不完整');
|
||||
}
|
||||
|
||||
$expectedSourceIds = array_map(
|
||||
static fn (array $item): string => (string) ($item['source_medicine_id'] ?? ''),
|
||||
$expectedItems
|
||||
);
|
||||
$nextSeenCodes = $seenCodes;
|
||||
$nextSeenVersions = $seenVersions;
|
||||
$normalized = [];
|
||||
$responseSourceIds = [];
|
||||
$actions = ['created' => 0, 'existing' => 0];
|
||||
foreach ($responseItems as $item) {
|
||||
if (!is_array($item)) {
|
||||
throw new RuntimeException('恩济药材导入响应 item 必须是对象');
|
||||
}
|
||||
$sourceId = self::canonicalSourceId($item['source_medicine_id'] ?? null);
|
||||
if (isset($responseSourceIds[$sourceId])) {
|
||||
throw new RuntimeException("恩济药材导入响应 source_medicine_id 重复:{$sourceId}");
|
||||
}
|
||||
$responseSourceIds[$sourceId] = true;
|
||||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||||
if ($code === '' || mb_strlen($code) > 32 || isset($nextSeenCodes[$code])) {
|
||||
throw new RuntimeException("恩济药材导入响应 medicine_code 为空、过长或重复:{$code}");
|
||||
}
|
||||
$version = filter_var($item['catalog_version'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($version === false || $version < 1 || isset($nextSeenVersions[$version])) {
|
||||
throw new RuntimeException('恩济药材导入响应 catalog_version 缺失或重复');
|
||||
}
|
||||
$action = (string) ($item['action'] ?? '');
|
||||
if (!array_key_exists($action, $actions)) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 无效');
|
||||
}
|
||||
++$actions[$action];
|
||||
$nextSeenCodes[$code] = true;
|
||||
$nextSeenVersions[$version] = true;
|
||||
$normalized[] = [
|
||||
'source_medicine_id' => $sourceId,
|
||||
'medicine_code' => $code,
|
||||
'catalog_version' => $version,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
usort($normalized, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
$left['source_medicine_id'],
|
||||
$right['source_medicine_id']
|
||||
));
|
||||
sort($expectedSourceIds, SORT_NATURAL);
|
||||
$actualSourceIds = array_column($normalized, 'source_medicine_id');
|
||||
sort($actualSourceIds, SORT_NATURAL);
|
||||
if ($expectedSourceIds !== $actualSourceIds) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 不完整或不匹配');
|
||||
}
|
||||
if ($actions['created'] !== $createdCount || $actions['existing'] !== $existingCount) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 与计数不一致');
|
||||
}
|
||||
if ($data['idempotent'] === true && ($createdCount !== 0 || $existingCount !== $itemCount)) {
|
||||
throw new RuntimeException('恩济药材导入响应幂等标记与 action 不一致');
|
||||
}
|
||||
|
||||
$seenCodes = $nextSeenCodes;
|
||||
$seenVersions = $nextSeenVersions;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionReplacer
|
||||
* @return array{source_count:int,batch_count:int,catalog:int,active_mappings:int,unmapped:int}
|
||||
*/
|
||||
public static function execute(
|
||||
int $batchSize = 100,
|
||||
?callable $sourceLoader = null,
|
||||
?callable $importer = null,
|
||||
?callable $projectionReplacer = null
|
||||
): array {
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
if (count($items) !== self::EXPECTED_MEDICINE_COUNT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'药材 bootstrap 要求恰好 %d 条启用且未删除的本地药材,当前为 %d 条',
|
||||
self::EXPECTED_MEDICINE_COUNT,
|
||||
count($items)
|
||||
));
|
||||
}
|
||||
$batches = self::buildBatches($items, $batchSize);
|
||||
if ($importer === null) {
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||||
}
|
||||
$client = new EjPharmacyClient();
|
||||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||||
}
|
||||
|
||||
$sourceById = [];
|
||||
foreach ($items as $item) {
|
||||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||||
}
|
||||
$seenCodes = [];
|
||||
$seenVersions = [];
|
||||
$projectionRows = [];
|
||||
foreach ($batches as $payload) {
|
||||
$responseItems = self::validateImportResponse(
|
||||
$importer($payload),
|
||||
$payload,
|
||||
$seenCodes,
|
||||
$seenVersions
|
||||
);
|
||||
foreach ($responseItems as $responseItem) {
|
||||
$sourceId = $responseItem['source_medicine_id'];
|
||||
$source = $sourceById[$sourceId];
|
||||
$projectionRows[] = [
|
||||
'local_medicine_id' => (int) $sourceId,
|
||||
'medicine_code' => $responseItem['medicine_code'],
|
||||
'name' => $source['name'],
|
||||
'brand' => '',
|
||||
'unit' => $source['unit'],
|
||||
'settlement_price' => $source['settlement_price'],
|
||||
'retail_price' => $source['retail_price'],
|
||||
'status' => $source['status'],
|
||||
'catalog_version' => $responseItem['catalog_version'],
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($projectionRows, static fn (array $left, array $right): int => $left['local_medicine_id'] <=> $right['local_medicine_id']);
|
||||
$verification = $projectionReplacer === null
|
||||
? self::replaceProjection($projectionRows)
|
||||
: $projectionReplacer($projectionRows);
|
||||
self::assertProjectionVerification($verification, self::EXPECTED_MEDICINE_COUNT);
|
||||
|
||||
return [
|
||||
'source_count' => count($items),
|
||||
'batch_count' => count($batches),
|
||||
'catalog' => (int) $verification['catalog'],
|
||||
'active_mappings' => (int) $verification['active_mappings'],
|
||||
'unmapped' => (int) $verification['unmapped'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $projectionRows
|
||||
* @param callable(callable():array<string,int>):array<string,int> $transaction
|
||||
* @param callable():void $referenceLocker
|
||||
* @param callable():array<string,int> $referenceCounter
|
||||
* @param callable():void $projectionLocker
|
||||
* @param callable(array<int,array<string,mixed>>):void $replacer
|
||||
* @param callable():array<string,int> $verifier
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public static function replaceProjectionWith(
|
||||
array $projectionRows,
|
||||
callable $transaction,
|
||||
callable $referenceLocker,
|
||||
callable $referenceCounter,
|
||||
callable $projectionLocker,
|
||||
callable $replacer,
|
||||
callable $verifier
|
||||
): array {
|
||||
return $transaction(static function () use (
|
||||
$projectionRows,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$projectionLocker,
|
||||
$replacer,
|
||||
$verifier
|
||||
): array {
|
||||
$referenceLocker();
|
||||
$references = $referenceCounter();
|
||||
foreach (['submissions', 'callbacks', 'business_links'] as $key) {
|
||||
if ((int) ($references[$key] ?? -1) !== 0) {
|
||||
throw new RuntimeException('恩济药材投影已有提交、回调或业务引用,禁止 bootstrap 替换');
|
||||
}
|
||||
}
|
||||
$projectionLocker();
|
||||
$replacer($projectionRows);
|
||||
$verification = $verifier();
|
||||
self::assertProjectionVerification($verification, count($projectionRows));
|
||||
return $verification;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadSourceRows(): array
|
||||
{
|
||||
return Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $projectionRows @return array<string,int> */
|
||||
private static function replaceProjection(array $projectionRows): array
|
||||
{
|
||||
return self::replaceProjectionWith(
|
||||
$projectionRows,
|
||||
static fn (callable $operation): array => Db::transaction($operation),
|
||||
static function (): void {
|
||||
Db::name('ej_pharmacy_submission')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_pharmacy_callback_inbox')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('pharmacy_submission_claim')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('tcm_prescription_order')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
},
|
||||
static function (): array {
|
||||
$directClaims = (int) Db::name('pharmacy_submission_claim')
|
||||
->where('target', 'direct')
|
||||
->count();
|
||||
$linkedOrders = (int) Db::name('tcm_prescription_order')
|
||||
->where(function ($query): void {
|
||||
$query->whereNotNull('ej_pharmacy_order_no')
|
||||
->whereOr('ej_pharmacy_submit_time', '>', 0)
|
||||
->whereOr('ej_pharmacy_status', '<>', '')
|
||||
->whereOr('ej_pharmacy_status_version', '>', 0);
|
||||
})
|
||||
->count();
|
||||
return [
|
||||
'submissions' => (int) Db::name('ej_pharmacy_submission')->count(),
|
||||
'callbacks' => (int) Db::name('ej_pharmacy_callback_inbox')->count(),
|
||||
'business_links' => $directClaims + $linkedOrders,
|
||||
];
|
||||
},
|
||||
static function () use ($projectionRows): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', 1)->lock(true)->find();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('doctor_medicine')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
|
||||
$lockedRows = Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$lockedItems = EjMedicineBootstrapItem::fromRows($lockedRows);
|
||||
$expectedItems = array_map(static fn (array $row): array => [
|
||||
'source_medicine_id' => (string) $row['local_medicine_id'],
|
||||
'name' => (string) $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => (string) $row['unit'],
|
||||
'settlement_price' => (string) $row['settlement_price'],
|
||||
'retail_price' => (string) $row['retail_price'],
|
||||
'status' => (int) $row['status'],
|
||||
], $projectionRows);
|
||||
if ($lockedItems !== $expectedItems) {
|
||||
throw new RuntimeException('本地药材源快照在远端导入期间发生变化,已拒绝替换投影');
|
||||
}
|
||||
},
|
||||
static function (array $rows): void {
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->delete();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->delete();
|
||||
$now = time();
|
||||
$catalogRows = [];
|
||||
$mappingRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$catalogRows[] = [
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'name' => $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => $row['unit'],
|
||||
'settlement_price' => $row['settlement_price'],
|
||||
'retail_price' => $row['retail_price'],
|
||||
'status' => $row['status'],
|
||||
'catalog_version' => $row['catalog_version'],
|
||||
'remote_deleted' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$mappingRows[] = [
|
||||
'local_medicine_id' => $row['local_medicine_id'],
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'status' => 1,
|
||||
'operator_id' => 0,
|
||||
'operator_name' => 'system-bootstrap',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
}
|
||||
foreach (array_chunk($catalogRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_catalog')->insertAll($chunk);
|
||||
}
|
||||
foreach (array_chunk($mappingRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_mapping')->insertAll($chunk);
|
||||
}
|
||||
|
||||
$stateValues = [
|
||||
'cursor' => 0,
|
||||
'last_success_time' => $now,
|
||||
'last_failure_time' => 0,
|
||||
'last_error_summary' => '',
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$updated = Db::name('ej_pharmacy_sync_state')->where('id', 1)->update($stateValues);
|
||||
if ($updated === 0 && !Db::name('ej_pharmacy_sync_state')->where('id', 1)->find()) {
|
||||
Db::name('ej_pharmacy_sync_state')->insert($stateValues + ['id' => 1, 'create_time' => $now]);
|
||||
}
|
||||
},
|
||||
static function () use ($projectionRows): array {
|
||||
$expectedLocalIds = array_map(
|
||||
static fn (array $row): int => (int) $row['local_medicine_id'],
|
||||
$projectionRows
|
||||
);
|
||||
$actualLocalIds = array_map(
|
||||
'intval',
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('local_medicine_id', 'asc')
|
||||
->column('local_medicine_id')
|
||||
);
|
||||
if ($expectedLocalIds !== $actualLocalIds) {
|
||||
throw new RuntimeException('恩济药材 bootstrap 映射未精确覆盖全部本地药材 id');
|
||||
}
|
||||
return [
|
||||
'catalog' => (int) Db::name('ej_medicine_catalog')->count(),
|
||||
'active_mappings' => count($actualLocalIds),
|
||||
'unmapped' => (int) Db::name('doctor_medicine')->alias('l')
|
||||
->leftJoin(
|
||||
'ej_medicine_mapping m',
|
||||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||||
)
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereNull('m.id')
|
||||
->count('l.id'),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $verification */
|
||||
private static function assertProjectionVerification(array $verification, int $expected): void
|
||||
{
|
||||
if (
|
||||
(int) ($verification['catalog'] ?? -1) !== $expected
|
||||
|| (int) ($verification['active_mappings'] ?? -1) !== $expected
|
||||
|| (int) ($verification['unmapped'] ?? -1) !== 0
|
||||
) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材 bootstrap 最终验证失败:catalog=%d active_mappings=%d unmapped=%d expected=%d',
|
||||
(int) ($verification['catalog'] ?? -1),
|
||||
(int) ($verification['active_mappings'] ?? -1),
|
||||
(int) ($verification['unmapped'] ?? -1),
|
||||
$expected
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static function canonicalSourceId(mixed $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
$value = ltrim($value, '0');
|
||||
if ($value === '') {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjMedicineCatalogSyncPolicy
|
||||
{
|
||||
/** @return array{items:array<int,array<string,mixed>>,next_cursor:int,has_more:bool} */
|
||||
public static function parsePage(array $response, int $cursor): array
|
||||
{
|
||||
$body = is_array($response['body'] ?? null) ? $response['body'] : [];
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
if ($httpStatus < 200 || $httpStatus >= 300 || (int) ($body['code'] ?? -1) !== 0) {
|
||||
$message = trim((string) ($body['message'] ?? ''));
|
||||
throw new RuntimeException($message !== '' ? $message : '洛阳药房药材目录同步失败');
|
||||
}
|
||||
|
||||
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
|
||||
$items = is_array($data['items'] ?? null) ? array_values(array_filter(
|
||||
$data['items'],
|
||||
static fn ($item): bool => is_array($item)
|
||||
)) : [];
|
||||
$nextCursor = max(0, (int) ($data['next_cursor'] ?? $cursor));
|
||||
$hasMore = !empty($data['has_more']);
|
||||
if ($hasMore && $nextCursor <= $cursor) {
|
||||
throw new RuntimeException('洛阳药房药材目录游标未推进,已停止同步');
|
||||
}
|
||||
|
||||
return ['items' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $existing
|
||||
* @param array<string,mixed> $remote
|
||||
* @return array{action:string,values:array<string,mixed>,deactivated:int}
|
||||
*/
|
||||
public static function merge(?array $existing, array $remote): array
|
||||
{
|
||||
$code = trim((string) ($remote['medicine_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
throw new RuntimeException('洛阳药房药材目录包含空 medicine_code');
|
||||
}
|
||||
|
||||
$deleted = !empty($remote['deleted']) || !empty($remote['remote_deleted']);
|
||||
$values = [
|
||||
'medicine_code' => $code,
|
||||
'name' => trim((string) ($remote['name'] ?? '')),
|
||||
'brand' => trim((string) ($remote['brand'] ?? '')),
|
||||
'unit' => trim((string) ($remote['unit'] ?? '')),
|
||||
'settlement_price' => self::decimal($remote['settlement_price'] ?? 0),
|
||||
'retail_price' => self::decimal($remote['retail_price'] ?? 0),
|
||||
'status' => $deleted ? 0 : (int) ($remote['status'] ?? 0),
|
||||
'catalog_version' => max(0, (int) ($remote['catalog_version'] ?? 0)),
|
||||
'remote_deleted' => $deleted ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($existing === null) {
|
||||
return [
|
||||
'action' => 'created',
|
||||
'values' => $values,
|
||||
'deactivated' => $values['status'] === 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$existingComparable = [
|
||||
'medicine_code' => trim((string) ($existing['medicine_code'] ?? '')),
|
||||
'name' => trim((string) ($existing['name'] ?? '')),
|
||||
'brand' => trim((string) ($existing['brand'] ?? '')),
|
||||
'unit' => trim((string) ($existing['unit'] ?? '')),
|
||||
'settlement_price' => self::decimal($existing['settlement_price'] ?? 0),
|
||||
'retail_price' => self::decimal($existing['retail_price'] ?? 0),
|
||||
'status' => (int) ($existing['status'] ?? 0),
|
||||
'catalog_version' => max(0, (int) ($existing['catalog_version'] ?? 0)),
|
||||
'remote_deleted' => (int) ($existing['remote_deleted'] ?? 0),
|
||||
];
|
||||
$deactivated = $existingComparable['status'] === 1 && $values['status'] === 0 ? 1 : 0;
|
||||
|
||||
return [
|
||||
'action' => $existingComparable === $values ? 'unchanged' : 'updated',
|
||||
'values' => $values,
|
||||
'deactivated' => $deactivated,
|
||||
];
|
||||
}
|
||||
|
||||
private static function decimal(mixed $value): string
|
||||
{
|
||||
return number_format(max(0.0, (float) $value), 4, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use app\common\model\pharmacy\EjMedicineCatalog;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncService
|
||||
{
|
||||
private const STATE_ID = 1;
|
||||
private const LOCK_TTL = 600;
|
||||
|
||||
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
|
||||
public static function sync(int $limit = 200): array
|
||||
{
|
||||
if (!(bool) Config::get('ej_pharmacy.catalog_sync_enabled', false)) {
|
||||
throw new RuntimeException('恩济药房增量目录同步已关闭,请使用一次性 bootstrap 命令初始化药材目录');
|
||||
}
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('洛阳药房接口未启用或配置不完整');
|
||||
}
|
||||
|
||||
self::ensureStateRow();
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$client = new EjPharmacyClient();
|
||||
$workflow = new EjMedicineCatalogSyncWorkflow(
|
||||
static fn (): bool => self::acquireLock($token),
|
||||
static fn () => self::releaseLock($token),
|
||||
static fn (): int => (int) (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->value('cursor') ?? 0),
|
||||
static fn (int $cursor, int $pageLimit): array => $client->medicines($cursor, $pageLimit),
|
||||
static fn (array $items, int $nextCursor): array => self::mergePage($items, $nextCursor, $token),
|
||||
static function (int $cursor) use ($token): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)->update([
|
||||
'cursor' => $cursor,
|
||||
'last_success_time' => time(),
|
||||
'last_error_summary' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
},
|
||||
static function (int $cursor, string $error) use ($token): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)->update([
|
||||
'cursor' => $cursor,
|
||||
'last_failure_time' => time(),
|
||||
'last_error_summary' => $error,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
return $workflow->sync($limit);
|
||||
}
|
||||
|
||||
private static function ensureStateRow(): void
|
||||
{
|
||||
if (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->find()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Db::name('ej_pharmacy_sync_state')->insert([
|
||||
'id' => self::STATE_ID,
|
||||
'cursor' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (!self::isDuplicateKey($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function acquireLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
$updated = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
|
||||
})
|
||||
->update([
|
||||
'lock_token' => $token,
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return $updated === 1;
|
||||
}
|
||||
|
||||
private static function releaseLock(string $token): void
|
||||
{
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->update(['lock_token' => '', 'lock_expires_at' => 0, 'update_time' => time()]);
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int,unchanged:int,deactivated:int} */
|
||||
private static function mergePage(array $items, int $nextCursor, string $token): array
|
||||
{
|
||||
return Db::transaction(function () use ($items, $nextCursor, $token): array {
|
||||
$stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'deactivated' => 0];
|
||||
foreach ($items as $item) {
|
||||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||||
$model = $code === '' ? null : EjMedicineCatalog::where('medicine_code', $code)->lock(true)->find();
|
||||
$result = EjMedicineCatalogSyncPolicy::merge($model ? $model->toArray() : null, $item);
|
||||
$values = $result['values'];
|
||||
if ($result['action'] === 'created') {
|
||||
EjMedicineCatalog::create($values);
|
||||
} elseif ($result['action'] === 'updated' && $model) {
|
||||
unset($values['medicine_code']);
|
||||
$model->save($values);
|
||||
}
|
||||
++$stats[$result['action']];
|
||||
$stats['deactivated'] += $result['deactivated'];
|
||||
|
||||
if ($result['deactivated'] === 1) {
|
||||
$now = time();
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('medicine_code', $code)
|
||||
->where('status', 1)
|
||||
->update(['status' => 0, 'delete_time' => $now, 'update_time' => $now]);
|
||||
}
|
||||
}
|
||||
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$state || !hash_equals((string) $state['lock_token'], $token)) {
|
||||
throw new RuntimeException('洛阳药房目录同步锁已失效,请重试');
|
||||
}
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->update([
|
||||
'cursor' => $nextCursor,
|
||||
'lock_expires_at' => time() + self::LOCK_TTL,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return $stats;
|
||||
});
|
||||
}
|
||||
|
||||
private static function isDuplicateKey(Throwable $exception): bool
|
||||
{
|
||||
return (string) $exception->getCode() === '23000'
|
||||
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncWorkflow
|
||||
{
|
||||
private $acquireLock;
|
||||
private $releaseLock;
|
||||
private $loadCursor;
|
||||
private $fetchPage;
|
||||
private $mergePage;
|
||||
private $markSuccess;
|
||||
private $markFailure;
|
||||
|
||||
public function __construct(
|
||||
callable $acquireLock,
|
||||
callable $releaseLock,
|
||||
callable $loadCursor,
|
||||
callable $fetchPage,
|
||||
callable $mergePage,
|
||||
callable $markSuccess,
|
||||
callable $markFailure
|
||||
) {
|
||||
$this->acquireLock = $acquireLock;
|
||||
$this->releaseLock = $releaseLock;
|
||||
$this->loadCursor = $loadCursor;
|
||||
$this->fetchPage = $fetchPage;
|
||||
$this->mergePage = $mergePage;
|
||||
$this->markSuccess = $markSuccess;
|
||||
$this->markFailure = $markFailure;
|
||||
}
|
||||
|
||||
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
|
||||
public function sync(int $limit = 200): array
|
||||
{
|
||||
if (!(bool) ($this->acquireLock)()) {
|
||||
throw new DomainException('洛阳药房目录正在同步,请稍后重试');
|
||||
}
|
||||
|
||||
$cursor = 0;
|
||||
$stats = [
|
||||
'pages' => 0,
|
||||
'pulled' => 0,
|
||||
'received' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'deactivated' => 0,
|
||||
'cursor' => $cursor,
|
||||
];
|
||||
|
||||
try {
|
||||
$cursor = max(0, (int) ($this->loadCursor)());
|
||||
$stats['cursor'] = $cursor;
|
||||
for ($page = 0; $page < 1000; ++$page) {
|
||||
$parsed = EjMedicineCatalogSyncPolicy::parsePage(
|
||||
($this->fetchPage)($cursor, min(max($limit, 1), 500)),
|
||||
$cursor
|
||||
);
|
||||
$merged = ($this->mergePage)($parsed['items'], $parsed['next_cursor']);
|
||||
++$stats['pages'];
|
||||
$pulled = count($parsed['items']);
|
||||
$stats['pulled'] += $pulled;
|
||||
$stats['received'] += $pulled;
|
||||
foreach (['created', 'updated', 'unchanged', 'deactivated'] as $key) {
|
||||
$stats[$key] += (int) ($merged[$key] ?? 0);
|
||||
}
|
||||
$cursor = $parsed['next_cursor'];
|
||||
$stats['cursor'] = $cursor;
|
||||
if (!$parsed['has_more']) {
|
||||
($this->markSuccess)($cursor, $stats);
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('洛阳药房药材目录分页超过安全上限');
|
||||
} catch (Throwable $exception) {
|
||||
($this->markFailure)($cursor, self::summarizeError($exception->getMessage()));
|
||||
throw $exception;
|
||||
} finally {
|
||||
($this->releaseLock)();
|
||||
}
|
||||
}
|
||||
|
||||
public static function summarizeError(string $message): string
|
||||
{
|
||||
$message = preg_replace('/(app[_-]?secret|signature|token|authorization)\s*[:=]\s*[^\s,;]+/i', '$1=[redacted]', $message) ?? $message;
|
||||
return mb_substr(trim($message), 0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjMedicineMappingPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $local @param array<string,mixed> $remote */
|
||||
public static function assertValid(array $local, array $remote): void
|
||||
{
|
||||
if ((int) ($local['id'] ?? 0) <= 0) {
|
||||
throw new DomainException('本地药材不存在');
|
||||
}
|
||||
if (!empty($local['delete_time'])) {
|
||||
throw new DomainException('本地药材已删除,不能建立映射');
|
||||
}
|
||||
if ((int) ($local['status'] ?? 0) !== 1) {
|
||||
throw new DomainException('本地药材已停用,不能建立映射');
|
||||
}
|
||||
|
||||
if (trim((string) ($remote['medicine_code'] ?? '')) === '') {
|
||||
throw new DomainException('洛阳药房药材不存在');
|
||||
}
|
||||
if (!empty($remote['remote_deleted'])) {
|
||||
throw new DomainException('洛阳药房药材已删除,不能建立映射');
|
||||
}
|
||||
if ((int) ($remote['status'] ?? 0) !== 1) {
|
||||
throw new DomainException('洛阳药房药材已停用,不能建立映射');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $local
|
||||
* @param array<string,mixed>|null $mapping
|
||||
* @return array{mapping_id:int,already_unlinked:bool}
|
||||
*/
|
||||
public static function unlinkDecision(array $local, ?array $mapping): array
|
||||
{
|
||||
$localId = (int) ($local['id'] ?? 0);
|
||||
if ($localId <= 0) {
|
||||
throw new DomainException('本地药材不存在');
|
||||
}
|
||||
if ($mapping === null) {
|
||||
return ['mapping_id' => 0, 'already_unlinked' => true];
|
||||
}
|
||||
if ((int) ($mapping['local_medicine_id'] ?? 0) !== $localId) {
|
||||
throw new DomainException('药材映射归属不匹配');
|
||||
}
|
||||
|
||||
$mappingId = (int) ($mapping['id'] ?? 0);
|
||||
if ($mappingId <= 0) {
|
||||
throw new DomainException('药材映射记录无效');
|
||||
}
|
||||
return [
|
||||
'mapping_id' => $mappingId,
|
||||
'already_unlinked' => (int) ($mapping['status'] ?? 0) !== 1 || !empty($mapping['delete_time']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyCallbackFailureTransition
|
||||
{
|
||||
/** @param callable(int,array<string,mixed>,string):bool $conditionalUpdate */
|
||||
public static function apply(int $inboxId, string $error, callable $conditionalUpdate): bool
|
||||
{
|
||||
if ($inboxId <= 0) {
|
||||
throw new InvalidArgumentException('callback inbox id is missing');
|
||||
}
|
||||
|
||||
return (bool) Closure::fromCallable($conditionalUpdate)(
|
||||
$inboxId,
|
||||
[
|
||||
'process_status' => 'FAILED',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'update_time' => time(),
|
||||
],
|
||||
'PROCESSED'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjPharmacyCallbackRetryException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjPharmacyCallbackWorkflow
|
||||
{
|
||||
private Closure $loadInbox;
|
||||
private Closure $createInbox;
|
||||
private Closure $reloadInbox;
|
||||
private Closure $process;
|
||||
private Closure $markFailed;
|
||||
private Closure $isDuplicateKey;
|
||||
|
||||
public function __construct(
|
||||
callable $loadInbox,
|
||||
callable $createInbox,
|
||||
callable $reloadInbox,
|
||||
callable $process,
|
||||
callable $markFailed,
|
||||
callable $isDuplicateKey
|
||||
) {
|
||||
$this->loadInbox = Closure::fromCallable($loadInbox);
|
||||
$this->createInbox = Closure::fromCallable($createInbox);
|
||||
$this->reloadInbox = Closure::fromCallable($reloadInbox);
|
||||
$this->process = Closure::fromCallable($process);
|
||||
$this->markFailed = Closure::fromCallable($markFailed);
|
||||
$this->isDuplicateKey = Closure::fromCallable($isDuplicateKey);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array<string,mixed> */
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
$eventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
if ($eventId === '') {
|
||||
return ['http_status' => 422, 'message' => 'event_id is required', 'duplicate' => false];
|
||||
}
|
||||
|
||||
$inbox = ($this->loadInbox)($eventId);
|
||||
if (is_array($inbox) && strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
|
||||
}
|
||||
|
||||
if (!is_array($inbox)) {
|
||||
try {
|
||||
$inbox = ($this->createInbox)($payload);
|
||||
} catch (Throwable $exception) {
|
||||
if (!(bool) ($this->isDuplicateKey)($exception)) {
|
||||
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
}
|
||||
$inbox = ($this->reloadInbox)($eventId);
|
||||
if (!is_array($inbox)) {
|
||||
return ['http_status' => 500, 'message' => 'callback inbox race could not be reloaded', 'duplicate' => false];
|
||||
}
|
||||
if (strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
($this->process)($inbox, $payload);
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => false];
|
||||
} catch (EjPharmacyCallbackRetryException $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 503, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
} catch (InvalidArgumentException|DomainException $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 422, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
} catch (Throwable $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
|
||||
final class EjPharmacyClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $appKey;
|
||||
private string $appSecret;
|
||||
/** @var null|Closure(string,string,string,array<int,string>):array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private ?Closure $transport;
|
||||
|
||||
public function __construct(
|
||||
?string $baseUrl = null,
|
||||
?string $appKey = null,
|
||||
?string $appSecret = null,
|
||||
?callable $transport = null
|
||||
)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl ?? (string) Config::get('ej_pharmacy.base_url', ''), '/');
|
||||
$this->appKey = $appKey ?? (string) Config::get('ej_pharmacy.app_key', '');
|
||||
$this->appSecret = $appSecret ?? (string) Config::get('ej_pharmacy.app_secret', '');
|
||||
if ($this->baseUrl === '' || $this->appKey === '' || $this->appSecret === '') {
|
||||
throw new RuntimeException('恩济药房接口未配置完整');
|
||||
}
|
||||
$this->transport = $transport === null ? null : Closure::fromCallable($transport);
|
||||
}
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return (bool) Config::get('ej_pharmacy.enabled', false)
|
||||
&& trim((string) Config::get('ej_pharmacy.base_url', '')) !== ''
|
||||
&& trim((string) Config::get('ej_pharmacy.app_key', '')) !== ''
|
||||
&& trim((string) Config::get('ej_pharmacy.app_secret', '')) !== '';
|
||||
}
|
||||
|
||||
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function medicines(int $after = 0, int $limit = 100): array
|
||||
{
|
||||
return $this->request('GET', '/api/openapi/v1/medicines', null, [
|
||||
'after' => max($after, 0),
|
||||
'limit' => min(max($limit, 1), 500),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function importMedicines(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/api/openapi/v1/medicine-imports', $payload);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function createPrescriptionOrder(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/api/openapi/v1/prescription-orders', $payload);
|
||||
}
|
||||
|
||||
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function prescriptionOrder(string $sourceOrderNo, int $sourceRevision = 0): array
|
||||
{
|
||||
$query = ['source_system' => 'zyt'];
|
||||
if ($sourceRevision > 0) {
|
||||
$query['source_revision'] = $sourceRevision;
|
||||
}
|
||||
return $this->request(
|
||||
'GET',
|
||||
'/api/openapi/v1/prescription-orders/' . rawurlencode($sourceOrderNo),
|
||||
null,
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $payload @param array<string,int|string> $query @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private function request(string $method, string $path, ?array $payload = null, array $query = []): array
|
||||
{
|
||||
$queryString = $query === [] ? '' : http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
$pathWithQuery = $path . ($queryString !== '' ? '?' . $queryString : '');
|
||||
$body = $payload === null
|
||||
? ''
|
||||
: (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$timestamp = (string) time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$requestId = bin2hex(random_bytes(16));
|
||||
$canonical = EjPharmacySignature::canonical($method, $pathWithQuery, $timestamp, $nonce, $body);
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'X-App-Key: ' . $this->appKey,
|
||||
'X-Timestamp: ' . $timestamp,
|
||||
'X-Nonce: ' . $nonce,
|
||||
'X-Signature: ' . EjPharmacySignature::sign($this->appSecret, $canonical),
|
||||
'X-Request-Id: ' . $requestId,
|
||||
'Expect:',
|
||||
];
|
||||
|
||||
if ($this->transport !== null) {
|
||||
return ($this->transport)(strtoupper($method), $pathWithQuery, $body, $headers);
|
||||
}
|
||||
|
||||
$configuredTransport = strtolower((string) Config::get('ej_pharmacy.http_transport', 'auto'));
|
||||
$curlSsl = strtoupper((string) ((function_exists('curl_version') ? curl_version() : [])['ssl_version'] ?? ''));
|
||||
if ($configuredTransport === 'openssl'
|
||||
|| ($configuredTransport === 'auto' && str_starts_with($curlSsl, 'NSS/'))
|
||||
) {
|
||||
return $this->requestWithOpenSsl($method, $pathWithQuery, $body, $headers, $requestId);
|
||||
}
|
||||
|
||||
$ch = curl_init($this->baseUrl . $pathWithQuery);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => (int) Config::get('ej_pharmacy.connect_timeout', 5),
|
||||
CURLOPT_TIMEOUT => (int) Config::get('ej_pharmacy.request_timeout', 30),
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($payload !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('恩济药房通信失败:' . $error);
|
||||
}
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('恩济药房返回了无效 JSON,HTTP ' . $httpStatus);
|
||||
}
|
||||
|
||||
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
|
||||
}
|
||||
|
||||
/** @param array<int,string> $headers @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private function requestWithOpenSsl(
|
||||
string $method,
|
||||
string $path,
|
||||
string $body,
|
||||
array $headers,
|
||||
string $requestId
|
||||
): array {
|
||||
$ssl = [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'allow_self_signed' => false,
|
||||
];
|
||||
$caFile = trim((string) Config::get('ej_pharmacy.ca_file', ''));
|
||||
if ($caFile !== '') {
|
||||
$ssl['cafile'] = $caFile;
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => strtoupper($method),
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'ignore_errors' => true,
|
||||
'timeout' => (int) Config::get('ej_pharmacy.request_timeout', 30),
|
||||
'protocol_version' => 1.1,
|
||||
],
|
||||
'ssl' => $ssl,
|
||||
]);
|
||||
$raw = @file_get_contents($this->baseUrl . $path, false, $context);
|
||||
if ($raw === false) {
|
||||
$lastError = error_get_last();
|
||||
$message = is_array($lastError) ? (string) ($lastError['message'] ?? '') : '';
|
||||
throw new RuntimeException('恩济药房通信失败:' . ($message !== '' ? $message : 'OpenSSL 请求失败'));
|
||||
}
|
||||
|
||||
$httpStatus = 0;
|
||||
$responseHeaders = $http_response_header ?? [];
|
||||
foreach (array_reverse($responseHeaders) as $responseHeader) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', $responseHeader, $matches)) {
|
||||
$httpStatus = (int) $matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('恩济药房返回了无效 JSON,HTTP ' . $httpStatus);
|
||||
}
|
||||
|
||||
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyPayload
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed> $prescription
|
||||
* @param array<int,string> $medicineMappings
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function build(array $order, array $prescription, array $medicineMappings, int $revision): array
|
||||
{
|
||||
$orderNo = trim((string) ($order['order_no'] ?? ''));
|
||||
if ($orderNo === '') {
|
||||
throw new InvalidArgumentException('order_no is required');
|
||||
}
|
||||
$herbs = $prescription['herbs'] ?? [];
|
||||
if (!is_array($herbs) || $herbs === []) {
|
||||
throw new InvalidArgumentException('prescription herbs are required');
|
||||
}
|
||||
$medicines = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
$medicineCode = trim((string) ($medicineMappings[$medicineId] ?? ''));
|
||||
if ($medicineId <= 0 || $medicineCode === '') {
|
||||
throw new DomainException('Unmapped medicine: ' . ($name !== '' ? $name : (string) $medicineId));
|
||||
}
|
||||
$quantity = (float) ($herb['dose'] ?? $herb['dosage'] ?? $herb['quantity'] ?? 0);
|
||||
if ($quantity <= 0) {
|
||||
throw new InvalidArgumentException('Medicine quantity must be positive: ' . $name);
|
||||
}
|
||||
$medicines[] = [
|
||||
'source_medicine_id' => (string) $medicineId,
|
||||
'medicine_code' => $medicineCode,
|
||||
'name' => $name,
|
||||
'quantity' => number_format($quantity, 4, '.', ''),
|
||||
'unit' => trim((string) ($herb['unit'] ?? '克')) ?: '克',
|
||||
'usage' => trim((string) ($herb['usage'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($medicines === []) {
|
||||
throw new InvalidArgumentException('prescription herbs are required');
|
||||
}
|
||||
|
||||
return [
|
||||
'source_system' => 'zyt',
|
||||
'source_order_no' => $orderNo,
|
||||
'source_revision' => max($revision, 1),
|
||||
'patient' => [
|
||||
'source_patient_id' => (string) ($prescription['patient_id'] ?? ''),
|
||||
'name' => trim((string) ($prescription['patient_name'] ?? $order['recipient_name'] ?? '')),
|
||||
'id_card' => trim((string) ($prescription['id_card'] ?? '')),
|
||||
'mobile' => trim((string) ($prescription['phone'] ?? $order['recipient_phone'] ?? '')),
|
||||
],
|
||||
'shipping' => [
|
||||
'recipient_name' => trim((string) ($order['recipient_name'] ?? $prescription['patient_name'] ?? '')),
|
||||
'recipient_mobile' => trim((string) ($order['recipient_phone'] ?? $prescription['phone'] ?? '')),
|
||||
'province' => trim((string) ($order['shipping_province'] ?? '')),
|
||||
'city' => trim((string) ($order['shipping_city'] ?? '')),
|
||||
'district' => trim((string) ($order['shipping_district'] ?? '')),
|
||||
'address' => trim((string) ($order['shipping_address'] ?? '')),
|
||||
],
|
||||
'prescription' => [
|
||||
'source_prescription_id' => (string) ($prescription['id'] ?? ''),
|
||||
'diagnosis' => trim((string) (
|
||||
$prescription['clinical_diagnosis']
|
||||
?? $prescription['diagnosis']
|
||||
?? $prescription['diagnosis_name']
|
||||
?? ''
|
||||
)),
|
||||
'processing_type' => trim((string) ($prescription['processing_type'] ?? 'decoction')) ?: 'decoction',
|
||||
'dose_count' => max((int) ($order['dose_count'] ?? $prescription['dose_count'] ?? 1), 1),
|
||||
'doctor' => [
|
||||
'source_doctor_id' => (string) ($prescription['creator_id'] ?? $prescription['doctor_id'] ?? ''),
|
||||
'name' => trim((string) ($prescription['doctor_name'] ?? '')),
|
||||
],
|
||||
'doctor_signature' => is_array($prescription['doctor_signature'] ?? null)
|
||||
? $prescription['doctor_signature']
|
||||
: [],
|
||||
'medicines' => $medicines,
|
||||
'instructions' => trim((string) (
|
||||
$prescription['usage_instruction']
|
||||
?? $prescription['instructions']
|
||||
?? $prescription['advice']
|
||||
?? ''
|
||||
)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacyShipmentPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isShippedEvent(array $payload): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'ORDER_SHIPPED'
|
||||
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'SHIPPED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isWorkflowStepEvent(array $payload): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'WORKFLOW_STEP_COMPLETED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isCompletedEvent(array $payload): bool
|
||||
{
|
||||
// A workflow node (including the final “ship” node) is only a
|
||||
// pharmacy-process update. It must not close the ZYT business order.
|
||||
return !self::isWorkflowStepEvent($payload)
|
||||
&& strtoupper(trim((string) ($payload['status'] ?? ''))) === 'COMPLETED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isRejectedEvent(array $payload): bool
|
||||
{
|
||||
return in_array(strtoupper(trim((string) ($payload['event_type'] ?? ''))), [
|
||||
'REVIEW_REJECTED',
|
||||
'INVENTORY_SHORTAGE',
|
||||
], true)
|
||||
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($payload['review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function nextFulfillmentStatus(
|
||||
int $currentStatus,
|
||||
array $payload,
|
||||
?int $rollbackStatus = null
|
||||
): int
|
||||
{
|
||||
if (self::isRejectedEvent($payload)) {
|
||||
// EJ rejection means the pharmacy did not accept the submission;
|
||||
// it must not turn the ZYT business order into a customer refusal.
|
||||
// Restore the status captured immediately before the submission.
|
||||
return $rollbackStatus !== null && $rollbackStatus > 0
|
||||
? $rollbackStatus
|
||||
: ($currentStatus === 9 ? 2 : $currentStatus);
|
||||
}
|
||||
if (self::isWorkflowStepEvent($payload)) {
|
||||
// EJ workflow callbacks are informational nodes. Keep the ZYT
|
||||
// fulfillment status unchanged; only explicit shipment/completion
|
||||
// events may advance it.
|
||||
return $currentStatus;
|
||||
}
|
||||
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
|
||||
return 5;
|
||||
}
|
||||
if (self::isCompletedEvent($payload) && in_array($currentStatus, [1, 2, 5], true)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return $currentStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacySignature
|
||||
{
|
||||
public static function canonical(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
||||
{
|
||||
return implode("\n", [
|
||||
strtoupper(trim($method)),
|
||||
$path,
|
||||
trim($timestamp),
|
||||
trim($nonce),
|
||||
hash('sha256', $body),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function sign(string $secret, string $canonical): string
|
||||
{
|
||||
return hash_hmac('sha256', $canonical, $secret);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $canonical, string $signature): bool
|
||||
{
|
||||
return $secret !== '' && $signature !== '' && hash_equals(self::sign($secret, $canonical), strtolower(trim($signature)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjPharmacyTrackingPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed>|null $current Active tracking for this order.
|
||||
* @param array<string,mixed>|null $matching Tracking already owning the incoming number.
|
||||
* @return array{action:string,archive_current:bool}
|
||||
*/
|
||||
public static function select(?array $current, ?array $matching, int $orderId, string $trackingNumber): array
|
||||
{
|
||||
$trackingNumber = trim($trackingNumber);
|
||||
if ($current !== null && trim((string) ($current['tracking_number'] ?? '')) === $trackingNumber) {
|
||||
return ['action' => 'REUSE_CURRENT', 'archive_current' => false];
|
||||
}
|
||||
|
||||
if ($matching !== null) {
|
||||
$ownerOrderId = (int) ($matching['order_id'] ?? 0);
|
||||
if ($ownerOrderId !== 0 && $ownerOrderId !== $orderId) {
|
||||
throw new DomainException('该运单号已关联其他订单,禁止重新绑定');
|
||||
}
|
||||
|
||||
return ['action' => 'REUSE_MATCHING', 'archive_current' => $current !== null];
|
||||
}
|
||||
|
||||
return ['action' => 'CREATE', 'archive_current' => $current !== null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class LockedPharmacySnapshotMutation
|
||||
{
|
||||
/**
|
||||
* @param callable():array<string,mixed> $lockOrder
|
||||
* @param callable(array<string,mixed>):?array<string,mixed> $lockClaim
|
||||
* @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation
|
||||
*/
|
||||
public static function run(
|
||||
callable $lockOrder,
|
||||
callable $lockClaim,
|
||||
callable $mutation,
|
||||
bool $assertMutable = true
|
||||
): mixed {
|
||||
$order = Closure::fromCallable($lockOrder)();
|
||||
if ($order === []) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
$claim = Closure::fromCallable($lockClaim)($order);
|
||||
if ($assertMutable) {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim);
|
||||
}
|
||||
|
||||
$result = Closure::fromCallable($mutation)($order, $claim);
|
||||
if ($result === false) {
|
||||
throw new DomainException('受保护变更未完成,事务已回滚');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation */
|
||||
public static function execute(
|
||||
int $orderId,
|
||||
callable $mutation,
|
||||
bool $assertMutable = true,
|
||||
int $revision = 1
|
||||
): mixed {
|
||||
return Db::transaction(static fn (): mixed => self::run(
|
||||
static fn (): array => (array) (Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find() ?: []),
|
||||
static fn (): ?array => Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->lock(true)
|
||||
->find() ?: null,
|
||||
$mutation,
|
||||
$assertMutable
|
||||
));
|
||||
}
|
||||
|
||||
/** @param callable(array<int,array<string,mixed>>):mixed $mutation */
|
||||
public static function executeForPrescription(int $prescriptionId, callable $mutation): mixed
|
||||
{
|
||||
return Db::transaction(static function () use ($prescriptionId, $mutation): mixed {
|
||||
$orders = Db::name('tcm_prescription_order')
|
||||
->where('prescription_id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->lock(true)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($orders as $order) {
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', (int) $order['id'])
|
||||
->where('source_revision', 1)
|
||||
->lock(true)
|
||||
->find();
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim ?: null);
|
||||
}
|
||||
|
||||
$result = Closure::fromCallable($mutation)($orders);
|
||||
if ($result === false) {
|
||||
throw new DomainException('受保护变更未完成,事务已回滚');
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyHerbIdentityResolver
|
||||
{
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $herbs
|
||||
* @param callable(array<int,int>):array<int,array<string,mixed>> $loadByIds
|
||||
* @param callable(array<int,string>):array<int,array<string,mixed>> $loadByNames
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public static function resolve(array $herbs, callable $loadByIds, callable $loadByNames): array
|
||||
{
|
||||
$ids = [];
|
||||
$names = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$byId = [];
|
||||
foreach ($ids === [] ? [] : $loadByIds(array_values(array_unique($ids))) as $row) {
|
||||
if (self::isActive($row)) {
|
||||
$byId[(int) $row['id']] = $row;
|
||||
}
|
||||
}
|
||||
$byName = [];
|
||||
foreach ($names === [] ? [] : $loadByNames(array_values(array_unique($names))) as $row) {
|
||||
if (!self::isActive($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$byName[$name][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$row = $byId[$id] ?? null;
|
||||
if (!is_array($row)) {
|
||||
throw new DomainException('药材“' . ($name !== '' ? $name : (string) $id) . '”对应的本地药材不存在或已停用');
|
||||
}
|
||||
} else {
|
||||
if ($name === '') {
|
||||
throw new DomainException('药材名称不能为空');
|
||||
}
|
||||
$candidates = $byName[$name] ?? [];
|
||||
if (count($candidates) === 0) {
|
||||
throw new DomainException('药材“' . $name . '”未在本地药材库中找到');
|
||||
}
|
||||
if (count($candidates) !== 1) {
|
||||
throw new DomainException('药材“' . $name . '”存在多个同名记录,请重新选择具体药材');
|
||||
}
|
||||
$row = $candidates[0];
|
||||
$id = (int) $row['id'];
|
||||
}
|
||||
|
||||
$herb['medicine_id'] = $id;
|
||||
$herb['name'] = trim((string) ($row['name'] ?? $name));
|
||||
unset($herb['id'], $herb['title'], $herb['local_medicine_id']);
|
||||
$resolved[] = $herb;
|
||||
}
|
||||
|
||||
if ($resolved === []) {
|
||||
throw new DomainException('处方药材不能为空');
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private static function isActive(array $row): bool
|
||||
{
|
||||
return (int) ($row['id'] ?? 0) > 0
|
||||
&& (int) ($row['status'] ?? 0) === 1
|
||||
&& ($row['delete_time'] ?? null) === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class PharmacyLogisticsValue
|
||||
{
|
||||
public static function normalize(mixed $value, int $maxLength, string $label): string
|
||||
{
|
||||
$normalized = preg_replace(
|
||||
'/^[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+|[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+$/u',
|
||||
'',
|
||||
(string) $value
|
||||
);
|
||||
if ($normalized === null) {
|
||||
throw new InvalidArgumentException($label . '格式无效');
|
||||
}
|
||||
if (mb_strlen($normalized) > $maxLength) {
|
||||
throw new InvalidArgumentException($label . '长度不能超过' . $maxLength . '个字符');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PharmacyReconciliationRequiredException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Throwable;
|
||||
|
||||
final class PharmacyRemoteOutcomeClassifier
|
||||
{
|
||||
public static function isConfirmedEjNoCreateHttpStatus(int $httpStatus): bool
|
||||
{
|
||||
return in_array($httpStatus, [400, 401, 403, 404, 405, 415, 422], true);
|
||||
}
|
||||
|
||||
public static function isConfirmedNoCreate(Throwable $exception): bool
|
||||
{
|
||||
return $exception instanceof PharmacyRemoteRejectedException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** The pharmacy explicitly confirmed that no remote order was created. */
|
||||
final class PharmacyRemoteRejectedException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyRemoteSnapshotPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed>|null $claim
|
||||
*/
|
||||
public static function isLocked(array $order, ?array $claim): bool
|
||||
{
|
||||
if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
if (trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
if ((int) ($order['gancao_submit_time'] ?? 0) > 0 || (int) ($order['ej_pharmacy_submit_time'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_array($claim) && in_array(
|
||||
strtoupper((string) ($claim['status'] ?? '')),
|
||||
['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed>|null $claim
|
||||
*/
|
||||
public static function assertMutable(array $order, ?array $claim): void
|
||||
{
|
||||
if (self::isLocked($order, $claim)) {
|
||||
throw new DomainException('订单已提交药房,患者、地址、处方与发货药房快照不可修改;请先完成远端取消确认,取消后创建新版本');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacySubmissionClaimPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $claim @return array<string,mixed> */
|
||||
public static function existingDecision(array $claim, string $requestedTarget, ?int $now = null): array
|
||||
{
|
||||
$target = (string) ($claim['target'] ?? '');
|
||||
$status = strtoupper(trim((string) ($claim['status'] ?? '')));
|
||||
if ($status === 'SUCCESS') {
|
||||
if ($target !== $requestedTarget) {
|
||||
throw new DomainException('该订单已上传其他药房');
|
||||
}
|
||||
return ['action' => 'IDEMPOTENT'] + $claim;
|
||||
}
|
||||
if ($status === 'PENDING') {
|
||||
$leaseExpiresAt = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
if ($leaseExpiresAt > 0 && $leaseExpiresAt <= ($now ?? time())) {
|
||||
return [
|
||||
'action' => $target === 'gancao' ? 'RECONCILE' : 'RETRY',
|
||||
'lease_expired' => true,
|
||||
] + $claim;
|
||||
}
|
||||
throw new DomainException('该订单正在上传药房,请勿重复提交');
|
||||
}
|
||||
if (in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) {
|
||||
if ($target !== $requestedTarget) {
|
||||
throw new DomainException('该订单远端结果待对账,禁止切换药房');
|
||||
}
|
||||
return ['action' => 'RECONCILE'] + $claim;
|
||||
}
|
||||
if ($status === 'FAILED') {
|
||||
return ['action' => 'RETRY'] + $claim;
|
||||
}
|
||||
|
||||
throw new DomainException('药房提交状态异常,请先对账处理');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
|
||||
final class PharmacySubmissionClaimService
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public static function acquire(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
int $operatorId,
|
||||
string $operatorName
|
||||
): array {
|
||||
self::assertTarget($target);
|
||||
$revision = max($revision, 0);
|
||||
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
|
||||
if ($revision <= 0) {
|
||||
$revision = self::nextRevisionForOrder($order, $target);
|
||||
}
|
||||
|
||||
// Rows handled by the old integration may already be marked as
|
||||
// ZYT "拒收". Reopen them to the uploadable fulfillment state
|
||||
// before creating the next EJ revision.
|
||||
if ($target === 'direct'
|
||||
&& self::isRejectedEjOrder($order, $target)
|
||||
&& (int) ($order['fulfillment_status'] ?? 0) === 9) {
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
|
||||
'fulfillment_status' => 2,
|
||||
]);
|
||||
$order['fulfillment_status'] = 2;
|
||||
}
|
||||
|
||||
$expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao'));
|
||||
if ($expectedTarget !== $target) {
|
||||
throw new DomainException('发货药房已变更,请刷新后重试');
|
||||
}
|
||||
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', $revision)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($claim) {
|
||||
$decision = PharmacySubmissionClaimPolicy::existingDecision($claim, $target);
|
||||
if ($decision['action'] === 'IDEMPOTENT') {
|
||||
return [
|
||||
'target' => $target,
|
||||
'token' => (string) $claim['claim_token'],
|
||||
'status' => 'SUCCESS',
|
||||
'idempotent' => true,
|
||||
'result' => self::idempotentResult($target, (string) ($claim['remote_order_no'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($decision['action'] === 'RECONCILE') {
|
||||
if (!empty($decision['lease_expired'])) {
|
||||
$claim = self::expirePendingGancaoClaim($claim, $operatorId, $operatorName);
|
||||
}
|
||||
return [
|
||||
'target' => $target,
|
||||
'token' => (string) $claim['claim_token'],
|
||||
'status' => (string) $claim['status'],
|
||||
'idempotency_key' => (string) $claim['idempotency_key'],
|
||||
'idempotent' => false,
|
||||
'reconcile' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (self::hasAnyRemoteOrder($order)) {
|
||||
if (!self::isRejectedEjOrder($order, $target)) {
|
||||
throw new DomainException('该订单已存在远程药房单号,不可重复提交');
|
||||
}
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$leaseSeconds = max(30, (int) Config::get('ej_pharmacy.submission_lease_seconds', 300));
|
||||
$values = [
|
||||
'target' => $target,
|
||||
'status' => 'PENDING',
|
||||
'claim_token' => $token,
|
||||
'idempotency_key' => hash('sha256', $orderId . ':' . $revision . ':' . $target),
|
||||
'remote_order_no' => '',
|
||||
'request_id' => '',
|
||||
'error_message' => '',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'claimed_at' => $now,
|
||||
'lease_expires_at' => $now + $leaseSeconds,
|
||||
'completed_at' => 0,
|
||||
'failed_at' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($claim) {
|
||||
Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])->update($values);
|
||||
} else {
|
||||
Db::name('pharmacy_submission_claim')->insert($values + [
|
||||
'prescription_order_id' => $orderId,
|
||||
'source_revision' => $revision,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $values + [
|
||||
'source_revision' => $revision,
|
||||
'idempotent' => false,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
public static function markSuccess(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
array $result
|
||||
): bool {
|
||||
self::assertTarget($target);
|
||||
$remoteOrderNo = trim((string) (
|
||||
$result['remote_order_no']
|
||||
?? $result['pharmacy_order_no']
|
||||
?? $result['recipel_order_no']
|
||||
?? ''
|
||||
));
|
||||
if ($remoteOrderNo === '') {
|
||||
throw new DomainException('药房返回缺少远程订单号');
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $result, $remoteOrderNo): bool {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order || self::hasConflictingRemoteOrder($order, $target)) {
|
||||
return false;
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$claimUpdated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => 'SUCCESS',
|
||||
'remote_order_no' => mb_substr($remoteOrderNo, 0, 64),
|
||||
'request_id' => mb_substr((string) ($result['request_id'] ?? ''), 0, 64),
|
||||
'error_message' => '',
|
||||
'completed_at' => $now,
|
||||
'failed_at' => 0,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($claimUpdated !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderValues = $target === 'direct'
|
||||
? [
|
||||
'ej_pharmacy_order_no' => mb_substr($remoteOrderNo, 0, 40),
|
||||
'ej_pharmacy_submit_time' => $now,
|
||||
'ej_pharmacy_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'),
|
||||
'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'),
|
||||
'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1),
|
||||
// Preserve the business status from before the EJ upload.
|
||||
'ej_pharmacy_previous_fulfillment_status' => (int) ($order['ej_pharmacy_previous_fulfillment_status'] ?? 0) > 0
|
||||
? (int) $order['ej_pharmacy_previous_fulfillment_status']
|
||||
: (int) ($order['fulfillment_status'] ?? 0),
|
||||
]
|
||||
: [
|
||||
'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32),
|
||||
'gancao_submit_time' => $now,
|
||||
];
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update($orderValues);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static function markFailure(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
string $error
|
||||
): bool {
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->where('status', 'PENDING')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
|
||||
->where('status', 'PENDING')
|
||||
->update([
|
||||
'status' => 'FAILED',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'failed_at' => $now,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
public static function markReconcile(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
string $error
|
||||
): bool {
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
|
||||
Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'failed_at' => 0,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => time(),
|
||||
]) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function claimForOrder(int $orderId, int $revision = 0): ?array
|
||||
{
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->when($revision > 0, static fn ($query) => $query->where('source_revision', $revision))
|
||||
->order('source_revision', 'desc')
|
||||
->find();
|
||||
|
||||
return is_array($claim) ? $claim : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function resolveGancao(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $resolution,
|
||||
string $remoteOrderNo,
|
||||
string $note,
|
||||
int $operatorId,
|
||||
string $operatorName
|
||||
): array {
|
||||
return Db::transaction(function () use (
|
||||
$orderId,
|
||||
$revision,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note,
|
||||
$operatorId,
|
||||
$operatorName
|
||||
): array {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
throw new DomainException('未找到待核对的甘草提交');
|
||||
}
|
||||
$resolved = PharmacySubmissionReconciliationPolicy::resolve(
|
||||
$claim,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note
|
||||
);
|
||||
if ($resolved['status'] === 'SUCCESS' && self::hasConflictingRemoteOrder($order, 'gancao')) {
|
||||
throw new DomainException('订单已存在洛阳药房单号,不能确认甘草成功');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$updated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('claim_token', (string) $claim['claim_token'])
|
||||
->whereIn('status', ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => $resolved['status'],
|
||||
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
|
||||
'error_message' => mb_substr($resolved['note'], 0, 1000),
|
||||
'lease_expires_at' => 0,
|
||||
'completed_at' => $resolved['status'] === 'SUCCESS' ? $now : 0,
|
||||
'failed_at' => $resolved['status'] === 'FAILED' ? $now : 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated !== 1) {
|
||||
throw new DomainException('提交状态已变化,请刷新后重新核对');
|
||||
}
|
||||
if ($resolved['status'] === 'SUCCESS') {
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
|
||||
'gancao_reciperl_order_no' => mb_substr($resolved['remote_order_no'], 0, 32),
|
||||
'gancao_submit_time' => $now,
|
||||
]);
|
||||
}
|
||||
Db::name('pharmacy_submission_claim_audit')->insert([
|
||||
'claim_id' => (int) $claim['id'],
|
||||
'prescription_order_id' => $orderId,
|
||||
'source_revision' => max($revision, 1),
|
||||
'target' => 'gancao',
|
||||
'action' => strtoupper(trim($resolution)),
|
||||
'from_status' => strtoupper((string) $claim['status']),
|
||||
'to_status' => $resolved['status'],
|
||||
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
|
||||
'note' => mb_substr($resolved['note'], 0, 1000),
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
return $resolved + ['claim_id' => (int) $claim['id']];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
public static function hasAnyRemoteOrder(array $order): bool
|
||||
{
|
||||
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== ''
|
||||
|| trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function isRejectedEjOrder(array $order, string $target): bool
|
||||
{
|
||||
if ($target !== 'direct') {
|
||||
return false;
|
||||
}
|
||||
return strtoupper(trim((string) ($order['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($order['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function nextRevisionForOrder(array $order, string $target): int
|
||||
{
|
||||
$latest = (int) Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', (int) $order['id'])
|
||||
->max('source_revision');
|
||||
if (self::isRejectedEjOrder($order, $target)) {
|
||||
return max($latest + 1, 1);
|
||||
}
|
||||
return max($latest, 1);
|
||||
}
|
||||
|
||||
private static function targetForShipMode(string $shipMode): string
|
||||
{
|
||||
return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $claim @return array<string,mixed> */
|
||||
private static function expirePendingGancaoClaim(array $claim, int $operatorId, string $operatorName): array
|
||||
{
|
||||
$now = time();
|
||||
$newToken = bin2hex(random_bytes(16));
|
||||
$updated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('status', 'PENDING')
|
||||
->where('claim_token', (string) $claim['claim_token'])
|
||||
->update([
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'claim_token' => $newToken,
|
||||
'error_message' => '提交租约已超时,甘草远端结果不确定,须人工核对',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated !== 1) {
|
||||
throw new DomainException('提交租约状态已变化,请刷新后重试');
|
||||
}
|
||||
Db::name('pharmacy_submission_claim_audit')->insert([
|
||||
'claim_id' => (int) $claim['id'],
|
||||
'prescription_order_id' => (int) $claim['prescription_order_id'],
|
||||
'source_revision' => (int) $claim['source_revision'],
|
||||
'target' => 'gancao',
|
||||
'action' => 'LEASE_EXPIRED',
|
||||
'from_status' => 'PENDING',
|
||||
'to_status' => 'PENDING_RECONCILE',
|
||||
'remote_order_no' => '',
|
||||
'note' => '租约超时后轮换 claim token,禁止自动重提',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
return array_replace($claim, [
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'claim_token' => $newToken,
|
||||
'lease_expires_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function assertTarget(string $target): void
|
||||
{
|
||||
if (!in_array($target, ['gancao', 'direct'], true)) {
|
||||
throw new DomainException('不支持的药房目标');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function hasConflictingRemoteOrder(array $order, string $target): bool
|
||||
{
|
||||
if ($target === 'direct') {
|
||||
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
return trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function idempotentResult(string $target, string $remoteOrderNo): array
|
||||
{
|
||||
if ($target === 'direct') {
|
||||
return ['pharmacy' => 'ej', 'pharmacy_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
|
||||
}
|
||||
|
||||
return ['pharmacy' => 'gancao', 'recipel_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class PharmacySubmissionClaimWorkflow
|
||||
{
|
||||
private Closure $acquireClaim;
|
||||
private Closure $invokeRemote;
|
||||
private Closure $markSuccess;
|
||||
private Closure $markFailure;
|
||||
private Closure $markReconcile;
|
||||
|
||||
public function __construct(
|
||||
callable $acquireClaim,
|
||||
callable $invokeRemote,
|
||||
callable $markSuccess,
|
||||
callable $markFailure,
|
||||
callable $markReconcile
|
||||
) {
|
||||
$this->acquireClaim = Closure::fromCallable($acquireClaim);
|
||||
$this->invokeRemote = Closure::fromCallable($invokeRemote);
|
||||
$this->markSuccess = Closure::fromCallable($markSuccess);
|
||||
$this->markFailure = Closure::fromCallable($markFailure);
|
||||
$this->markReconcile = Closure::fromCallable($markReconcile);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function execute(string $target): array
|
||||
{
|
||||
if (!in_array($target, ['gancao', 'direct'], true)) {
|
||||
throw new InvalidArgumentException('Unsupported pharmacy target');
|
||||
}
|
||||
|
||||
$claim = ($this->acquireClaim)($target);
|
||||
if (!empty($claim['idempotent'])) {
|
||||
$result = is_array($claim['result'] ?? null) ? $claim['result'] : [];
|
||||
return $result + ['target' => $target, 'idempotent' => true];
|
||||
}
|
||||
|
||||
$token = trim((string) ($claim['token'] ?? $claim['claim_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
throw new DomainException('药房提交凭证缺失');
|
||||
}
|
||||
$claimStatus = strtoupper(trim((string) ($claim['status'] ?? '')));
|
||||
if ($target === 'gancao' && (
|
||||
!empty($claim['reconcile'])
|
||||
|| in_array($claimStatus, ['UNKNOWN', 'PENDING_RECONCILE'], true)
|
||||
)) {
|
||||
throw new PharmacyReconciliationRequiredException(
|
||||
'甘草药房远端结果待核对,当前禁止重提;请等待人工或后续对账'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = ($this->invokeRemote)($target, $token, $claim);
|
||||
if (!is_array($result)) {
|
||||
throw new DomainException('药房返回数据格式错误');
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
if (PharmacyRemoteOutcomeClassifier::isConfirmedNoCreate($exception)) {
|
||||
($this->markFailure)($target, $token, $exception->getMessage(), $claim);
|
||||
} else {
|
||||
($this->markReconcile)($target, $token, $exception->getMessage(), $claim);
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
try {
|
||||
$finalized = (bool) ($this->markSuccess)($target, $token, $result, $claim);
|
||||
} catch (Throwable $exception) {
|
||||
($this->markReconcile)($target, $token, '远端成功但本地回写异常:' . $exception->getMessage(), $claim);
|
||||
throw new PharmacyReconciliationRequiredException(
|
||||
'远端可能已创建订单,本地回写失败,必须对账后再操作',
|
||||
0,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
if (!$finalized) {
|
||||
($this->markReconcile)($target, $token, '远端成功但本地提交凭证无法完成', $claim);
|
||||
throw new PharmacyReconciliationRequiredException('远端已返回成功,本地回写未完成,必须对账后再操作');
|
||||
}
|
||||
|
||||
return $result + ['target' => $target, 'idempotent' => false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacySubmissionReconciliationPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $claim @return array{status:string,remote_order_no:string,note:string} */
|
||||
public static function resolve(
|
||||
array $claim,
|
||||
string $resolution,
|
||||
string $remoteOrderNo,
|
||||
string $note,
|
||||
?int $now = null
|
||||
): array
|
||||
{
|
||||
if (strtolower(trim((string) ($claim['target'] ?? ''))) !== 'gancao') {
|
||||
throw new DomainException('仅甘草药房不确定提交支持人工确认');
|
||||
}
|
||||
$status = strtoupper(trim((string) ($claim['status'] ?? '')));
|
||||
$expiredPending = $status === 'PENDING'
|
||||
&& (int) ($claim['lease_expires_at'] ?? 0) > 0
|
||||
&& (int) $claim['lease_expires_at'] <= ($now ?? time());
|
||||
if (!$expiredPending && !in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) {
|
||||
throw new DomainException('当前提交状态无需人工确认');
|
||||
}
|
||||
$resolution = strtoupper(trim($resolution));
|
||||
if (!in_array($resolution, ['CONFIRM_SUCCESS', 'CONFIRM_NOT_CREATED'], true)) {
|
||||
throw new DomainException('不支持的人工确认结果');
|
||||
}
|
||||
$note = trim($note);
|
||||
if ($note === '') {
|
||||
throw new DomainException('请填写甘草后台核对依据');
|
||||
}
|
||||
$remoteOrderNo = trim($remoteOrderNo);
|
||||
if ($resolution === 'CONFIRM_SUCCESS' && $remoteOrderNo === '') {
|
||||
throw new DomainException('确认成功时必须填写甘草药方单号');
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $resolution === 'CONFIRM_SUCCESS' ? 'SUCCESS' : 'FAILED',
|
||||
'remote_order_no' => $resolution === 'CONFIRM_SUCCESS' ? $remoteOrderNo : '',
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class PharmacySupplyMode
|
||||
{
|
||||
/** @param array<string,mixed> $order */
|
||||
public static function resolve(array $order): string
|
||||
{
|
||||
if (strtolower(trim((string) ($order['ship_mode'] ?? ''))) === 'direct') {
|
||||
return 'direct';
|
||||
}
|
||||
if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return 'gancao';
|
||||
}
|
||||
|
||||
return 'self';
|
||||
}
|
||||
|
||||
public static function label(string $mode): string
|
||||
{
|
||||
return match (strtolower(trim($mode))) {
|
||||
'direct' => '洛阳直发',
|
||||
'gancao' => '甘草',
|
||||
default => '自营',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class PharmacyUploadPermissionAlias
|
||||
{
|
||||
public const LEGACY_URI = 'tcm.prescriptionorder/submitgancaorecipel';
|
||||
public const CANONICAL_URI = 'tcm.prescriptionorder/uploadtopharmacy';
|
||||
public const RECONCILE_URI = 'tcm.prescriptionorder/confirmgancaosubmission';
|
||||
|
||||
public static function canonicalUri(string $uri): string
|
||||
{
|
||||
$uri = strtolower($uri);
|
||||
return in_array($uri, [self::LEGACY_URI, self::CANONICAL_URI], true)
|
||||
? self::CANONICAL_URI
|
||||
: $uri;
|
||||
}
|
||||
|
||||
public static function isControlled(string $uri): bool
|
||||
{
|
||||
return in_array(strtolower($uri), [self::LEGACY_URI, self::CANONICAL_URI, self::RECONCILE_URI], true);
|
||||
}
|
||||
|
||||
/** @param array<int,string> $adminUris */
|
||||
public static function allows(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
$canonicalAccess = self::canonicalUri($accessUri);
|
||||
foreach ($adminUris as $uri) {
|
||||
if (self::canonicalUri((string) $uri) === $canonicalAccess) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxMediaChannel;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
|
||||
class MediaChannelService
|
||||
{
|
||||
private const ACTIVE_ROWS_CACHE_TTL_SECONDS = 1.0;
|
||||
private const CURRENT_TAG_CATALOG_CACHE_KEY = 'qywx:current_tag_catalog:v1';
|
||||
private const CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS = 30;
|
||||
private const SCAN_DUPLICATE_UPDATE_FIELDS = [
|
||||
'source_group_name',
|
||||
'last_seen_time',
|
||||
'update_time',
|
||||
];
|
||||
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private static ?array $activeChannelRowsCache = null;
|
||||
|
||||
private static float $activeChannelRowsCachedAt = 0.0;
|
||||
|
||||
/** @var array<int, array{tag_id: string, tag_name: string, group_name: string, customer_count: int}>|null */
|
||||
private static ?array $currentTagCatalogCache = null;
|
||||
|
||||
private static float $currentTagCatalogCachedAt = 0.0;
|
||||
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private static ?array $currentTagChannelRowsCache = null;
|
||||
|
||||
private static float $currentTagChannelRowsCachedAt = 0.0;
|
||||
|
||||
/**
|
||||
* 与业绩看板「渠道来源」相同的分组(按 source_group_name),不含客户数统计。
|
||||
*
|
||||
* @return array<int, array{
|
||||
* group_name: string,
|
||||
* channels: array<int, array{channel_code: string, channel_name: string}>
|
||||
* }>
|
||||
*/
|
||||
public static function getOptionGroups(): array
|
||||
{
|
||||
$rows = self::getActiveChannelRows();
|
||||
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($rows as $r) {
|
||||
$g = (string) (($r['source_group_name'] ?? '') !== '' ? $r['source_group_name'] : '其它');
|
||||
$groups[$g] ??= ['group_name' => $g, 'channels' => []];
|
||||
$groups[$g]['channels'][] = [
|
||||
'channel_code' => (string) ($r['channel_code'] ?? ''),
|
||||
'channel_name' => (string) ($r['channel_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$sortedGroups = array_values($groups);
|
||||
usort($sortedGroups, static function (array $a, array $b): int {
|
||||
$aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1;
|
||||
$bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1;
|
||||
if ($aMedia !== $bMedia) {
|
||||
return $aMedia <=> $bMedia;
|
||||
}
|
||||
|
||||
return strcmp($a['group_name'], $b['group_name']);
|
||||
});
|
||||
|
||||
return $sortedGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{code: string, name: string}>
|
||||
*/
|
||||
public static function getOptions(): array
|
||||
{
|
||||
$rows = self::getActiveChannelRows();
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$nameCompare = strnatcasecmp(
|
||||
(string) ($a['channel_name'] ?? ''),
|
||||
(string) ($b['channel_name'] ?? '')
|
||||
);
|
||||
|
||||
return $nameCompare !== 0
|
||||
? $nameCompare
|
||||
: strcmp((string) ($a['channel_code'] ?? ''), (string) ($b['channel_code'] ?? ''));
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'code' => (string) ($row['channel_code'] ?? ''),
|
||||
'name' => (string) ($row['channel_name'] ?? ''),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微客户页与一诊渠道共用的当前标签目录。
|
||||
*
|
||||
* 只统计仍关联未删除客户的标签;同一个 tag_id 只保留更新时间最新、
|
||||
* 同时间 id 最大的一份名称和分组,避免标签改名后同时展示新旧快照。
|
||||
*
|
||||
* @return array<int, array{tag_id: string, tag_name: string, group_name: string, customer_count: int}>
|
||||
*/
|
||||
public static function getCurrentTagCatalog(): array
|
||||
{
|
||||
$now = microtime(true);
|
||||
if (self::$currentTagCatalogCache !== null
|
||||
&& ($now - self::$currentTagCatalogCachedAt) < self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS) {
|
||||
return self::$currentTagCatalogCache;
|
||||
}
|
||||
|
||||
try {
|
||||
$cachedCatalog = Cache::get(self::CURRENT_TAG_CATALOG_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
// 缓存目录/服务不可用时直接查库,缓存不能阻断业务接口。
|
||||
$cachedCatalog = null;
|
||||
}
|
||||
if (is_array($cachedCatalog)) {
|
||||
self::$currentTagCatalogCache = $cachedCatalog;
|
||||
self::$currentTagCatalogCachedAt = microtime(true);
|
||||
|
||||
return self::$currentTagCatalogCache;
|
||||
}
|
||||
|
||||
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$sql = <<<SQL
|
||||
SELECT latest_tag.tag_id,
|
||||
COALESCE(latest_tag.tag_name, '') AS tag_name,
|
||||
COALESCE(latest_tag.group_name, '') AS group_name,
|
||||
current_tag.customer_count
|
||||
FROM {$tagTable} latest_tag
|
||||
INNER JOIN (
|
||||
SELECT tagged.tag_id,
|
||||
MAX(CONCAT(LPAD(tagged.update_time, 10, '0'), LPAD(tagged.id, 10, '0'))) AS latest_sort_key,
|
||||
COUNT(DISTINCT tagged.external_userid) AS customer_count
|
||||
FROM {$tagTable} tagged
|
||||
INNER JOIN {$contactTable} active_contact
|
||||
ON active_contact.external_userid = tagged.external_userid
|
||||
AND active_contact.delete_time IS NULL
|
||||
WHERE tagged.tag_id <> ''
|
||||
GROUP BY tagged.tag_id
|
||||
) current_tag
|
||||
ON current_tag.tag_id = latest_tag.tag_id
|
||||
AND current_tag.latest_sort_key = CONCAT(
|
||||
LPAD(latest_tag.update_time, 10, '0'),
|
||||
LPAD(latest_tag.id, 10, '0')
|
||||
)
|
||||
ORDER BY current_tag.customer_count DESC, latest_tag.tag_id ASC
|
||||
SQL;
|
||||
|
||||
self::$currentTagCatalogCache = array_map(static fn (array $row): array => [
|
||||
'tag_id' => trim((string) ($row['tag_id'] ?? '')),
|
||||
'tag_name' => trim((string) ($row['tag_name'] ?? '')),
|
||||
'group_name' => trim((string) ($row['group_name'] ?? '')),
|
||||
'customer_count' => (int) ($row['customer_count'] ?? 0),
|
||||
], Db::query($sql));
|
||||
self::$currentTagCatalogCachedAt = microtime(true);
|
||||
try {
|
||||
Cache::set(
|
||||
self::CURRENT_TAG_CATALOG_CACHE_KEY,
|
||||
self::$currentTagCatalogCache,
|
||||
self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// 同上:共享缓存仅用于加速,当前请求的内存缓存仍然有效。
|
||||
}
|
||||
|
||||
return self::$currentTagCatalogCache;
|
||||
}
|
||||
|
||||
public static function forgetCurrentTagCatalogCache(): void
|
||||
{
|
||||
self::$currentTagCatalogCache = null;
|
||||
self::$currentTagCatalogCachedAt = 0.0;
|
||||
self::$currentTagChannelRowsCache = null;
|
||||
self::$currentTagChannelRowsCachedAt = 0.0;
|
||||
try {
|
||||
Cache::delete(self::CURRENT_TAG_CATALOG_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
// 缓存不可用不影响标签同步和后续数据库读取。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一诊专用渠道选项:严格投影当前企微标签,不混入历史 name-only 渠道。
|
||||
*
|
||||
* @return array<int, array{code: string, name: string, tag_id: string, group_name: string, customer_count: int}>
|
||||
*/
|
||||
public static function getCurrentTagOptions(): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
'code' => (string) ($row['channel_code'] ?? ''),
|
||||
'name' => (string) ($row['channel_name'] ?? ''),
|
||||
'tag_id' => (string) ($row['source_tag_id'] ?? ''),
|
||||
'group_name' => (string) ($row['source_group_name'] ?? ''),
|
||||
'customer_count' => (int) ($row['customer_count'] ?? 0),
|
||||
], self::getCurrentTagChannelRows());
|
||||
}
|
||||
|
||||
/**
|
||||
* 一诊专用解析器。当前标签即使在历史渠道注册表中被停用,也仍按企微当前标签生效;
|
||||
* 全局财务渠道的启停语义继续由 getChannelByCode() 维护。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function getCurrentTagChannelByCode(string $channelCode): ?array
|
||||
{
|
||||
$channelCode = trim($channelCode);
|
||||
if ($channelCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::getCurrentTagChannelRows() as $row) {
|
||||
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getDefaultCode(): string
|
||||
{
|
||||
$rows = self::getActiveChannelRows();
|
||||
|
||||
return (string) ($rows[0]['channel_code'] ?? '');
|
||||
}
|
||||
|
||||
public static function isValidCode(string $channelCode): bool
|
||||
{
|
||||
$channelCode = trim($channelCode);
|
||||
if ($channelCode === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::getChannelByCode($channelCode) !== null;
|
||||
}
|
||||
|
||||
public static function normalizeStatsCode(string $channelCode): string
|
||||
{
|
||||
return self::isValidCode($channelCode) ? trim($channelCode) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function getChannelByCode(string $channelCode): ?array
|
||||
{
|
||||
$channelCode = trim($channelCode);
|
||||
if ($channelCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$model = QywxMediaChannel::where('channel_code', $channelCode)->find();
|
||||
|
||||
if ($model !== null) {
|
||||
$row = $model->toArray();
|
||||
if ((int) ($row['status'] ?? 0) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagId = trim((string) ($row['source_tag_id'] ?? ''));
|
||||
if ($tagId === '') {
|
||||
return $row;
|
||||
}
|
||||
|
||||
$merged = self::mergeConfiguredChannelsWithTags([$row], self::loadCurrentTagRows([$tagId]));
|
||||
|
||||
return $merged[0] ?? $row;
|
||||
}
|
||||
|
||||
foreach (self::getActiveChannelRows() as $row) {
|
||||
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getNameByCode(string $channelCode): string
|
||||
{
|
||||
$channel = self::getChannelByCode($channelCode);
|
||||
return (string) ($channel['channel_name'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号老数据渠道:doctor_appointment.channels 对应 dict_data(type_value=channels) 的 value。
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getLegacyAppointmentChannelValues(?array $channel): array
|
||||
{
|
||||
if ($channel === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = array_values(array_unique(array_filter([
|
||||
trim((string) ($channel['channel_name'] ?? '')),
|
||||
trim((string) ($channel['source_tag_name'] ?? '')),
|
||||
trim((string) ($channel['legacy_channel_name'] ?? '')),
|
||||
trim((string) ($channel['legacy_source_tag_name'] ?? '')),
|
||||
])));
|
||||
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name('dict_data')
|
||||
->where('type_value', 'channels')
|
||||
->whereIn('name', $names)
|
||||
->column('value');
|
||||
|
||||
return array_values(array_filter(array_map('intval', $rows), static fn (int $value): bool => $value > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyFollowUsersChannelFilter(Query $query, string $field, ?array $channel): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$patterns = self::buildLikePatterns($channel);
|
||||
if ($patterns === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$bindings = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$segments[] = $field . ' LIKE ?';
|
||||
$bindings[] = $pattern;
|
||||
}
|
||||
|
||||
$query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a fact table by its external_userid without joining the denormalized
|
||||
* contact rows. The contact table may contain several rows for one customer;
|
||||
* a normal JOIN therefore both scans follow_users TEXT repeatedly and
|
||||
* multiplies facts. Enterprise tag channels use the normalized relation
|
||||
* table, while legacy name-only channels keep a deduplicated JSON fallback.
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
||||
if ($tagId !== '') {
|
||||
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$query->whereRaw(
|
||||
"{$field} IN ("
|
||||
. "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag "
|
||||
. 'WHERE channel_tag.tag_id = ? '
|
||||
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
|
||||
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
|
||||
. 'AND active_channel_contact.delete_time IS NULL))',
|
||||
[$tagId]
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$patterns = self::buildLikePatterns($channel);
|
||||
if ($patterns === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$bindings = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$segments[] = 'channel_contact.follow_users LIKE ?';
|
||||
$bindings[] = $pattern;
|
||||
}
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$query->whereRaw(
|
||||
"{$field} IN (SELECT channel_contact.external_userid FROM {$contactTable} channel_contact"
|
||||
. ' WHERE channel_contact.delete_time IS NULL AND (' . implode(' OR ', $segments) . '))',
|
||||
$bindings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
|
||||
*/
|
||||
public static function scanFromContacts(int $batchSize = 200): array
|
||||
{
|
||||
$lastId = 0;
|
||||
$scannedContacts = 0;
|
||||
$discoveredTags = [];
|
||||
$upserted = 0;
|
||||
$now = time();
|
||||
|
||||
while (true) {
|
||||
$rows = QywxExternalContact::where('id', '>', $lastId)
|
||||
->whereNull('delete_time')
|
||||
->field('id, follow_users')
|
||||
->order('id asc')
|
||||
->limit($batchSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lastId = (int) ($row['id'] ?? 0);
|
||||
if ($lastId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scannedContacts++;
|
||||
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
|
||||
$followUsers = is_array($followUsers) ? $followUsers : [];
|
||||
|
||||
foreach (self::extractTagsFromFollowUsers($followUsers) as $tag) {
|
||||
$tagKey = self::buildTagUniqKey($tag['source_tag_id'], $tag['source_tag_name']);
|
||||
if ($tagKey === '' || isset($discoveredTags[$tagKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$discoveredTags[$tagKey] = true;
|
||||
$channelCode = self::buildChannelCode($tag['source_tag_id'], $tag['source_tag_name']);
|
||||
$channelName = $tag['source_tag_name'] !== '' ? $tag['source_tag_name'] : $tag['source_tag_id'];
|
||||
|
||||
$rowData = [
|
||||
'channel_code' => $channelCode,
|
||||
'channel_name' => $channelName,
|
||||
'source_tag_id' => $tag['source_tag_id'],
|
||||
'source_tag_name' => $tag['source_tag_name'],
|
||||
'source_group_name' => $tag['source_group_name'],
|
||||
'tag_uniq_key' => $tagKey,
|
||||
'status' => 1,
|
||||
'last_seen_time' => $now,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
Db::name('qywx_media_channel')
|
||||
->duplicate(self::SCAN_DUPLICATE_UPDATE_FIELDS)
|
||||
->insert($rowData);
|
||||
$upserted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$activeChannelRowsCache = null;
|
||||
self::$activeChannelRowsCachedAt = 0.0;
|
||||
self::$currentTagChannelRowsCache = null;
|
||||
self::$currentTagChannelRowsCachedAt = 0.0;
|
||||
|
||||
return [
|
||||
'scanned_contacts' => $scannedContacts,
|
||||
'discovered_tags' => count($discoveredTags),
|
||||
'inserted_or_updated' => $upserted,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function getCurrentTagChannelRows(): array
|
||||
{
|
||||
$now = microtime(true);
|
||||
if (self::$currentTagChannelRowsCache !== null
|
||||
&& ($now - self::$currentTagChannelRowsCachedAt) < self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS) {
|
||||
return self::$currentTagChannelRowsCache;
|
||||
}
|
||||
|
||||
$catalog = self::getCurrentTagCatalog();
|
||||
$tagIds = array_values(array_filter(array_map(
|
||||
static fn (array $tag): string => trim((string) ($tag['tag_id'] ?? '')),
|
||||
$catalog
|
||||
), static fn (string $tagId): bool => $tagId !== ''));
|
||||
|
||||
$configuredRows = [];
|
||||
if ($tagIds !== []) {
|
||||
$configuredRows = QywxMediaChannel::whereIn('source_tag_id', $tagIds)
|
||||
->field(
|
||||
'id, channel_code, channel_name, source_tag_id, source_tag_name, source_group_name, '
|
||||
. 'tag_uniq_key, status, last_seen_time, create_time, update_time'
|
||||
)
|
||||
->order('id asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
self::$currentTagChannelRowsCache = self::mergeCurrentTagsWithConfiguredChannels($catalog, $configuredRows);
|
||||
self::$currentTagChannelRowsCachedAt = microtime(true);
|
||||
|
||||
return self::$currentTagChannelRowsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前企微标签决定展示名称和可见集合;注册表只提供稳定 code 及历史名称兼容。
|
||||
* 历史 name-only 行不会进入结果,注册表 status 也不会隐藏仍在使用的企微标签。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $catalog
|
||||
* @param array<int, array<string, mixed>> $configuredRows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function mergeCurrentTagsWithConfiguredChannels(array $catalog, array $configuredRows): array
|
||||
{
|
||||
$configuredByTagId = [];
|
||||
foreach ($configuredRows as $configuredRow) {
|
||||
$tagId = trim((string) ($configuredRow['source_tag_id'] ?? ''));
|
||||
if ($tagId !== '' && !isset($configuredByTagId[$tagId])) {
|
||||
$configuredByTagId[$tagId] = $configuredRow;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($catalog as $tag) {
|
||||
$tagId = trim((string) ($tag['tag_id'] ?? $tag['source_tag_id'] ?? ''));
|
||||
if ($tagId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagName = trim((string) ($tag['tag_name'] ?? $tag['source_tag_name'] ?? ''));
|
||||
$groupName = trim((string) ($tag['group_name'] ?? $tag['source_group_name'] ?? ''));
|
||||
$configured = $configuredByTagId[$tagId] ?? [];
|
||||
$channelCode = trim((string) ($configured['channel_code'] ?? ''));
|
||||
if ($channelCode === '') {
|
||||
$channelCode = self::buildChannelCode($tagId, $tagName);
|
||||
}
|
||||
if ($channelCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $configured;
|
||||
$oldChannelName = trim((string) ($configured['channel_name'] ?? ''));
|
||||
$oldTagName = trim((string) ($configured['source_tag_name'] ?? ''));
|
||||
if ($oldChannelName !== '' && $oldChannelName !== $tagName) {
|
||||
$row['legacy_channel_name'] = $oldChannelName;
|
||||
}
|
||||
if ($oldTagName !== '' && $oldTagName !== $tagName) {
|
||||
$row['legacy_source_tag_name'] = $oldTagName;
|
||||
}
|
||||
|
||||
$row['id'] = (int) ($configured['id'] ?? 0);
|
||||
$row['channel_code'] = $channelCode;
|
||||
$row['channel_name'] = $tagName !== '' ? $tagName : $tagId;
|
||||
$row['source_tag_id'] = $tagId;
|
||||
$row['source_tag_name'] = $tagName;
|
||||
$row['source_group_name'] = $groupName;
|
||||
$row['tag_uniq_key'] = self::buildTagUniqKey($tagId, $tagName);
|
||||
$row['status'] = 1;
|
||||
$row['customer_count'] = (int) ($tag['customer_count'] ?? 0);
|
||||
$row['last_seen_time'] = (int) ($configured['last_seen_time'] ?? 0);
|
||||
$row['create_time'] = (int) ($configured['create_time'] ?? 0);
|
||||
$row['update_time'] = (int) ($configured['update_time'] ?? 0);
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine the persistent channel registry with the latest tag snapshots in
|
||||
* the normalized relation table. The registry keeps stable channel codes
|
||||
* and historical name-only channels; relation rows supply newly discovered
|
||||
* tags and names for tags that were renamed in WeCom.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function getActiveChannelRows(): array
|
||||
{
|
||||
$now = microtime(true);
|
||||
if (self::$activeChannelRowsCache !== null
|
||||
&& ($now - self::$activeChannelRowsCachedAt) < self::ACTIVE_ROWS_CACHE_TTL_SECONDS) {
|
||||
return self::$activeChannelRowsCache;
|
||||
}
|
||||
|
||||
$configuredRows = QywxMediaChannel::field(
|
||||
'id, channel_code, channel_name, source_tag_id, source_tag_name, source_group_name, '
|
||||
. 'tag_uniq_key, status, last_seen_time, create_time, update_time'
|
||||
)
|
||||
->order('id asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$rows = self::mergeConfiguredChannelsWithTags($configuredRows, self::loadCurrentTagRows());
|
||||
self::$activeChannelRowsCache = $rows;
|
||||
self::$activeChannelRowsCachedAt = microtime(true);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[]|null $tagIds null loads all current tags
|
||||
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
|
||||
*/
|
||||
private static function loadCurrentTagRows(?array $tagIds = null): array
|
||||
{
|
||||
$bindings = [];
|
||||
$tagFilter = '';
|
||||
if ($tagIds !== null) {
|
||||
$tagIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($tagId): string => trim((string) $tagId),
|
||||
$tagIds
|
||||
), static fn (string $tagId): bool => $tagId !== '')));
|
||||
if ($tagIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tagFilter = ' AND newest_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
||||
$bindings = $tagIds;
|
||||
}
|
||||
|
||||
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
|
||||
$sql = <<<SQL
|
||||
SELECT latest_tag.tag_id AS source_tag_id,
|
||||
COALESCE(latest_tag.tag_name, '') AS source_tag_name,
|
||||
COALESCE(latest_tag.group_name, '') AS source_group_name
|
||||
FROM {$tagTable} latest_tag
|
||||
INNER JOIN (
|
||||
SELECT latest_time.tag_id, MAX(tag_at_time.id) AS latest_id
|
||||
FROM (
|
||||
SELECT newest_tag.tag_id, MAX(newest_tag.update_time) AS latest_update_time
|
||||
FROM {$tagTable} newest_tag
|
||||
WHERE newest_tag.tag_id <> ''
|
||||
{$tagFilter}
|
||||
GROUP BY newest_tag.tag_id
|
||||
) latest_time
|
||||
INNER JOIN {$tagTable} tag_at_time
|
||||
ON tag_at_time.tag_id = latest_time.tag_id
|
||||
AND tag_at_time.update_time = latest_time.latest_update_time
|
||||
GROUP BY latest_time.tag_id
|
||||
) selected_tag
|
||||
ON selected_tag.latest_id = latest_tag.id
|
||||
ORDER BY latest_tag.tag_id ASC
|
||||
SQL;
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'source_tag_id' => trim((string) ($row['source_tag_id'] ?? '')),
|
||||
'source_tag_name' => trim((string) ($row['source_tag_name'] ?? '')),
|
||||
'source_group_name' => trim((string) ($row['source_group_name'] ?? '')),
|
||||
], Db::query($sql, $bindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled configured tags stay disabled. Active configured rows keep their
|
||||
* stable codes, while automatic display names follow the newest tag name.
|
||||
* Tags not yet present in the registry receive the same deterministic code
|
||||
* that the scanner would create.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $configuredRows
|
||||
* @param array<int, array<string, mixed>> $tagRows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function mergeConfiguredChannelsWithTags(array $configuredRows, array $tagRows): array
|
||||
{
|
||||
$tagMap = [];
|
||||
foreach ($tagRows as $tagRow) {
|
||||
$tagId = trim((string) ($tagRow['source_tag_id'] ?? $tagRow['tag_id'] ?? ''));
|
||||
if ($tagId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagMap[$tagId] = [
|
||||
'source_tag_id' => $tagId,
|
||||
'source_tag_name' => trim((string) ($tagRow['source_tag_name'] ?? $tagRow['tag_name'] ?? '')),
|
||||
'source_group_name' => trim((string) ($tagRow['source_group_name'] ?? $tagRow['group_name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$configuredTagIds = [];
|
||||
$configuredCodes = [];
|
||||
foreach ($configuredRows as $configuredRow) {
|
||||
$channelCode = trim((string) ($configuredRow['channel_code'] ?? ''));
|
||||
$tagId = trim((string) ($configuredRow['source_tag_id'] ?? ''));
|
||||
if ($channelCode !== '') {
|
||||
$configuredCodes[$channelCode] = true;
|
||||
}
|
||||
if ($tagId !== '') {
|
||||
// A disabled registry row is an explicit opt-out and must not be
|
||||
// reintroduced as a dynamically discovered channel.
|
||||
$configuredTagIds[$tagId] = true;
|
||||
}
|
||||
if ($channelCode === '' || (int) ($configuredRow['status'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $configuredRow;
|
||||
$currentTag = $tagId !== '' ? ($tagMap[$tagId] ?? null) : null;
|
||||
if ($currentTag !== null) {
|
||||
$oldTagName = trim((string) ($row['source_tag_name'] ?? ''));
|
||||
$oldChannelName = trim((string) ($row['channel_name'] ?? ''));
|
||||
$currentTagName = (string) $currentTag['source_tag_name'];
|
||||
|
||||
if ($currentTagName !== '') {
|
||||
if ($oldTagName !== '' && $oldTagName !== $currentTagName) {
|
||||
$row['legacy_source_tag_name'] = $oldTagName;
|
||||
}
|
||||
|
||||
$isAutomaticName = $oldChannelName === ''
|
||||
|| $oldChannelName === $oldTagName
|
||||
|| $oldChannelName === $tagId;
|
||||
if ($isAutomaticName) {
|
||||
if ($oldChannelName !== '' && $oldChannelName !== $currentTagName) {
|
||||
$row['legacy_channel_name'] = $oldChannelName;
|
||||
}
|
||||
$row['channel_name'] = $currentTagName;
|
||||
}
|
||||
$row['source_tag_name'] = $currentTagName;
|
||||
}
|
||||
$row['source_group_name'] = (string) $currentTag['source_group_name'];
|
||||
}
|
||||
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
foreach ($tagMap as $tagId => $tagRow) {
|
||||
if (isset($configuredTagIds[$tagId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$channelName = (string) ($tagRow['source_tag_name'] ?? '');
|
||||
$channelCode = self::buildChannelCode($tagId, $channelName);
|
||||
if ($channelCode === '' || isset($configuredCodes[$channelCode])) {
|
||||
continue;
|
||||
}
|
||||
$configuredCodes[$channelCode] = true;
|
||||
$result[] = [
|
||||
'id' => 0,
|
||||
'channel_code' => $channelCode,
|
||||
'channel_name' => $channelName !== '' ? $channelName : $tagId,
|
||||
'source_tag_id' => $tagId,
|
||||
'source_tag_name' => $channelName,
|
||||
'source_group_name' => (string) ($tagRow['source_group_name'] ?? ''),
|
||||
'tag_uniq_key' => self::buildTagUniqKey($tagId, $channelName),
|
||||
'status' => 1,
|
||||
'last_seen_time' => 0,
|
||||
'create_time' => 0,
|
||||
'update_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $channel
|
||||
* @return string[]
|
||||
*/
|
||||
private static function buildLikePatterns(array $channel): array
|
||||
{
|
||||
$patterns = [];
|
||||
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
||||
$tagName = trim((string) ($channel['source_tag_name'] ?? ''));
|
||||
|
||||
if ($tagId !== '') {
|
||||
$escapedTagId = addcslashes($tagId, '%_\\');
|
||||
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
|
||||
$patterns[] = '%"id":"' . $escapedTagId . '"%';
|
||||
}
|
||||
|
||||
if ($tagName !== '') {
|
||||
$escapedTagName = addcslashes($tagName, '%_\\');
|
||||
$patterns[] = '%"name":"' . $escapedTagName . '"%';
|
||||
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
|
||||
}
|
||||
|
||||
return array_values(array_unique($patterns));
|
||||
}
|
||||
|
||||
private static function tableWithPrefix(string $table): string
|
||||
{
|
||||
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
|
||||
|
||||
return $prefix . $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $followUsers
|
||||
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
|
||||
*/
|
||||
private static function extractTagsFromFollowUsers(array $followUsers): array
|
||||
{
|
||||
$tags = [];
|
||||
foreach ($followUsers as $followUser) {
|
||||
if (!is_array($followUser)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawTags = $followUser['tags'] ?? [];
|
||||
if (!is_array($rawTags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rawTags as $tag) {
|
||||
if (!is_array($tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagId = trim((string) ($tag['tag_id'] ?? $tag['id'] ?? ''));
|
||||
$tagName = trim((string) ($tag['name'] ?? $tag['tag_name'] ?? ''));
|
||||
$groupName = trim((string) ($tag['group_name'] ?? ''));
|
||||
$uniqKey = self::buildTagUniqKey($tagId, $tagName);
|
||||
if ($uniqKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tags[$uniqKey] = [
|
||||
'source_tag_id' => $tagId,
|
||||
'source_tag_name' => $tagName,
|
||||
'source_group_name' => $groupName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($tags);
|
||||
}
|
||||
|
||||
private static function buildTagUniqKey(string $tagId, string $tagName): string
|
||||
{
|
||||
$tagId = trim($tagId);
|
||||
$tagName = trim($tagName);
|
||||
if ($tagId !== '') {
|
||||
return 'tag_id:' . $tagId;
|
||||
}
|
||||
if ($tagName !== '') {
|
||||
return 'tag_name:' . md5(mb_strtolower($tagName, 'UTF-8'));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function buildChannelCode(string $tagId, string $tagName): string
|
||||
{
|
||||
$tagId = trim($tagId);
|
||||
if ($tagId !== '') {
|
||||
return 'tag_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', $tagId);
|
||||
}
|
||||
|
||||
$normalizedName = trim(mb_strtolower($tagName, 'UTF-8'));
|
||||
return 'tagname_' . substr(md5($normalizedName), 0, 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use RuntimeException;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 企业微信内部应用获客链接 API。
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/97297
|
||||
*/
|
||||
class QywxCustomerAcquisitionApiService
|
||||
{
|
||||
private const TOKEN_INVALID_CODES = [40001, 40014, 42001];
|
||||
|
||||
private string $corpId;
|
||||
private string $secret;
|
||||
private Client $client;
|
||||
/** @var null|callable():string */
|
||||
private $accessTokenResolver;
|
||||
|
||||
/** @param null|callable():string $accessTokenResolver 仅用于测试或托管 token 场景。 */
|
||||
public function __construct(?Client $client = null, ?callable $accessTokenResolver = null)
|
||||
{
|
||||
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
|
||||
$this->secret = trim((string) config('qywx_customer_acquisition.secret', ''));
|
||||
$this->client = $client ?? new Client([
|
||||
'base_uri' => rtrim((string) config('qywx_customer_acquisition.base_uri', 'https://qyapi.weixin.qq.com'), '/') . '/',
|
||||
'timeout' => max(5, (int) config('qywx_customer_acquisition.timeout', 20)),
|
||||
'connect_timeout' => 8,
|
||||
'http_errors' => false,
|
||||
'verify' => config('qywx_customer_acquisition.verify', true),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
$this->accessTokenResolver = $accessTokenResolver;
|
||||
}
|
||||
|
||||
/** @return array{configured:bool,missing:list<string>} */
|
||||
public static function configurationStatus(): array
|
||||
{
|
||||
$missing = [];
|
||||
if (trim((string) config('qywx_customer_acquisition.corp_id', '')) === '') {
|
||||
$missing[] = 'work_wechat.corp_id';
|
||||
}
|
||||
if (trim((string) config('qywx_customer_acquisition.secret', '')) === '') {
|
||||
$missing[] = 'work_wechat.customer_acquisition_secret / secret';
|
||||
}
|
||||
|
||||
return ['configured' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
|
||||
/** @return array{link_id_list:list<string>,next_cursor:string} */
|
||||
public function listLinks(string $cursor = '', int $limit = 100): array
|
||||
{
|
||||
$body = ['limit' => min(100, max(1, $limit))];
|
||||
if ($cursor !== '') {
|
||||
$body['cursor'] = $cursor;
|
||||
}
|
||||
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/list_link', $body);
|
||||
|
||||
return [
|
||||
'link_id_list' => array_values(array_filter(array_map('strval', (array) ($response['link_id_list'] ?? [])))),
|
||||
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function getLink(string $linkId): array
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/get', ['link_id' => $linkId]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function createLink(array $payload): array
|
||||
{
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/create_link', $payload);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function updateLink(array $payload): array
|
||||
{
|
||||
$this->assertLinkId((string) ($payload['link_id'] ?? ''));
|
||||
|
||||
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/update_link', $payload);
|
||||
}
|
||||
|
||||
public function deleteLink(string $linkId): void
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
$this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/delete_link', ['link_id' => $linkId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定获客链接添加的客户。单页最多 1000 条。
|
||||
*
|
||||
* @return array{customer_list:list<array<string,mixed>>,next_cursor:string}
|
||||
*/
|
||||
public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array
|
||||
{
|
||||
$this->assertLinkId($linkId);
|
||||
$body = [
|
||||
'link_id' => $linkId,
|
||||
'limit' => min(1000, max(1, $limit)),
|
||||
];
|
||||
if ($cursor !== '') {
|
||||
$body['cursor'] = $cursor;
|
||||
}
|
||||
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/customer', $body);
|
||||
$customers = array_values(array_filter(
|
||||
(array) ($response['customer_list'] ?? []),
|
||||
static fn (mixed $row): bool => is_array($row)
|
||||
));
|
||||
|
||||
return [
|
||||
'customer_list' => $customers,
|
||||
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function getChatInfo(string $chatKey): array
|
||||
{
|
||||
$chatKey = trim($chatKey);
|
||||
if ($chatKey === '' || strlen($chatKey) > 512) {
|
||||
throw new RuntimeException('获客会话 ChatKey 不正确');
|
||||
}
|
||||
|
||||
return $this->request(
|
||||
'POST',
|
||||
'cgi-bin/externalcontact/customer_acquisition/get_chat_info',
|
||||
['chat_key' => $chatKey]
|
||||
);
|
||||
}
|
||||
|
||||
/** 通过只读列表接口验证 token、可信 IP、获客助手开通状态与应用权限。 */
|
||||
public function checkPermission(): array
|
||||
{
|
||||
$result = $this->listLinks('', 1);
|
||||
$hasLink = $result['link_id_list'] !== [];
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => $hasLink
|
||||
? '获客助手 API 权限验证通过,当前应用已有官方获客链接'
|
||||
: '获客助手 API 权限验证通过,但当前应用尚未通过 API 创建官方获客链接',
|
||||
'has_link' => $hasLink,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function request(string $method, string $path, array $body = [], bool $retried = false): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
$cacheKey = $this->tokenCacheKey();
|
||||
$token = $this->accessToken();
|
||||
try {
|
||||
$options = ['query' => ['access_token' => $token]];
|
||||
if (strtoupper($method) === 'POST') {
|
||||
$options['json'] = $body;
|
||||
}
|
||||
$response = $this->client->request($method, ltrim($path, '/'), $options);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('企业微信获客助手接口连接失败,请检查服务器网络与可信 IP 配置', 0, $e);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('企业微信获客助手接口返回了无法解析的数据');
|
||||
}
|
||||
$errcode = (int) ($decoded['errcode'] ?? 0);
|
||||
if ($errcode === 0) {
|
||||
return $decoded;
|
||||
}
|
||||
if (!$retried && in_array($errcode, self::TOKEN_INVALID_CODES, true)) {
|
||||
Cache::delete($cacheKey);
|
||||
|
||||
return $this->request($method, $path, $body, true);
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'企业微信获客助手接口失败[%d]:%s',
|
||||
$errcode,
|
||||
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
|
||||
));
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->accessTokenResolver !== null) {
|
||||
$token = trim((string) call_user_func($this->accessTokenResolver));
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('托管 access_token 为空');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
$cacheKey = $this->tokenCacheKey();
|
||||
$cached = trim((string) Cache::get($cacheKey, ''));
|
||||
if ($cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
|
||||
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('获取企业微信 access_token 失败,请检查服务器网络', 0, $e);
|
||||
}
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($decoded) || (int) ($decoded['errcode'] ?? 0) !== 0 || empty($decoded['access_token'])) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'获取企业微信 access_token 失败[%d]:%s',
|
||||
(int) ($decoded['errcode'] ?? -1),
|
||||
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
|
||||
));
|
||||
}
|
||||
$token = (string) $decoded['access_token'];
|
||||
Cache::set($cacheKey, $token, max(60, (int) ($decoded['expires_in'] ?? 7200) - 300));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function tokenCacheKey(): string
|
||||
{
|
||||
return 'qywx_customer_acquisition_token:' . hash('sha256', $this->corpId . '|' . $this->secret);
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['configured']) {
|
||||
throw new RuntimeException('获客助手应用配置不完整:缺少 ' . implode('、', $status['missing']));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertLinkId(string $linkId): void
|
||||
{
|
||||
if ($linkId === '' || strlen($linkId) > 128) {
|
||||
throw new RuntimeException('获客链接 ID 不正确');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客客户归因、会话统计与回调幂等落库。 */
|
||||
class QywxCustomerAcquisitionCustomerService
|
||||
{
|
||||
private QywxCustomerAcquisitionApiService $api;
|
||||
|
||||
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
|
||||
{
|
||||
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步一个远端获客链接的全部客户,远端列表字段采用覆盖语义。
|
||||
* recv_msg_cnt 不在列表接口中返回,因此同步时保留本地值。
|
||||
*
|
||||
* @return array{scanned:int,created:int,updated:int,pages:int,truncated:bool}
|
||||
*/
|
||||
public function syncLink(string $remoteLinkId, int $maxCustomers = 20000): array
|
||||
{
|
||||
$remoteLinkId = trim($remoteLinkId);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('获客链接 ID 不能为空');
|
||||
}
|
||||
$cursor = '';
|
||||
$scanned = 0;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$pages = 0;
|
||||
do {
|
||||
$page = $this->api->listCustomers($remoteLinkId, $cursor, 1000);
|
||||
$pages++;
|
||||
foreach ($page['customer_list'] as $customer) {
|
||||
if ($scanned >= $maxCustomers) {
|
||||
break 2;
|
||||
}
|
||||
$scanned++;
|
||||
$result = self::upsertCustomer($remoteLinkId, $customer, false);
|
||||
$result === 'created' ? $created++ : $updated++;
|
||||
}
|
||||
$cursor = (string) ($page['next_cursor'] ?? '');
|
||||
} while ($cursor !== '');
|
||||
|
||||
return compact('scanned', 'created', 'updated', 'pages') + ['truncated' => $cursor !== ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 customer_acquisition 回调。相同事件只成功处理一次;失败事件保留审计并允许企微重试。
|
||||
*
|
||||
* @return array{duplicate:bool,status:string}
|
||||
*/
|
||||
public function handleCallback(array $message): array
|
||||
{
|
||||
$changeType = trim((string) ($message['ChangeType'] ?? $message['change_type'] ?? ''));
|
||||
if (!in_array($changeType, ['customer_start_chat', 'message_from_customer'], true)) {
|
||||
return ['duplicate' => false, 'status' => 'ignored'];
|
||||
}
|
||||
$chatKey = trim((string) ($message['ChatKey'] ?? $message['Chatkey'] ?? $message['chat_key'] ?? ''));
|
||||
$eventTime = (int) ($message['CreateTime'] ?? $message['create_time'] ?? 0);
|
||||
$eventKey = self::eventKey($message, $changeType, $chatKey, $eventTime);
|
||||
$event = self::beginEvent($eventKey, $changeType, $chatKey, $eventTime, $message);
|
||||
if (($event['duplicate'] ?? false) === true) {
|
||||
return ['duplicate' => true, 'status' => 'success'];
|
||||
}
|
||||
|
||||
$eventId = (int) ($event['id'] ?? 0);
|
||||
try {
|
||||
// customer_start_chat 仅能确认“客户已发起会话”,企业微信不保证该事件携带 ChatKey。
|
||||
// 此时先落归因与聊天状态,精确消息数等待 message_from_customer 回调补齐。
|
||||
if ($changeType === 'customer_start_chat' && $chatKey === '') {
|
||||
$remoteLinkId = trim((string) (
|
||||
$message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
|
||||
));
|
||||
$externalUserId = trim((string) (
|
||||
$message['ExternalUserID'] ?? $message['ExternalUserId'] ?? $message['external_userid'] ?? ''
|
||||
));
|
||||
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? $message['userid'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('customer_start_chat 回调缺少 link_id / external_userid / userid');
|
||||
}
|
||||
self::upsertCustomer($remoteLinkId, [
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'chat_status' => 1,
|
||||
'state' => (string) ($message['State'] ?? $message['state'] ?? ''),
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $message,
|
||||
], false);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
}
|
||||
if ($chatKey === '') {
|
||||
self::finishEvent($eventId, 3, 'failed_invalid: message_from_customer 回调缺少 ChatKey');
|
||||
throw new RuntimeException('message_from_customer 回调缺少 ChatKey');
|
||||
}
|
||||
$now = time();
|
||||
if ($eventTime > 0 && ($now - $eventTime) >= 1800) {
|
||||
throw new RuntimeException('获客回调 ChatKey 已超过 30 分钟有效期');
|
||||
}
|
||||
$chat = $this->api->getChatInfo($chatKey);
|
||||
$chatInfo = is_array($chat['chat_info'] ?? null) ? $chat['chat_info'] : [];
|
||||
$remoteLinkId = trim((string) (
|
||||
$chatInfo['link_id'] ?? $message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
|
||||
));
|
||||
$externalUserId = trim((string) (
|
||||
$chat['external_userid'] ?? $message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''
|
||||
));
|
||||
$userId = trim((string) ($chat['userid'] ?? $message['UserID'] ?? $message['UserId'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('get_chat_info 未返回完整的 link_id / external_userid / userid');
|
||||
}
|
||||
self::upsertCustomer($remoteLinkId, [
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'chat_status' => max(1, (int) ($message['ChatStatus'] ?? 1)),
|
||||
'recv_msg_cnt' => max(0, (int) ($chatInfo['recv_msg_cnt'] ?? 0)),
|
||||
'state' => (string) ($chatInfo['state'] ?? $message['State'] ?? ''),
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $chat,
|
||||
], true);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
} catch (\Throwable $e) {
|
||||
if ($eventId > 0 && str_contains($e->getMessage(), 'message_from_customer 回调缺少 ChatKey')) {
|
||||
throw $e;
|
||||
}
|
||||
self::scheduleRetryOrExpire($eventId, $eventTime, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试仍在 ChatKey 30 分钟有效期内的失败回调,并把到期记录明确标记 failed_expired。
|
||||
*
|
||||
* @return array{selected:int,success:int,failed:int,expired:int}
|
||||
*/
|
||||
public function retryPending(int $limit = 100): array
|
||||
{
|
||||
$now = time();
|
||||
// 进程在 beginEvent 后异常退出时,处理中事件会卡在 status=0;一分钟后自动回收再试。
|
||||
Db::name('qywx_customer_acquisition_event')
|
||||
->where('status', 0)
|
||||
->where('update_time', '<=', $now - 60)
|
||||
->where('expire_time', '>', $now)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'next_retry' => $now,
|
||||
'error_message' => 'watchdog_recovered: 上次处理未正常结束',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$expired = (int) Db::name('qywx_customer_acquisition_event')
|
||||
->whereIn('status', [0, 2])
|
||||
->where('expire_time', '>', 0)
|
||||
->where('expire_time', '<=', $now)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'next_retry' => 0,
|
||||
'error_message' => 'failed_expired: ChatKey 已超过 30 分钟有效期',
|
||||
'chat_key' => '',
|
||||
'raw_json' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$rows = Db::name('qywx_customer_acquisition_event')
|
||||
->where('status', 2)
|
||||
->where('next_retry', '<=', $now)
|
||||
->where('expire_time', '>', $now)
|
||||
->order('next_retry', 'asc')
|
||||
->limit(min(500, max(1, $limit)))
|
||||
->select()->toArray();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
foreach ($rows as $row) {
|
||||
$message = json_decode((string) ($row['raw_json'] ?? ''), true);
|
||||
if (!is_array($message)) {
|
||||
self::scheduleRetryOrExpire(
|
||||
(int) $row['id'],
|
||||
(int) ($row['event_time'] ?? 0),
|
||||
'回调原始数据无法解析'
|
||||
);
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->handleCallback($message);
|
||||
$success++;
|
||||
} catch (\Throwable) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['selected' => count($rows), 'success' => $success, 'failed' => $failed, 'expired' => $expired];
|
||||
}
|
||||
|
||||
public static function eventKey(array $message, string $changeType, string $chatKey, int $eventTime): string
|
||||
{
|
||||
$parts = [
|
||||
(string) ($message['MsgId'] ?? $message['MsgID'] ?? ''),
|
||||
$changeType,
|
||||
$chatKey,
|
||||
(string) $eventTime,
|
||||
(string) ($message['LinkID'] ?? $message['LinkId'] ?? ''),
|
||||
(string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''),
|
||||
(string) ($message['UserID'] ?? $message['UserId'] ?? ''),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/** @return array{expire_time:int,next_retry:int,expired:bool} */
|
||||
public static function retryDecision(int $eventTime, int $now, int $storedExpireTime = 0): array
|
||||
{
|
||||
$expireTime = $storedExpireTime > 0
|
||||
? $storedExpireTime
|
||||
: ($eventTime > 0 ? $eventTime + 1800 : $now + 1800);
|
||||
$expired = $expireTime <= $now;
|
||||
|
||||
return [
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => $expired ? 0 : min($expireTime - 1, $now + 30),
|
||||
'expired' => $expired,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{id:int,duplicate:bool} */
|
||||
private static function beginEvent(
|
||||
string $eventKey,
|
||||
string $changeType,
|
||||
string $chatKey,
|
||||
int $eventTime,
|
||||
array $message
|
||||
): array {
|
||||
$now = time();
|
||||
$raw = self::encodeJson($message);
|
||||
$expireTime = self::retryDecision($eventTime, $now)['expire_time'];
|
||||
try {
|
||||
$id = (int) Db::name('qywx_customer_acquisition_event')->insertGetId([
|
||||
'event_key' => $eventKey,
|
||||
'change_type' => $changeType,
|
||||
'chat_key' => $chatKey,
|
||||
'status' => 0,
|
||||
'attempts' => 1,
|
||||
'event_time' => max(0, $eventTime),
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => 0,
|
||||
'error_message' => '',
|
||||
'raw_json' => $raw,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
return ['id' => $id, 'duplicate' => false];
|
||||
} catch (\Throwable $e) {
|
||||
$existing = Db::name('qywx_customer_acquisition_event')->where('event_key', $eventKey)->find();
|
||||
if (!$existing) {
|
||||
throw $e;
|
||||
}
|
||||
if ((int) ($existing['status'] ?? 0) === 1) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
if ((int) ($existing['status'] ?? 0) !== 2) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
$claimed = Db::name('qywx_customer_acquisition_event')
|
||||
->where('id', (int) $existing['id'])
|
||||
->where('status', 2)
|
||||
->update([
|
||||
'status' => 0,
|
||||
'attempts' => (int) ($existing['attempts'] ?? 0) + 1,
|
||||
'error_message' => '',
|
||||
'raw_json' => $raw,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($claimed <= 0) {
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => true];
|
||||
}
|
||||
|
||||
return ['id' => (int) $existing['id'], 'duplicate' => false];
|
||||
}
|
||||
}
|
||||
|
||||
private static function finishEvent(
|
||||
int $id,
|
||||
int $status,
|
||||
string $error = '',
|
||||
string $remoteLinkId = '',
|
||||
string $externalUserId = '',
|
||||
string $userId = ''
|
||||
): void {
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'link_id' => $remoteLinkId,
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'next_retry' => 0,
|
||||
// ChatKey 是短时敏感凭证,终态后不再保留;原始回调也随之清理。
|
||||
'chat_key' => '',
|
||||
'raw_json' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function scheduleRetryOrExpire(int $id, int $eventTime, string $error): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
$expireTime = (int) (Db::name('qywx_customer_acquisition_event')
|
||||
->where('id', $id)->value('expire_time') ?? 0);
|
||||
$decision = self::retryDecision($eventTime, $now, $expireTime);
|
||||
$expireTime = $decision['expire_time'];
|
||||
$expired = $decision['expired'];
|
||||
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
|
||||
'status' => $expired ? 3 : 2,
|
||||
'expire_time' => $expireTime,
|
||||
'next_retry' => $decision['next_retry'],
|
||||
'error_message' => mb_substr(
|
||||
$expired ? 'failed_expired: ' . $error : $error,
|
||||
0,
|
||||
1000
|
||||
),
|
||||
'chat_key' => $expired ? '' : Db::raw('chat_key'),
|
||||
'raw_json' => $expired ? null : Db::raw('raw_json'),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return 'created'|'updated' */
|
||||
private static function upsertCustomer(string $remoteLinkId, array $customer, bool $messageCountKnown): string
|
||||
{
|
||||
$externalUserId = trim((string) ($customer['external_userid'] ?? ''));
|
||||
$userId = trim((string) ($customer['userid'] ?? ''));
|
||||
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
|
||||
throw new RuntimeException('获客客户数据缺少 link_id / external_userid / userid');
|
||||
}
|
||||
[$ownerAdminId, $deptId] = self::resolveOwner($userId);
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_customer_acquisition_customer')
|
||||
->where('link_id', $remoteLinkId)
|
||||
->where('external_userid', $externalUserId)
|
||||
->where('userid', $userId)
|
||||
->find();
|
||||
$snapshot = $customer['snapshot'] ?? $customer;
|
||||
$incomingChatStatus = max(0, min(2, (int) ($customer['chat_status'] ?? 0)));
|
||||
$data = [
|
||||
'promotion_link_id' => (int) (Db::name('qywx_promotion_link')
|
||||
->where('remote_link_id', $remoteLinkId)->value('id') ?? 0),
|
||||
'owner_admin_id' => $ownerAdminId,
|
||||
'dept_id' => $deptId,
|
||||
'state' => mb_substr((string) ($customer['state'] ?? ''), 0, 255),
|
||||
// 已确认发过消息后,列表同步返回的“未发/未知”不得把状态回退。
|
||||
'chat_status' => $existing
|
||||
? Db::raw('CASE WHEN chat_status = 1 OR ' . $incomingChatStatus . ' = 1 THEN 1 ELSE ' . $incomingChatStatus . ' END')
|
||||
: $incomingChatStatus,
|
||||
'last_sync_time' => $now,
|
||||
'raw_snapshot' => self::encodeJson($snapshot),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($messageCountKnown) {
|
||||
$remoteCount = max(0, (int) ($customer['recv_msg_cnt'] ?? 0));
|
||||
// get_chat_info 返回累计值,必须 max/覆盖,绝不按回调次数累加。
|
||||
$data['recv_msg_cnt'] = $existing
|
||||
? Db::raw('GREATEST(recv_msg_cnt,' . $remoteCount . ')')
|
||||
: $remoteCount;
|
||||
$data['message_count_known'] = 1;
|
||||
}
|
||||
if ($incomingChatStatus === 1 || $messageCountKnown) {
|
||||
$eventTime = max(0, (int) ($customer['event_time'] ?? $now));
|
||||
$data['last_chat_time'] = $existing
|
||||
? Db::raw('GREATEST(last_chat_time,' . $eventTime . ')')
|
||||
: $eventTime;
|
||||
}
|
||||
if ($existing) {
|
||||
Db::name('qywx_customer_acquisition_customer')->where('id', (int) $existing['id'])->update($data);
|
||||
|
||||
return 'updated';
|
||||
}
|
||||
$data += [
|
||||
'link_id' => $remoteLinkId,
|
||||
'external_userid' => $externalUserId,
|
||||
'userid' => $userId,
|
||||
'recv_msg_cnt' => $messageCountKnown ? max(0, (int) ($customer['recv_msg_cnt'] ?? 0)) : 0,
|
||||
'message_count_known' => $messageCountKnown ? 1 : 0,
|
||||
'first_acquired_time' => max(0, (int) ($customer['create_time'] ?? $customer['event_time'] ?? $now)),
|
||||
'last_chat_time' => ($incomingChatStatus === 1 || $messageCountKnown)
|
||||
? max(0, (int) ($customer['event_time'] ?? $now))
|
||||
: 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
Db::name('qywx_customer_acquisition_customer')->insert($data);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int} */
|
||||
private static function resolveOwner(string $userId): array
|
||||
{
|
||||
$adminId = (int) (Db::name('admin')->where('work_wechat_userid', $userId)
|
||||
->whereNull('delete_time')->value('id') ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return [0, 0];
|
||||
}
|
||||
$deptId = (int) (Db::name('admin_dept')->where('admin_id', $adminId)
|
||||
->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
|
||||
return [$adminId, $deptId];
|
||||
}
|
||||
|
||||
private static function encodeJson(mixed $value): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json === false ? '{}' : $json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
/** 企业微信获客助手链接校验。 */
|
||||
class QywxCustomerAcquisitionLinkService
|
||||
{
|
||||
private const HOST = 'work.weixin.qq.com';
|
||||
|
||||
/**
|
||||
* 只接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。
|
||||
*/
|
||||
public static function isAllowed(string $url, bool $allowEmpty = false): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return $allowEmpty;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts)
|
||||
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|
||||
|| strtolower((string) ($parts['host'] ?? '')) !== self::HOST
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| (isset($parts['port']) && (int) $parts['port'] !== 443)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
|
||||
return preg_match('#^/ca/[A-Za-z0-9_-]+/?$#', $path) === 1;
|
||||
}
|
||||
|
||||
public static function example(): string
|
||||
{
|
||||
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 企业微信推广凭证加密器:密钥仅来自服务器配置,密文可安全落库。 */
|
||||
class QywxPromotionCredentialCipher
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('企业微信授权凭证加密失败');
|
||||
}
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'v' => 1,
|
||||
'iv' => base64_encode($iv),
|
||||
'tag' => base64_encode($tag),
|
||||
'data' => base64_encode($cipher),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function decrypt(string $payload): string
|
||||
{
|
||||
if ($payload === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$json = base64_decode($payload, true);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$iv = base64_decode((string) ($data['iv'] ?? ''), true);
|
||||
$tag = base64_decode((string) ($data['tag'] ?? ''), true);
|
||||
$cipher = base64_decode((string) ($data['data'] ?? ''), true);
|
||||
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('企业微信授权凭证解密失败,请检查 CREDENTIAL_KEY 是否发生变更');
|
||||
}
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$material = trim((string) config('qywx_promotion.credential_key', ''));
|
||||
if ($material === '') {
|
||||
$material = trim((string) config('qywx_promotion.suite_secret', ''));
|
||||
}
|
||||
if ($material === '') {
|
||||
throw new RuntimeException('未配置企业微信推广凭证加密密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', $material, true);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user