first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
+181
View File
@@ -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 CompletionsOpenAI 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];
}
}
+100
View File
@@ -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 集合)或 nullALL = 不过滤)。
*/
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 服务
* - 目前仅支持腾讯云 COSqcloud)
* - 其他 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
+108
View File
@@ -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;
/**
* 京东官方物流轨迹查询(京东物流开放平台 LOPhttps://api.jdl.com
*
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
* 同时供后台「京东接口更新」按钮直接拉取并落库。
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
* 未配置时 isConfigured()=falseExpressTrackService 完全沿用快递100 逻辑,互不影响。
*
* 接口:京东物流标准轨迹服务 /jd/tracking/query2025-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/对接方案编码/单号类型 走 .envJD_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_secretmd5-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/querybody 为 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');
// 待签串:固定顺序拼接,首尾包 appSecretmethod=接口pathparam_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 / HMacSHA512base64(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;
}
}
+166
View File
@@ -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);
}
}
}
+174
View File
@@ -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_keyCAM 密钥)'];
}
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;
// MixTranscodeParamsSDK 说明「若设置该参数则内部字段须填全」。仅填 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());
}
}
}
+244
View File
@@ -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 药材名 => 甘草 idconfig 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:支付宝会员的用户 IDALIPAY_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('恩济药房返回了无效 JSONHTTP ' . $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('恩济药房返回了无效 JSONHTTP ' . $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