;rgb:0000/0000/0000
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\service\DirectUploadService;
|
||||
use app\common\service\UploadService;
|
||||
use Exception;
|
||||
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
|
||||
{
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
try {
|
||||
$result = DirectUploadService::issueCredentials($type);
|
||||
$this->assertDirectUploadPermission($type);
|
||||
$result = DirectUploadService::issueCredentials(
|
||||
$type,
|
||||
$this->adminId,
|
||||
trim((string)$this->request->post('name', ''))
|
||||
);
|
||||
return $this->success('ok', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
|
||||
public function ossConfirm()
|
||||
{
|
||||
try {
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
$this->assertDirectUploadPermission($type);
|
||||
$result = DirectUploadService::confirm([
|
||||
'type' => trim((string)$this->request->post('type', 'video')),
|
||||
'type' => $type,
|
||||
'key' => trim((string)$this->request->post('key', '')),
|
||||
'name' => trim((string)$this->request->post('name', '')),
|
||||
'size' => (int)$this->request->post('size', 0),
|
||||
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertDirectUploadPermission(string $type): void
|
||||
{
|
||||
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|
||||
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
|
||||
$permissions = array_map('strtolower', $permissions);
|
||||
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
|
||||
throw new Exception('权限不足,无法上传医生工作站安装包');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,13 +5,48 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
|
||||
class WecomPromotionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
|
||||
public function tagOptions()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
|
||||
}
|
||||
|
||||
public function createTag()
|
||||
{
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->fail('请使用 POST 创建标签');
|
||||
}
|
||||
$name = $this->request->post('name', '');
|
||||
if (!is_string($name)) {
|
||||
return $this->fail('标签名称格式不正确');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
|
||||
}
|
||||
|
||||
public function uploadWelcomeMedia()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
|
||||
$this->request->file('file'),
|
||||
(string) $this->request->post('type', ''),
|
||||
$this->adminId
|
||||
)));
|
||||
}
|
||||
|
||||
public function overview()
|
||||
{
|
||||
@@ -31,8 +66,25 @@ class WecomPromotionController extends BaseAdminController
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$params = $this->request->post();
|
||||
if ((int) ($params['id'] ?? 0) <= 0 && !$this->hasBasePagePermission()) {
|
||||
return $this->fail('共享操作人只能编辑已授权方案,不能新建分流方案');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
|
||||
$params,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function batchUpdatePools()
|
||||
{
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
@@ -52,9 +104,22 @@ class WecomPromotionController extends BaseAdminController
|
||||
)));
|
||||
}
|
||||
|
||||
public function batchSetOperators()
|
||||
{
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('方案操作人已批量更新', WecomPromotionLogic::batchSetOperators(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function deletePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
@@ -108,7 +173,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
|
||||
public function checkApiPermission()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
@@ -117,7 +182,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
|
||||
public function syncRemoteLinks()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$poolId = (int) $this->request->post('pool_id', 0);
|
||||
@@ -145,7 +210,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
|
||||
public function deleteRemoteLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
@@ -200,7 +265,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
|
||||
public function deleteLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
@@ -223,10 +288,11 @@ class WecomPromotionController extends BaseAdminController
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
return QywxPromotionOperatorAccess::hasPagePermission($this->adminId, $this->adminInfo);
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
private function hasBasePagePermission(): bool
|
||||
{
|
||||
return QywxPromotionOperatorAccess::hasBasePagePermission($this->adminId, $this->adminInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\controller\order;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\order\OrderLists;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\order\OrderActionLogLogic;
|
||||
use app\adminapi\logic\order\OrderLogic;
|
||||
use app\adminapi\validate\order\OrderValidate;
|
||||
@@ -17,6 +18,8 @@ use app\adminapi\validate\order\OrderValidate;
|
||||
*/
|
||||
class OrderController extends BaseAdminController
|
||||
{
|
||||
private const EDIT_TIME_PERMISSION = 'order.order/editTime';
|
||||
|
||||
/**
|
||||
* @notes 订单列表
|
||||
* @return \think\response\Json
|
||||
@@ -341,13 +344,21 @@ class OrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 编辑订单(关联患者、订单类型)
|
||||
* @notes 编辑订单(关联患者、订单类型、支付时间、创建时间)
|
||||
* 权限:超管或指定角色组可修改任意订单;其他用户只能修改自己创建的订单
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$params = (new OrderValidate())->post()->goCheck('edit');
|
||||
$hasTimeParams = array_key_exists('payment_time', $params)
|
||||
|| array_key_exists('create_time', $params);
|
||||
if ($hasTimeParams) {
|
||||
if (!$this->canEditOrderTime()) {
|
||||
return $this->fail('无权限修改订单支付时间或创建时间');
|
||||
}
|
||||
$params = (new OrderValidate())->post()->goCheck('edit_time');
|
||||
}
|
||||
$orderId = (int)$params['id'];
|
||||
$order = \app\common\model\Order::find($orderId);
|
||||
if (!$order) {
|
||||
@@ -357,11 +368,15 @@ class OrderController extends BaseAdminController
|
||||
return $this->fail('无权限修改此订单');
|
||||
}
|
||||
|
||||
$result = OrderLogic::edit($orderId, $params);
|
||||
$result = OrderLogic::edit($orderId, $params, $hasTimeParams);
|
||||
if (!$result) {
|
||||
return $this->fail(OrderLogic::getError());
|
||||
}
|
||||
$this->logOrderAction($orderId, 'edit', '编辑患者/订单类型等');
|
||||
$this->logOrderAction(
|
||||
$orderId,
|
||||
'edit',
|
||||
$hasTimeParams ? '编辑患者/订单类型/支付时间/创建时间等' : '编辑患者/订单类型等'
|
||||
);
|
||||
|
||||
return $this->success('编辑成功');
|
||||
}
|
||||
@@ -382,6 +397,18 @@ class OrderController extends BaseAdminController
|
||||
return (int)$order->creator_id === $this->adminId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 是否拥有支付单时间修正权限
|
||||
*/
|
||||
private function canEditOrderTime(): bool
|
||||
{
|
||||
if (!empty($this->adminInfo['root']) && (int)$this->adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::EDIT_TIME_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 支付订单
|
||||
* @return \think\response\Json
|
||||
|
||||
@@ -4,16 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\qywx;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
|
||||
/**
|
||||
* 企业微信客户管理控制器
|
||||
*/
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
private const DELETE_PERMISSION = 'qywx.customer/delete';
|
||||
|
||||
/**
|
||||
* @notes 客户列表
|
||||
*/
|
||||
@@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
*/
|
||||
public function sync()
|
||||
{
|
||||
public function sync()
|
||||
{
|
||||
$result = CustomerLogic::triggerBackgroundSync();
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
$msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步';
|
||||
|
||||
return $this->success($msg, $result);
|
||||
}
|
||||
return $this->success($msg, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除一条本地企业微信客户同步记录
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
// 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。
|
||||
if (!$this->canDeleteCustomer()) {
|
||||
return $this->fail('权限不足,无法删除企业微信客户');
|
||||
}
|
||||
|
||||
$params = (new CustomerValidate())->post()->goCheck('delete');
|
||||
if (!CustomerLogic::deleteCustomer((int) $params['id'])) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取统计信息
|
||||
@@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController
|
||||
/**
|
||||
* @notes 保存同步设置
|
||||
*/
|
||||
public function saveSyncSettings()
|
||||
public function saveSyncSettings()
|
||||
{
|
||||
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
|
||||
$result = CustomerLogic::saveSyncSettings($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
private function canDeleteCustomer(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,13 +1010,20 @@ class DiagnosisController extends BaseAdminController
|
||||
}
|
||||
if ($result === null) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
|
||||
'message' => DiagnosisAiLogic::getError(),
|
||||
]);
|
||||
} else {
|
||||
$emit('done', $result);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
|
||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
||||
'profile' => (string) ($prepared['profile'] ?? ''),
|
||||
'task' => (string) ($prepared['task'] ?? ''),
|
||||
'admin_id' => (int) ($prepared['admin_id'] ?? 0),
|
||||
'exception_class' => get_class($e),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
|
||||
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('关联支付单成功', $result);
|
||||
}
|
||||
|
||||
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
|
||||
public function unlinkPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
|
||||
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('收款关联已移除,金额已同步更新', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,9 @@ declare (strict_types=1);
|
||||
|
||||
namespace app\adminapi\http\middleware;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
use app\common\{
|
||||
cache\AdminAuthCache,
|
||||
service\JsonService
|
||||
@@ -69,10 +70,23 @@ class AuthMiddleware
|
||||
|
||||
$adminAuthCache = new AdminAuthCache($request->adminInfo['admin_id']);
|
||||
|
||||
// 当前访问路径
|
||||
$accessUri = strtolower($request->controller() . '/' . $request->action());
|
||||
// 全部路由
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
// 当前访问路径
|
||||
$accessUri = strtolower($request->controller() . '/' . $request->action());
|
||||
|
||||
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
|
||||
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
|
||||
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
|
||||
$adminUris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
||||
if ($this->isKnownWecomPromotionAction($accessUri)
|
||||
&& in_array(strtolower(QywxPromotionOperatorAccess::PAGE_PERMISSION), $adminUris, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return JsonService::fail('权限不足,无法访问或操作');
|
||||
}
|
||||
|
||||
// 全部路由
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
|
||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
@@ -204,6 +218,32 @@ class AuthMiddleware
|
||||
'tcm.prescription/audit',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function isKnownWecomPromotionAction(string $accessUri): bool
|
||||
{
|
||||
return in_array($accessUri, [
|
||||
'firstvisit.wecompromotion/tagoptions',
|
||||
'firstvisit.wecompromotion/createtag',
|
||||
'firstvisit.wecompromotion/uploadwelcomemedia',
|
||||
'firstvisit.wecompromotion/overview',
|
||||
'firstvisit.wecompromotion/savepool',
|
||||
'firstvisit.wecompromotion/batchupdatepools',
|
||||
'firstvisit.wecompromotion/savewidget',
|
||||
'firstvisit.wecompromotion/batchsetoperators',
|
||||
'firstvisit.wecompromotion/deletepool',
|
||||
'firstvisit.wecompromotion/savelink',
|
||||
'firstvisit.wecompromotion/savemember',
|
||||
'firstvisit.wecompromotion/togglemember',
|
||||
'firstvisit.wecompromotion/checkapipermission',
|
||||
'firstvisit.wecompromotion/syncremotelinks',
|
||||
'firstvisit.wecompromotion/remotelinkdetail',
|
||||
'firstvisit.wecompromotion/deleteremotelink',
|
||||
'firstvisit.wecompromotion/synccustomers',
|
||||
'firstvisit.wecompromotion/customerstatistics',
|
||||
'firstvisit.wecompromotion/togglelink',
|
||||
'firstvisit.wecompromotion/deletelink',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方库 lists:与开方、处方库维护菜单权限互通(避免开方页「从处方库导入」403)
|
||||
|
||||
@@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按客户当前跟进关系中的添加方式筛选。
|
||||
*
|
||||
* follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值;
|
||||
* 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。
|
||||
*/
|
||||
private function applyAddWayFilter($query): void
|
||||
{
|
||||
if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$addWay = self::normalizeAddWay($this->params['add_way']);
|
||||
if ($addWay === null) {
|
||||
$query->whereRaw('1=0');
|
||||
return;
|
||||
}
|
||||
|
||||
$numberPrefix = '%"add_way":' . $addWay;
|
||||
$stringPrefix = '%"add_way":"' . $addWay;
|
||||
$query->where(function ($q) use ($numberPrefix, $stringPrefix) {
|
||||
$q->where('follow_users', 'like', $numberPrefix . ',%')
|
||||
->whereOr('follow_users', 'like', $numberPrefix . '}%')
|
||||
->whereOr('follow_users', 'like', $stringPrefix . '",%')
|
||||
->whereOr('follow_users', 'like', $stringPrefix . '"}%');
|
||||
});
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$query = QywxExternalContact::where($this->searchWhere);
|
||||
|
||||
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
|
||||
$followAlreadyScoped = $this->applyAddTimeFilter($query);
|
||||
if (!$followAlreadyScoped) {
|
||||
$this->applyFollowUserFilter($query);
|
||||
}
|
||||
|
||||
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
|
||||
if (!$followAlreadyScoped) {
|
||||
$this->applyFollowUserFilter($query);
|
||||
}
|
||||
$this->applyAddWayFilter($query);
|
||||
|
||||
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
|
||||
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
|
||||
$tagIds = $this->normalizeTagIds();
|
||||
if ($tagIds !== []) {
|
||||
@@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取当前页客户的加客渠道流水。
|
||||
*
|
||||
* 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。
|
||||
* 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。
|
||||
*
|
||||
* @param string[] $externalUserids
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function loadAddChannelsByExternalUserid(array $externalUserids): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($externalUserids as $externalUserid) {
|
||||
$externalUserid = trim((string) $externalUserid);
|
||||
if ($externalUserid !== '') {
|
||||
$ids[$externalUserid] = true;
|
||||
}
|
||||
}
|
||||
$ids = array_keys($ids);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$events = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('state', '<>', '')
|
||||
->whereIn('external_userid', $ids)
|
||||
->field(['id', 'external_userid', 'user_id', 'state', 'event_time'])
|
||||
->order('event_time', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$poolIds = [];
|
||||
foreach ($events as $event) {
|
||||
$state = trim((string) ($event['state'] ?? ''));
|
||||
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
|
||||
$poolIds[(int) $matches[1]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$poolNamesById = [];
|
||||
if ($poolIds !== []) {
|
||||
// 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。
|
||||
$poolNamesById = Db::name('qywx_promotion_pool')
|
||||
->whereIn('id', array_keys($poolIds))
|
||||
->column('name', 'id');
|
||||
}
|
||||
|
||||
return self::projectAddChannelEvents($events, $poolNamesById);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $events 已按 event_time DESC, id DESC 排序
|
||||
* @param array<int|string, string> $poolNamesById
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private static function projectAddChannelEvents(array $events, array $poolNamesById): array
|
||||
{
|
||||
$channelsByExternalUserid = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$externalUserid = trim((string) ($event['external_userid'] ?? ''));
|
||||
$state = trim((string) ($event['state'] ?? ''));
|
||||
if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$externalUserid][$state] = true;
|
||||
|
||||
$poolId = 0;
|
||||
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
|
||||
$poolId = (int) $matches[1];
|
||||
}
|
||||
$poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : '';
|
||||
|
||||
$channelsByExternalUserid[$externalUserid][] = [
|
||||
'state' => $state,
|
||||
'label' => $poolName !== ''
|
||||
? $poolName
|
||||
: ($poolId > 0 ? '获客助手方案 #' . $poolId : $state),
|
||||
'source_type' => $poolId > 0 ? 'promotion_pool' : 'state',
|
||||
'pool_id' => $poolId,
|
||||
'user_id' => trim((string) ($event['user_id'] ?? '')),
|
||||
'event_time' => (int) ($event['event_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $channelsByExternalUserid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业微信客户详情 follow_user.add_way 的可读文案。
|
||||
*
|
||||
* add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。
|
||||
* 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。
|
||||
*/
|
||||
private static function addWayLabel(int $addWay): string
|
||||
{
|
||||
$labels = [
|
||||
0 => '未知添加方式',
|
||||
1 => '通过扫描二维码添加',
|
||||
2 => '通过搜索手机号添加',
|
||||
3 => '通过名片分享添加',
|
||||
4 => '通过群聊添加',
|
||||
5 => '通过手机通讯录添加',
|
||||
6 => '通过微信联系人添加',
|
||||
8 => '安装第三方应用时自动添加',
|
||||
9 => '通过搜索邮箱添加',
|
||||
10 => '通过视频号添加',
|
||||
11 => '通过日程参与人添加',
|
||||
12 => '通过会议参与人添加',
|
||||
13 => '通过微信好友添加',
|
||||
14 => '通过智慧硬件专属客服添加',
|
||||
15 => '通过上门服务客服添加',
|
||||
16 => '通过获客链接添加',
|
||||
17 => '通过定制开发添加',
|
||||
18 => '通过需求回复添加',
|
||||
21 => '通过第三方售前客服添加',
|
||||
22 => '通过可能的商务伙伴添加',
|
||||
24 => '通过接受微信好友申请添加',
|
||||
201 => '通过内部成员共享添加',
|
||||
202 => '通过管理员或负责人分配添加',
|
||||
];
|
||||
|
||||
return $labels[$addWay] ?? '其他添加方式(' . $addWay . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function normalizeAddWay($value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value >= 0 ? $value : null;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取列表
|
||||
*/
|
||||
@@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
->toArray();
|
||||
|
||||
$wxUserids = [];
|
||||
$externalUserids = [];
|
||||
foreach ($lists as $item) {
|
||||
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
|
||||
if ($externalUserid !== '') {
|
||||
$externalUserids[$externalUserid] = true;
|
||||
}
|
||||
$raw = json_decode($item['follow_users'] ?? '[]', true);
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
@@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
if ($wxUserids !== []) {
|
||||
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
|
||||
}
|
||||
$addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids));
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
|
||||
$followUsers = is_array($followUsers) ? $followUsers : [];
|
||||
foreach ($followUsers as &$fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$wx = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
|
||||
$fu['admin_name'] = $adminNameByWx[$wx];
|
||||
}
|
||||
}
|
||||
foreach ($followUsers as &$fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$wx = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
|
||||
$fu['admin_name'] = $adminNameByWx[$wx];
|
||||
}
|
||||
$addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null);
|
||||
if ($addWay !== null) {
|
||||
$fu['add_way'] = $addWay;
|
||||
$fu['add_way_label'] = self::addWayLabel($addWay);
|
||||
}
|
||||
}
|
||||
unset($fu);
|
||||
$item['follow_users'] = $followUsers;
|
||||
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
|
||||
@@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
|
||||
$item['tags'] = is_array($tags) ? $tags : [];
|
||||
|
||||
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
|
||||
$item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? [];
|
||||
$item['add_channel_states'] = array_column($item['add_channels'], 'state');
|
||||
|
||||
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
|
||||
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
|
||||
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
|
||||
|
||||
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$allOids = array_values(array_unique($allOids));
|
||||
$amountByOid = [];
|
||||
if ($allOids !== []) {
|
||||
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
|
||||
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
|
||||
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
|
||||
->whereIn('status', [2, 5])->column('amount', 'id');
|
||||
}
|
||||
foreach ($poIds as $pid) {
|
||||
$s = 0.0;
|
||||
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
->join('order o', 'l.pay_order_id = o.id')
|
||||
->whereIn('l.prescription_order_id', $poIds)
|
||||
->whereNull('o.delete_time')
|
||||
->whereIn('o.status', [2, 5])
|
||||
->sum('o.amount');
|
||||
|
||||
return round($sum, 2);
|
||||
|
||||
@@ -18,6 +18,7 @@ use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemMenu;
|
||||
use app\common\model\auth\SystemRoleMenu;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
|
||||
|
||||
/**
|
||||
@@ -74,11 +75,9 @@ class AuthLogic
|
||||
->column('perms');
|
||||
|
||||
$hasAllAuth = array_diff($allAuth, $roleAuth);
|
||||
if (empty($hasAllAuth)) {
|
||||
return ['*'];
|
||||
}
|
||||
$permissions = empty($hasAllAuth) ? ['*'] : $roleAuth;
|
||||
|
||||
return $roleAuth;
|
||||
return self::appendSharedPromotionPermission($permissions, (int) ($admin['id'] ?? 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -94,12 +93,28 @@ class AuthLogic
|
||||
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||||
$menuId = SystemRoleMenu::whereIn('role_id', $roleIds)->column('menu_id');
|
||||
|
||||
return SystemMenu::distinct(true)
|
||||
$permissions = SystemMenu::distinct(true)
|
||||
->where([
|
||||
['is_disable', '=', 0],
|
||||
['perms', '<>', ''],
|
||||
['id', 'in', array_unique($menuId)],
|
||||
])
|
||||
->column('perms');
|
||||
|
||||
return self::appendSharedPromotionPermission($permissions, $adminId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function appendSharedPromotionPermission(array $permissions, int $adminId): array
|
||||
{
|
||||
if (in_array('*', $permissions, true)
|
||||
|| in_array(QywxPromotionOperatorAccess::PAGE_PERMISSION, $permissions, true)
|
||||
|| !QywxPromotionOperatorAccess::hasSharedPagePermission($adminId)) {
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
$permissions[] = QywxPromotionOperatorAccess::PAGE_PERMISSION;
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\SystemMenu;
|
||||
use app\common\model\auth\SystemRoleMenu;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,12 @@ class MenuLogic extends BaseLogic
|
||||
|
||||
if ($admin['root'] != 1) {
|
||||
$roleMenu = SystemRoleMenu::whereIn('role_id', $admin['role_id'])->column('menu_id');
|
||||
if (QywxPromotionOperatorAccess::hasSharedPagePermission((int) $adminId)) {
|
||||
$roleMenu = array_values(array_unique(array_merge(
|
||||
array_map('intval', $roleMenu),
|
||||
self::sharedPromotionMenuIds()
|
||||
)));
|
||||
}
|
||||
$where[] = ['id', 'in', $roleMenu];
|
||||
}
|
||||
|
||||
@@ -62,6 +69,45 @@ class MenuLogic extends BaseLogic
|
||||
}
|
||||
|
||||
|
||||
/** @return list<int> */
|
||||
private static function sharedPromotionMenuIds(): array
|
||||
{
|
||||
$menus = SystemMenu::where('is_disable', 0)
|
||||
->field('id,pid,perms')
|
||||
->select()
|
||||
->toArray();
|
||||
$byId = [];
|
||||
$pageIds = [];
|
||||
foreach ($menus as $menu) {
|
||||
$id = (int) ($menu['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$byId[$id] = $menu;
|
||||
if ((string) ($menu['perms'] ?? '') === QywxPromotionOperatorAccess::PAGE_PERMISSION) {
|
||||
$pageIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($pageIds as $pageId) {
|
||||
$chain = [];
|
||||
$currentId = $pageId;
|
||||
while ($currentId > 0) {
|
||||
if (!isset($byId[$currentId])) {
|
||||
$chain = [];
|
||||
break;
|
||||
}
|
||||
$chain[] = $currentId;
|
||||
$currentId = (int) ($byId[$currentId]['pid'] ?? 0);
|
||||
}
|
||||
$result = array_merge($result, $chain);
|
||||
}
|
||||
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 添加菜单
|
||||
* @param array $params
|
||||
@@ -181,4 +227,4 @@ class MenuLogic extends BaseLogic
|
||||
return linear_to_tree($data, 'children');
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
return [
|
||||
return self::withDeletedFansVisibility([
|
||||
'meta' => [
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
|
||||
],
|
||||
'rows' => $rows,
|
||||
'target' => $target,
|
||||
];
|
||||
], $adminInfo);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
|
||||
);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$empty = [
|
||||
$empty = self::withDeletedFansVisibility([
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$context['start_date'], $context['end_date']],
|
||||
'entity' => null,
|
||||
];
|
||||
], $adminInfo, true);
|
||||
|
||||
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['dept', 'member'], true)) {
|
||||
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
|
||||
];
|
||||
unset($result['deleted_count']);
|
||||
|
||||
return self::withDeletedFansVisibility($result, $adminInfo, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
|
||||
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
|
||||
*/
|
||||
private static function canViewDeletedFans(array $adminInfo): bool
|
||||
{
|
||||
return ($adminInfo['account'] ?? null) === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
|
||||
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
|
||||
*/
|
||||
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
|
||||
{
|
||||
$canView = self::canViewDeletedFans($adminInfo);
|
||||
if (!$canView) {
|
||||
$fields = $detail
|
||||
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
|
||||
: ['deleted_fans_count', 'deleted_count'];
|
||||
$result = self::removeDeletedFansFields($result, $fields);
|
||||
}
|
||||
if ($detail) {
|
||||
$result['can_view_deleted_fans'] = $canView;
|
||||
} else {
|
||||
$result['meta']['can_view_deleted_fans'] = $canView;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param string[] $fields */
|
||||
private static function removeDeletedFansFields(array $value, array $fields): array
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
unset($value[$field]);
|
||||
}
|
||||
foreach ($value as &$item) {
|
||||
if (is_array($item)) {
|
||||
$item = self::removeDeletedFansFields($item, $fields);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the clicked entity and its authorized target range. This is
|
||||
* deliberately structural: it avoids recomputing all overview metrics,
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -20,7 +21,12 @@ class WecomAcquisitionCustomerLogic
|
||||
->whereNull('l.delete_time')
|
||||
->where('l.remote_link_id', '<>', '')
|
||||
->where('l.remote_status', 1);
|
||||
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
|
||||
self::applyScope(
|
||||
$query,
|
||||
'l',
|
||||
QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo),
|
||||
self::operatorPoolIds($adminId)
|
||||
);
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('l.id', $localLinkId);
|
||||
}
|
||||
@@ -55,8 +61,10 @@ class WecomAcquisitionCustomerLogic
|
||||
{
|
||||
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
|
||||
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$base = self::customerQuery($params, $visibleIds);
|
||||
$hasBasePagePermission = QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo);
|
||||
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
|
||||
$operatorPoolIds = self::operatorPoolIds($adminId);
|
||||
$base = self::customerQuery($params, $visibleIds, $operatorPoolIds);
|
||||
$total = (int) (clone $base)->count();
|
||||
$rows = $base
|
||||
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
|
||||
@@ -71,7 +79,7 @@ class WecomAcquisitionCustomerLogic
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds);
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds, $operatorPoolIds);
|
||||
$summaryRow = $summaryQuery->fieldRaw(
|
||||
'COUNT(*) AS customer_count, '
|
||||
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
|
||||
@@ -81,7 +89,9 @@ class WecomAcquisitionCustomerLogic
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'scope_label' => $hasBasePagePermission
|
||||
? DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo))
|
||||
: '仅共享方案',
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'summary' => [
|
||||
@@ -98,14 +108,14 @@ class WecomAcquisitionCustomerLogic
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerQuery(array $params, ?array $visibleIds)
|
||||
private static function customerQuery(array $params, ?array $visibleIds, array $operatorPoolIds)
|
||||
{
|
||||
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
|
||||
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
|
||||
->leftJoin('dept d', 'd.id = c.dept_id')
|
||||
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
|
||||
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
|
||||
self::applyCustomerScope($query, $visibleIds);
|
||||
self::applyCustomerScope($query, $visibleIds, $operatorPoolIds);
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('c.promotion_link_id', $localLinkId);
|
||||
@@ -131,35 +141,58 @@ class WecomAcquisitionCustomerLogic
|
||||
* 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户;
|
||||
* 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。
|
||||
*/
|
||||
private static function applyCustomerScope($query, ?array $visibleIds): void
|
||||
private static function applyCustomerScope($query, ?array $visibleIds, array $operatorPoolIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
if ($visibleIds === [] && $operatorPoolIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
|
||||
$query->where(function ($scope) use ($ids): void {
|
||||
$scope->whereIn('c.owner_admin_id', $ids)
|
||||
->whereOr(function ($unmapped) use ($ids): void {
|
||||
$unmapped->where('c.owner_admin_id', 0)
|
||||
->whereIn('l.owner_admin_id', $ids);
|
||||
});
|
||||
$query->where(function ($scope) use ($ids, $operatorPoolIds): void {
|
||||
if ($ids !== []) {
|
||||
$scope->whereIn('c.owner_admin_id', $ids)
|
||||
->whereOr(function ($unmapped) use ($ids): void {
|
||||
$unmapped->where('c.owner_admin_id', 0)
|
||||
->whereIn('l.owner_admin_id', $ids);
|
||||
});
|
||||
if ($operatorPoolIds !== []) {
|
||||
$scope->whereOr('p.id', 'in', $operatorPoolIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$scope->whereIn('p.id', $operatorPoolIds);
|
||||
});
|
||||
}
|
||||
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds): void
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds, array $operatorPoolIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
if ($visibleIds === [] && $operatorPoolIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
|
||||
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
|
||||
$query->where(function ($scope) use ($alias, $ids, $operatorPoolIds): void {
|
||||
if ($ids !== []) {
|
||||
$scope->whereIn($alias . '.owner_admin_id', $ids);
|
||||
if ($operatorPoolIds !== []) {
|
||||
$scope->whereOr($alias . '.pool_id', 'in', $operatorPoolIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$scope->whereIn($alias . '.pool_id', $operatorPoolIds);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function operatorPoolIds(int $adminId): array
|
||||
{
|
||||
return QywxPromotionOperatorAccess::activePoolIds($adminId);
|
||||
}
|
||||
|
||||
private static function maskIdentifier(string $value): string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -357,9 +357,10 @@ class OrderLogic
|
||||
* @notes 编辑订单
|
||||
* @param int $id
|
||||
* @param array $params
|
||||
* @param bool $canEditTime 是否已通过订单时间修改权限校验
|
||||
* @return bool
|
||||
*/
|
||||
public static function edit(int $id, array $params): bool
|
||||
public static function edit(int $id, array $params, bool $canEditTime = false): bool
|
||||
{
|
||||
try {
|
||||
$order = Order::find($id);
|
||||
@@ -368,6 +369,13 @@ class OrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasTimeParams = array_key_exists('payment_time', $params)
|
||||
|| array_key_exists('create_time', $params);
|
||||
if ($hasTimeParams && !$canEditTime) {
|
||||
self::setError('无权限修改订单支付时间或创建时间');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($params['remark'])) {
|
||||
$order->remark = $params['remark'];
|
||||
}
|
||||
@@ -379,6 +387,26 @@ class OrderLogic
|
||||
if (isset($params['order_type'])) {
|
||||
$order->order_type = (int)$params['order_type'];
|
||||
}
|
||||
// 支付时间参与营业额等统计,仅已支付/已退款订单允许修正且不可清空
|
||||
if (array_key_exists('payment_time', $params)) {
|
||||
$paymentTime = trim((string)$params['payment_time']);
|
||||
if (!in_array((int)$order->status, [2, 4], true)) {
|
||||
self::setError('仅已支付或已退款订单可修改支付时间');
|
||||
return false;
|
||||
}
|
||||
if ($paymentTime === '') {
|
||||
self::setError('已支付或已退款订单的支付时间不能为空');
|
||||
return false;
|
||||
}
|
||||
$order->payment_time = $paymentTime;
|
||||
}
|
||||
if (isset($params['create_time'])) {
|
||||
$createTime = (string)$params['create_time'];
|
||||
$order->create_time = self::normalizeEditedCreateTime(
|
||||
$order->getData('create_time'),
|
||||
$createTime
|
||||
);
|
||||
}
|
||||
|
||||
$order->save();
|
||||
return true;
|
||||
@@ -388,6 +416,18 @@ class OrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容历史库中 create_time 为 INT 时间戳的表结构
|
||||
*/
|
||||
private static function normalizeEditedCreateTime(mixed $storedValue, string $dateTime): int|string
|
||||
{
|
||||
if (is_int($storedValue) || (is_string($storedValue) && ctype_digit($storedValue))) {
|
||||
return (int)strtotime($dateTime);
|
||||
}
|
||||
|
||||
return $dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 支付订单
|
||||
* @param int $id
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\common\model\auth\Admin;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxSyncSettings;
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92130
|
||||
*/
|
||||
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
|
||||
public static function upsertSingleExternalContactFromApi(
|
||||
string $externalUserId,
|
||||
int $snapshotEventId = 0,
|
||||
string $snapshotFollowUserId = ''
|
||||
): void
|
||||
{
|
||||
$externalUserId = trim($externalUserId);
|
||||
if ($externalUserId === '') {
|
||||
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
|
||||
$updateCount,
|
||||
$skippedCount
|
||||
);
|
||||
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
|
||||
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
|
||||
$snapshotEventId,
|
||||
$snapshotFollowUserId,
|
||||
$followUsers
|
||||
);
|
||||
}
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
}
|
||||
|
||||
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function recordExternalContactEvent(array $data): void
|
||||
public static function recordExternalContactEvent(array $data): int
|
||||
{
|
||||
$changeType = (string) ($data['change_type'] ?? '');
|
||||
if ($changeType === '') {
|
||||
// 没有 ChangeType 的事件流水没有价值,直接丢弃
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$eventTime = (int) ($data['event_time'] ?? 0);
|
||||
@@ -716,9 +728,85 @@ class CustomerLogic extends BaseLogic
|
||||
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
|
||||
. implode(',', array_fill(0, count($cols), '?')) . ')';
|
||||
Db::execute($sql, array_values($row));
|
||||
|
||||
return (int) Db::name('qywx_external_contact_event')
|
||||
->where('change_type', $row['change_type'])
|
||||
->where('user_id', $row['user_id'])
|
||||
->where('external_userid', $row['external_userid'])
|
||||
->where('event_time', $row['event_time'])
|
||||
->value('id');
|
||||
} catch (\Throwable $e) {
|
||||
// 事件流水只用于统计,失败只记日志不阻塞主回调
|
||||
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手工删除一条本地同步记录。
|
||||
*
|
||||
* 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复
|
||||
* external_userid。只有该客户已无其他有效行时才清理共享的标签关系。
|
||||
*/
|
||||
public static function deleteCustomer(int $id): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
self::$error = '客户参数错误';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$externalUserId = Db::transaction(static function () use ($id): string {
|
||||
$row = Db::name('qywx_external_contact')
|
||||
->where('id', $id)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw new \DomainException('客户不存在或已删除');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::name('qywx_external_contact')
|
||||
->where('id', $id)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
if ($externalUserId !== '') {
|
||||
$activeRows = (int) Db::name('qywx_external_contact')
|
||||
->where('external_userid', $externalUserId)
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
if ($activeRows === 0) {
|
||||
Db::name('qywx_external_contact_tag')
|
||||
->where('external_userid', $externalUserId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return $externalUserId;
|
||||
});
|
||||
|
||||
if ($externalUserId !== '') {
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\DomainException $e) {
|
||||
self::$error = $e->getMessage();
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage());
|
||||
self::$error = '删除失败,请稍后重试';
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,8 +340,8 @@ class ConversionLogic
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the distinct external contacts behind an add_fans_count row.
|
||||
/**
|
||||
* Return the add events behind an add_fans_count row.
|
||||
*
|
||||
* The caller must pass the already-authorized admin range and the exact
|
||||
* department ids represented by the clicked tree node. This keeps the
|
||||
@@ -505,7 +505,7 @@ class ConversionLogic
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
@@ -515,15 +515,20 @@ class ConversionLogic
|
||||
);
|
||||
// Target membership has already been resolved before the event query,
|
||||
// so sorting and pagination operate on the clicked row's small set.
|
||||
$matched = $pairs;
|
||||
$matched = $pairs;
|
||||
|
||||
usort($matched, static function (array $left, array $right): int {
|
||||
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
|
||||
if ($eventCompare !== 0) {
|
||||
return $eventCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
(string) ($left['external_userid'] ?? ''),
|
||||
(string) ($right['external_userid'] ?? '')
|
||||
) ?: strcmp(
|
||||
@@ -552,45 +557,45 @@ class ConversionLogic
|
||||
$pageRows,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$pageRows
|
||||
)))));
|
||||
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
if ($deleteTime < $addTime) {
|
||||
$deleteTime = 0;
|
||||
}
|
||||
@@ -606,18 +611,18 @@ class ConversionLogic
|
||||
];
|
||||
}, $pageRows);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
* binding. null means the channel has no binding and therefore does not
|
||||
* restrict business statistics.
|
||||
*
|
||||
@@ -1262,17 +1267,17 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
*
|
||||
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
||||
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||||
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
|
||||
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
|
||||
/**
|
||||
* 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
*
|
||||
* 口径:
|
||||
* - 以 (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次;
|
||||
* - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
|
||||
* - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增;
|
||||
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
|
||||
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
|
||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数,
|
||||
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
|
||||
* 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
|
||||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||||
*
|
||||
@@ -1285,25 +1290,21 @@ class ConversionLogic
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
$effectivePairs = array_values(array_filter(
|
||||
$detailRows,
|
||||
static fn (array $row): bool => empty($row['is_deleted'])
|
||||
));
|
||||
|
||||
return self::buildFanCountRows($detailRows, $effectivePairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
|
||||
return self::buildFanCountRows($detailRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function loadFanDetailRows(
|
||||
int $startTimestamp,
|
||||
@@ -1335,7 +1336,7 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
@@ -1379,23 +1380,56 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||
. ' WHERE prev_e.user_id = e.user_id'
|
||||
. ' AND prev_e.external_userid = e.external_userid'
|
||||
. ' AND prev_e.change_type = ?'
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
|
||||
->group('e.user_id, e.external_userid');
|
||||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||
$livePairPredicate =
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time <= ?'
|
||||
. ' AND (surviving_del.event_time > surviving_e.event_time'
|
||||
. ' OR (surviving_del.event_time = surviving_e.event_time'
|
||||
. ' AND surviving_del.id > surviving_e.id))))';
|
||||
$livePairBindings = [
|
||||
'add_external_contact',
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
'del_external_contact',
|
||||
$endTimestamp,
|
||||
];
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||
. ' WHERE prev_e.user_id = e.user_id'
|
||||
. ' AND prev_e.external_userid = e.external_userid'
|
||||
. ' AND prev_e.change_type = ?'
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
// 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` earlier_e'
|
||||
. ' WHERE earlier_e.user_id = e.user_id'
|
||||
. ' AND earlier_e.external_userid = e.external_userid'
|
||||
. ' AND earlier_e.change_type = ?'
|
||||
. ' AND earlier_e.event_time >= ?'
|
||||
. ' AND earlier_e.event_time <= ?'
|
||||
. ' AND (earlier_e.event_time < e.event_time'
|
||||
. ' OR (earlier_e.event_time = e.event_time AND earlier_e.id < e.id)))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp]
|
||||
)
|
||||
->field('e.id AS add_event_id,e.user_id,e.external_userid,e.event_time AS add_time');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
} elseif ($unboundOnly) {
|
||||
@@ -1406,58 +1440,49 @@ class ConversionLogic
|
||||
. ' AND bound_a.delete_time IS NULL)'
|
||||
);
|
||||
}
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
|
||||
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw(
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$deletedQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
|
||||
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$deletedPairs = $deletedQuery
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// add_way 筛选对有效与已删粉丝使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$effectiveQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
$mediaChannel
|
||||
);
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw($livePairPredicate, $livePairBindings)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($effectivePairs as &$effectivePair) {
|
||||
$effectivePair['is_deleted'] = false;
|
||||
}
|
||||
unset($effectivePair);
|
||||
|
||||
$deletedQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$deletedQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
$mediaChannel,
|
||||
true
|
||||
);
|
||||
}
|
||||
$deletedPairs = $deletedQuery
|
||||
->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedPairs as &$deletedPair) {
|
||||
$deletedPair['is_deleted'] = true;
|
||||
}
|
||||
unset($deletedPair);
|
||||
|
||||
// add_way 筛选对有效与已删组合使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs);
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $rows;
|
||||
|
||||
@@ -1465,97 +1490,69 @@ class ConversionLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @param array<int,array<string,mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(
|
||||
array $candidatePairs,
|
||||
array $effectivePairs
|
||||
): array {
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId !== '' && $externalUserId !== '') {
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$detailsByKey = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
|
||||
if (isset($detailsByKey[$key])) {
|
||||
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
|
||||
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
|
||||
$detailsByKey[$key]['add_time'] = $addTime;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$detailsByKey[$key] = [
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => $addTime,
|
||||
'is_deleted' => !isset($effectiveKeys[$key]),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
|
||||
*
|
||||
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
|
||||
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
|
||||
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
|
||||
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $candidatePairs
|
||||
* @param array<int, array<string, mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
|
||||
{
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
|
||||
$countsByUser = [];
|
||||
$seenCandidateKeys = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($seenCandidateKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenCandidateKeys[$key] = true;
|
||||
$countsByUser[$userId] ??= [
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(array $candidatePairs): array
|
||||
{
|
||||
$detailsByPair = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($detailsByPair[$key])) {
|
||||
continue;
|
||||
}
|
||||
$detailsByPair[$key] = [
|
||||
'add_event_id' => $eventId,
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
|
||||
'is_deleted' => !empty($pair['is_deleted']),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByPair);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
|
||||
*
|
||||
* 每个不同 (user_id, external_userid) 都计入 add_fans_count;is_deleted=true 的组合同时计入
|
||||
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $candidatePairs
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function buildFanCountRows(array $candidatePairs): array
|
||||
{
|
||||
$countsByUser = [];
|
||||
$seenPairs = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($seenPairs[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenPairs[$key] = true;
|
||||
$countsByUser[$userId] ??= [
|
||||
'user_id' => $userId,
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
];
|
||||
++$countsByUser[$userId]['add_fans_count'];
|
||||
if (!isset($effectiveKeys[$key])) {
|
||||
++$countsByUser[$userId]['deleted_fans_count'];
|
||||
}
|
||||
];
|
||||
++$countsByUser[$userId]['add_fans_count'];
|
||||
if (!empty($pair['is_deleted'])) {
|
||||
++$countsByUser[$userId]['deleted_fans_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($countsByUser);
|
||||
@@ -1569,25 +1566,25 @@ class ConversionLogic
|
||||
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
|
||||
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeUncountedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$externalUserIds = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeUncountedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$externalUserIds = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($extId !== '') {
|
||||
$externalUserIds[$extId] = true;
|
||||
}
|
||||
}
|
||||
$externalIdList = array_keys($externalUserIds);
|
||||
if ($externalIdList === []) {
|
||||
return $pairs;
|
||||
$externalIdList = array_keys($externalUserIds);
|
||||
if ($externalIdList === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/** @var array<string, true> $excludedKeys user_id\0external_userid */
|
||||
@@ -1628,21 +1625,21 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
if ($excludedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($excludedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $extId === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($excludedKeys[$userId . "\0" . $extId])) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $pair;
|
||||
$kept[] = $pair;
|
||||
}
|
||||
|
||||
return $kept;
|
||||
|
||||
@@ -18,6 +18,9 @@ use think\facade\Log;
|
||||
*/
|
||||
class DiagnosisAiLogic extends BaseLogic
|
||||
{
|
||||
/** @var string Safe machine-readable code for the current assistant request. */
|
||||
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
|
||||
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
|
||||
|
||||
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
|
||||
@@ -354,6 +357,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): ?array {
|
||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||
$diagnosisId,
|
||||
$adminId,
|
||||
@@ -433,6 +437,14 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||
$profile = (string) ($prepared['profile'] ?? '');
|
||||
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
||||
$deliveredDelta = false;
|
||||
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
|
||||
$accepted = $onDelta($delta);
|
||||
if ($accepted !== false) {
|
||||
$deliveredDelta = true;
|
||||
}
|
||||
return $accepted;
|
||||
};
|
||||
|
||||
try {
|
||||
$result = DifyChatService::streamChat(
|
||||
@@ -440,7 +452,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$onDelta,
|
||||
$forwardDelta,
|
||||
$shouldAbort,
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
@@ -452,6 +464,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
@@ -464,6 +477,47 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
|
||||
// Some Dify-compatible gateways accept blocking chat but reject or
|
||||
// incompletely terminate streaming responses. Before any delta has
|
||||
// reached the doctor it is safe to make one blocking compatibility
|
||||
// attempt; after a delta, retrying could duplicate clinical text.
|
||||
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||
if (
|
||||
!$deliveredDelta
|
||||
&& in_array(
|
||||
$streamErrorCode,
|
||||
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
|
||||
true
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
self::logAssistantFailure(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
}
|
||||
if (empty($result['ok'])) {
|
||||
self::logAssistantUpstreamError(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::formatAssistantResult($prepared, $result);
|
||||
@@ -480,6 +534,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
// 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。
|
||||
$message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试');
|
||||
$errorCode = trim((string) ($result['error_code'] ?? ''));
|
||||
self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode);
|
||||
if ($errorCode !== '') {
|
||||
$message .= '(' . $errorCode . ')';
|
||||
}
|
||||
@@ -488,6 +543,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
}
|
||||
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
|
||||
if ($content === '') {
|
||||
self::$assistantErrorCode = 'EMPTY_RESPONSE';
|
||||
self::setError('AI 助手未返回内容,请重试');
|
||||
return null;
|
||||
}
|
||||
@@ -520,6 +576,20 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** Return a safe code for the current SSE terminal error event. */
|
||||
public static function getAssistantErrorCode(): string
|
||||
{
|
||||
return self::normaliseAssistantErrorCode(self::$assistantErrorCode);
|
||||
}
|
||||
|
||||
private static function normaliseAssistantErrorCode(string $code): string
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1
|
||||
? $code
|
||||
: 'AI_ASSISTANT_FAILED';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private static function parsePrescriptionDraft(string $content): ?array
|
||||
{
|
||||
@@ -621,14 +691,13 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
\Throwable $exception,
|
||||
string $task = ''
|
||||
): void {
|
||||
Log::warning('diagnosis ai assistant upstream call failed', [
|
||||
Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'task' => $task,
|
||||
'admin_id' => $adminId,
|
||||
'exception_class' => get_class($exception),
|
||||
'exception_message' => $exception->getMessage(),
|
||||
]);
|
||||
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -647,15 +716,15 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$errorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||
$errorMessage = trim((string) ($result['error'] ?? ''));
|
||||
$latencyMs = (int) ($result['latency_ms'] ?? 0);
|
||||
Log::warning('diagnosis ai assistant upstream rejected', [
|
||||
Log::warning('diagnosis ai assistant upstream rejected ' . json_encode([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'task' => $task,
|
||||
'admin_id' => $adminId,
|
||||
'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN',
|
||||
'upstream_error' => $errorMessage,
|
||||
'has_upstream_error' => $errorMessage !== '',
|
||||
'latency_ms' => $latencyMs,
|
||||
]);
|
||||
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
|
||||
private static function mutatePayOrderLinks(int $id, callable $mutation)
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
return Db::transaction(static function () use ($id, $mutation) {
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$order) {
|
||||
throw new \DomainException('订单不存在');
|
||||
}
|
||||
$result = $mutation();
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
if (self::$error === '') {
|
||||
self::$error = $e->getMessage();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $payOrderIds
|
||||
*/
|
||||
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
|
||||
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
|
||||
*/
|
||||
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
|
||||
if ((int) ($adminInfo['root'] ?? 0) !== 1
|
||||
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
|
||||
self::$error = '无权限移除收款关联';
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
$id = (int) $params['id'];
|
||||
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作此订单';
|
||||
return false;
|
||||
}
|
||||
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
|
||||
self::$error = '已完成或已取消的订单不允许移除收款关联';
|
||||
return false;
|
||||
}
|
||||
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
|
||||
self::$error = '该收款记录未关联当前订单,请刷新后重试';
|
||||
return false;
|
||||
}
|
||||
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
|
||||
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldPaidCents = (int) round((float) $order->paid * 100);
|
||||
$removedCents = (int) round((float) $payOrder->amount * 100);
|
||||
if ($removedCents < 0) {
|
||||
self::$error = '收款金额异常,请先核对金额';
|
||||
return false;
|
||||
}
|
||||
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
|
||||
->where('pay_order_id', $payOrderId)->delete();
|
||||
if ($deleted !== 1) {
|
||||
throw new \RuntimeException('收款关联已变化,请刷新后重试');
|
||||
}
|
||||
$remainingIds = self::linkedPayOrderIdList($id);
|
||||
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
|
||||
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
|
||||
$order->linked_pay_order_id = $remainingIds[0] ?? null;
|
||||
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
|
||||
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
|
||||
$newPaidCents = $hasRefund
|
||||
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
|
||||
: $remainingPaidCents;
|
||||
$order->paid = $newPaidCents / 100;
|
||||
// 与详情的关联已付总额口径一致;订单 amount 不变。
|
||||
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
|
||||
$order->save();
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
|
||||
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
|
||||
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
|
||||
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
|
||||
), true);
|
||||
|
||||
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
|
||||
*
|
||||
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
if ($strict) {
|
||||
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
|
||||
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
|
||||
}
|
||||
// 非关键日志沿用历史容错行为
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class OrderValidate extends BaseValidate
|
||||
'assistant_id' => 'require|integer|gt:0',
|
||||
'amounts' => 'require|array',
|
||||
'order_types' => 'require|array',
|
||||
'payment_time' => 'dateFormat:Y-m-d H:i:s',
|
||||
'create_time' => 'require|dateFormat:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
@@ -38,11 +40,15 @@ class OrderValidate extends BaseValidate
|
||||
'status.require' => '订单状态必填',
|
||||
'status.in' => '订单状态不正确',
|
||||
'payment_method.in' => '支付方式不正确',
|
||||
'payment_time.dateFormat' => '支付时间格式不正确',
|
||||
'create_time.require' => '创建时间必填',
|
||||
'create_time.dateFormat' => '创建时间格式不正确',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'create' => ['patient_id', 'order_type', 'amount'],
|
||||
'edit' => ['id'],
|
||||
'edit' => ['id', 'patient_id', 'order_type', 'remark'],
|
||||
'edit_time' => ['id', 'payment_time', 'create_time'],
|
||||
'detail' => ['id'],
|
||||
'pay' => ['payment_method'],
|
||||
'cancel' => ['id'],
|
||||
|
||||
@@ -12,11 +12,15 @@ use app\common\validate\BaseValidate;
|
||||
class CustomerValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|integer|gt:0',
|
||||
'auto_sync' => 'require|boolean',
|
||||
'interval' => 'require|integer|between:3600,86400',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '请选择要删除的客户',
|
||||
'id.integer' => '客户参数格式错误',
|
||||
'id.gt' => '客户参数格式错误',
|
||||
'auto_sync.require' => '请选择是否自动同步',
|
||||
'auto_sync.boolean' => '自动同步参数格式错误',
|
||||
'interval.require' => '请选择同步间隔',
|
||||
@@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate
|
||||
{
|
||||
return $this->only(['auto_sync', 'interval']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除客户场景
|
||||
*/
|
||||
public function sceneDelete()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'paidPayOrders' => ['diagnosis_id'],
|
||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
'unlinkPayOrder' => ['id', 'pay_order_id'],
|
||||
'requestCompletion' => ['id'],
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'refund' => ['id', 'reason', 'refund_amount'],
|
||||
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
];
|
||||
|
||||
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'pay_order_id'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('pay_order_id', 'require|integer|gt:0');
|
||||
}
|
||||
|
||||
public function updateAmount(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'amount'])
|
||||
|
||||
@@ -6,8 +6,11 @@ namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
|
||||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use app\common\service\qywx\QywxPromotionEnqueueException;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Work\Application;
|
||||
use EasyWeChat\Work\Message;
|
||||
@@ -64,6 +67,9 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
|
||||
try {
|
||||
$this->handleChangeExternalContact($message);
|
||||
} catch (QywxPromotionEnqueueException $e) {
|
||||
// 未持久化不能假应答成功:外层返回500,让企微重新投递。
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('qywx external contact callback: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
@@ -96,6 +102,11 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
}
|
||||
|
||||
return response($content, 200, $headers);
|
||||
} catch (QywxPromotionEnqueueException) {
|
||||
// 异常调用栈可能携带原始事件参数;这里只记固定信息,不记录WelcomeCode。
|
||||
Log::error('qywx external contact callback: promotion event persistence failed');
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
} catch (BadRequestException $e) {
|
||||
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
|
||||
|
||||
@@ -120,9 +131,17 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
$failReason = (string) ($message['FailReason'] ?? '');
|
||||
$eventTime = (int) ($message['CreateTime'] ?? 0);
|
||||
|
||||
// 只接管已保存新配置且可核验方案/成员的推广事件。
|
||||
// 回调内立即尝试欢迎语和正式客户标签,常驻/分钟任务继续承担重试及其余慢动作。
|
||||
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
|
||||
$event = $message instanceof Message ? $message->toArray() : (array) $message;
|
||||
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event, true);
|
||||
$auditEvent = $event;
|
||||
unset($auditEvent['WelcomeCode']);
|
||||
|
||||
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
|
||||
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
|
||||
CustomerLogic::recordExternalContactEvent([
|
||||
$eventId = CustomerLogic::recordExternalContactEvent([
|
||||
'change_type' => $changeType,
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $extId,
|
||||
@@ -130,9 +149,16 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
'fail_reason' => $failReason,
|
||||
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
|
||||
'event_time' => $eventTime,
|
||||
'raw' => $message,
|
||||
'raw' => $auditEvent,
|
||||
]);
|
||||
|
||||
if ($queued) {
|
||||
// 推广任务先于事件流水执行;事件落库后再用任务内不可变配置补写渠道快照。
|
||||
QywxExternalContactEventTagSnapshotService::captureForPromotionEvent($eventId);
|
||||
// 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。
|
||||
return;
|
||||
}
|
||||
|
||||
if ($extId === '') {
|
||||
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
|
||||
|
||||
@@ -204,6 +230,10 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
}
|
||||
}
|
||||
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
|
||||
CustomerLogic::upsertSingleExternalContactFromApi($extId);
|
||||
CustomerLogic::upsertSingleExternalContactFromApi(
|
||||
$extId,
|
||||
$changeType === 'add_external_contact' ? $eventId : 0,
|
||||
$userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class QywxRefreshPromotionMedia extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:refresh-promotion-media')->setDescription('预热/刷新已保存欢迎语使用的三天临时素材');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxPromotionMediaService())->refreshReferenced(100);
|
||||
$output->writeln('QYWX_PROMOTION_MEDIA ' . json_encode($result));
|
||||
return $result['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class QywxRetryPromotionAutomation extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:retry-promotion-automation')
|
||||
->setDescription('补偿推广标签/备注/资料同步;过期欢迎语仅记过期,不补发');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxPromotionAutomationService())->retryPending(100);
|
||||
$output->writeln('QYWX_PROMOTION_RETRY ' . json_encode($result));
|
||||
return $result['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\console\input\Option;
|
||||
|
||||
/** Supervisor/systemd常驻:只消费欢迎语,不运行慢速客户同步或素材上传。 */
|
||||
class QywxWorkPromotionAutomation extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:work-promotion-automation')
|
||||
->setDescription('秒级消费推广欢迎语(需常驻;欢迎码仅20秒有效)')
|
||||
->addOption('once', null, Option::VALUE_NONE, '只消费一轮');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$running = true;
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGTERM, static function () use (&$running): void { $running = false; });
|
||||
pcntl_signal(SIGINT, static function () use (&$running): void { $running = false; });
|
||||
}
|
||||
$service = new QywxPromotionAutomationService();
|
||||
do {
|
||||
try {
|
||||
$result = $service->processWelcomes(100);
|
||||
if ($result['selected'] > 0 || $input->getOption('once')) {
|
||||
$output->writeln('QYWX_PROMOTION_WELCOME ' . json_encode($result));
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不输出异常堆栈/SQL/请求,避免把短期凭证带进守护进程日志。
|
||||
$output->writeln('QYWX_PROMOTION_WELCOME worker storage unavailable');
|
||||
if ($input->getOption('once')) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (!$input->getOption('once') && $running) {
|
||||
usleep(250000);
|
||||
}
|
||||
} while (!$input->getOption('once') && $running);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -79,56 +79,80 @@ class DifyChatService
|
||||
$formatted = null;
|
||||
|
||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
false,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
$fileRejected = false;
|
||||
$inputRejected = false;
|
||||
|
||||
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)
|
||||
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
|
||||
if ($inputIndex > 0 && !$inputRejected) {
|
||||
break;
|
||||
}
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$attemptInputs,
|
||||
$query,
|
||||
$user,
|
||||
false,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
|
||||
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;
|
||||
$lastSpec = $requestSpec;
|
||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||||
$inputRejected = $inputRejected || self::isInputRejection(
|
||||
$response,
|
||||
$requestSpec,
|
||||
$attemptInputs
|
||||
);
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& !$inputRejected
|
||||
&& self::shouldTryNextProtocol($response, false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$response = self::sendRequest(
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405, 501], true)) {
|
||||
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
|
||||
$formatted = self::formatResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
|
||||
// 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。
|
||||
if ($inputIndex === 0 && $inputRejected) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
|
||||
$formatted = self::formatResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
|
||||
if (!self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])) {
|
||||
if (!$fileRejected) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
@@ -191,64 +215,83 @@ class DifyChatService
|
||||
$formatted = null;
|
||||
|
||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
true,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
$fileRejected = false;
|
||||
$inputRejected = false;
|
||||
|
||||
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)
|
||||
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
|
||||
if ($inputIndex > 0 && !$inputRejected) {
|
||||
break;
|
||||
}
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$attemptInputs,
|
||||
$query,
|
||||
$user,
|
||||
true,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
|
||||
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::sendStreamRequest(
|
||||
$requestSpec['protocol'],
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||||
$inputRejected = $inputRejected || self::isInputRejection(
|
||||
$response,
|
||||
$requestSpec,
|
||||
$attemptInputs
|
||||
);
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& !$inputRejected
|
||||
&& self::shouldTryNextProtocol($response, true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$response = self::sendStreamRequest(
|
||||
$requestSpec['protocol'],
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
|
||||
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
if (
|
||||
$hasFallback
|
||||
&& empty($response['emitted'])
|
||||
&& in_array($response['http_code'], [404, 405, 501], true)
|
||||
$inputIndex === 0
|
||||
&& $inputRejected
|
||||
&& empty($lastResponse['emitted'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
|
||||
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
|
||||
// 附件不可达时 Dify 会在 200 流里发 event:error,同样按附件问题降级。
|
||||
$fileRejected = self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])
|
||||
|| (!empty($lastResponse['upstream_error']) && $attempt['files'] !== []);
|
||||
if (!empty($lastResponse['emitted']) || !$fileRejected) {
|
||||
return $formatted;
|
||||
}
|
||||
@@ -450,6 +493,66 @@ class DifyChatService
|
||||
return $attempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dify 应用输入变量由发布时的表单定义决定。先保留结构化输入;若上游明确
|
||||
* 拒绝输入,再使用空对象兼容旧应用。病例正文始终在 query 中,不会丢失。
|
||||
*
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function buildInputAttemptPlan(array $inputs): array
|
||||
{
|
||||
return $inputs === [] ? [[]] : [$inputs, []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $requestSpec
|
||||
* @param array<string,mixed> $inputs
|
||||
*/
|
||||
private static function isInputRejection(array $response, array $requestSpec, array $inputs): bool
|
||||
{
|
||||
if (
|
||||
$inputs === []
|
||||
|| ($requestSpec['protocol'] ?? '') !== 'dify'
|
||||
|| (int) ($response['errno'] ?? 0) !== 0
|
||||
|| !empty($response['emitted'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||||
if (in_array($httpCode, [413, 422], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$upstreamCode = strtolower(self::responseUpstreamCode($response));
|
||||
if ($httpCode === 400) {
|
||||
// 标准 Dify 会给出 invalid_param;部分兼容网关只保留 400,因此空码
|
||||
// 也允许一次无 inputs 重试。额度、模型或应用状态错误不能重复提交。
|
||||
return $upstreamCode === ''
|
||||
|| in_array($upstreamCode, ['invalid_param', 'payload_too_large', 'request_too_large'], true);
|
||||
}
|
||||
|
||||
return !empty($response['upstream_error'])
|
||||
&& in_array(
|
||||
$upstreamCode,
|
||||
['invalid_param', 'payload_too_large', 'request_too_large'],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $response */
|
||||
private static function responseUpstreamCode(array $response): string
|
||||
{
|
||||
$upstreamCode = self::cleanUpstreamCode($response['upstream_code'] ?? '');
|
||||
if ($upstreamCode !== '' || !isset($response['body'])) {
|
||||
return $upstreamCode;
|
||||
}
|
||||
$decoded = json_decode((string) $response['body'], true);
|
||||
return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,string>> $files
|
||||
*/
|
||||
@@ -458,6 +561,26 @@ class DifyChatService
|
||||
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次上游响应是否属于“这批附件我处理不了”。
|
||||
*
|
||||
* 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error,
|
||||
* 这两种形态都必须触发去掉附件的降级重试。
|
||||
*
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<int,array<string,string>> $files
|
||||
*/
|
||||
private static function isFileRejection(array $response, array $files): bool
|
||||
{
|
||||
if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) {
|
||||
return true;
|
||||
}
|
||||
return !empty($response['upstream_error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
|
||||
* 才不会把“没看到”当成“没有”。
|
||||
@@ -509,6 +632,30 @@ class DifyChatService
|
||||
return $baseUrl . '/v1/' . $endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an ambiguous base URL should be tried with the other wire
|
||||
* protocol. Only a missing/unsupported endpoint is a blocking-mode protocol
|
||||
* signal. A 2xx stream with no delivered delta but no valid terminal frame
|
||||
* is also safe to retry. Business validation, authentication, rate-limit and
|
||||
* server failures retain their original diagnosis instead of being hidden.
|
||||
*
|
||||
* @param array<string,mixed> $response
|
||||
*/
|
||||
private static function shouldTryNextProtocol(array $response, bool $streaming): bool
|
||||
{
|
||||
if ((int) ($response['errno'] ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||||
if (in_array($httpCode, [404, 405, 501], true)) {
|
||||
return true;
|
||||
}
|
||||
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
|
||||
return false;
|
||||
}
|
||||
return !empty($response['upstream_error']) || empty($response['finished']);
|
||||
}
|
||||
|
||||
private static function isValidBaseUrl(string $baseUrl): bool
|
||||
{
|
||||
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
|
||||
@@ -579,6 +726,21 @@ class DifyChatService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* libcurl 7.32+ exposes CURLOPT_XFERINFOFUNCTION, while CentOS/RHEL 7 commonly
|
||||
* ships libcurl 7.29 with only CURLOPT_PROGRESSFUNCTION. Resolve the option
|
||||
* by name so loading this class never evaluates an undefined PHP constant.
|
||||
*/
|
||||
private static function curlProgressOption(): ?int
|
||||
{
|
||||
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) {
|
||||
if (defined($name)) {
|
||||
return (int) constant($name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param callable(string):mixed $onDelta
|
||||
@@ -636,7 +798,7 @@ class DifyChatService
|
||||
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
|
||||
return $state['callback_error'] ? 0 : strlen($chunk);
|
||||
};
|
||||
$progress = static function () use (&$state, $shouldAbort): int {
|
||||
$progress = static function (...$unused) use (&$state, $shouldAbort): int {
|
||||
if ($shouldAbort !== null && $shouldAbort()) {
|
||||
$state['client_aborted'] = true;
|
||||
return 1;
|
||||
@@ -644,7 +806,7 @@ class DifyChatService
|
||||
return 0;
|
||||
};
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
$curlOptions = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
@@ -660,9 +822,13 @@ class DifyChatService
|
||||
],
|
||||
CURLOPT_HEADERFUNCTION => $header,
|
||||
CURLOPT_WRITEFUNCTION => $write,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => $progress,
|
||||
]);
|
||||
];
|
||||
$progressOption = self::curlProgressOption();
|
||||
if ($progressOption !== null) {
|
||||
$curlOptions[CURLOPT_NOPROGRESS] = false;
|
||||
$curlOptions[$progressOption] = $progress;
|
||||
}
|
||||
curl_setopt_array($ch, $curlOptions);
|
||||
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
@@ -1044,25 +1210,23 @@ class DifyChatService
|
||||
return;
|
||||
}
|
||||
$url = (string) ($requestSpec['url'] ?? '');
|
||||
$upstreamCode = (string) ($response['upstream_code'] ?? '');
|
||||
if ($upstreamCode === '' && isset($response['body'])) {
|
||||
$decoded = json_decode((string) $response['body'], true);
|
||||
$upstreamCode = is_array($decoded)
|
||||
? self::cleanUpstreamCode($decoded['code'] ?? '')
|
||||
: '';
|
||||
}
|
||||
Log::warning('prescription ai upstream request failed', [
|
||||
$context = [
|
||||
'protocol' => (string) ($requestSpec['protocol'] ?? ''),
|
||||
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
|
||||
'http_code' => (int) ($response['http_code'] ?? 0),
|
||||
'curl_errno' => (int) ($response['errno'] ?? 0),
|
||||
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
|
||||
'upstream_code' => $upstreamCode,
|
||||
'upstream_code' => self::responseUpstreamCode($response),
|
||||
'query_bytes' => strlen($query),
|
||||
'file_count' => count($files),
|
||||
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
|
||||
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
|
||||
]);
|
||||
];
|
||||
// ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化
|
||||
// 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。
|
||||
Log::warning(
|
||||
'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ class DirectUploadService
|
||||
/** 视频允许的扩展名(沿用 config/project.file_video) */
|
||||
public const TYPE_VIDEO = 'video';
|
||||
public const TYPE_VOICE = 'voice';
|
||||
public const TYPE_DESKTOP_PACKAGE = 'desktop_package';
|
||||
|
||||
/** 默认凭证有效期 30 分钟 */
|
||||
public const DEFAULT_DURATION = 1800;
|
||||
@@ -28,6 +29,7 @@ class DirectUploadService
|
||||
private const MAX_SIZE = [
|
||||
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
|
||||
self::TYPE_DESKTOP_PACKAGE => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,7 +38,7 @@ class DirectUploadService
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function issueCredentials(string $type): array
|
||||
public static function issueCredentials(string $type, int $adminId = 0, string $name = ''): array
|
||||
{
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型: ' . $type);
|
||||
@@ -54,9 +56,27 @@ class DirectUploadService
|
||||
throw new Exception('腾讯云 COS 配置不完整');
|
||||
}
|
||||
|
||||
$keyPrefix = self::buildKeyPrefix($type);
|
||||
$keyPrefix = self::buildKeyPrefix($type, $adminId);
|
||||
$objectKey = '';
|
||||
// 兼容前后端错峰发布:旧 uploader 只传 type,不传 name。
|
||||
// 新 uploader 仍使用更严格的单对象授权;旧版则限制在当前管理员当天目录,
|
||||
// 并在 confirm 阶段校验文件名、扩展名与实际对象。
|
||||
if ($type === self::TYPE_DESKTOP_PACKAGE && trim($name) !== '') {
|
||||
$extension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
|
||||
$objectKey = $keyPrefix
|
||||
. (int)round(microtime(true) * 1000)
|
||||
. '-'
|
||||
. bin2hex(random_bytes(8))
|
||||
. ($extension !== '' ? '.' . $extension : '');
|
||||
self::validateFileExtension($type, $objectKey, $name);
|
||||
}
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
|
||||
$sts = $engine->getStsCredentials(
|
||||
$objectKey !== '' ? $objectKey : $keyPrefix,
|
||||
self::MAX_SIZE[$type],
|
||||
self::DEFAULT_DURATION,
|
||||
$objectKey !== ''
|
||||
);
|
||||
|
||||
return [
|
||||
'provider' => 'qcloud',
|
||||
@@ -66,6 +86,7 @@ class DirectUploadService
|
||||
'host' => $sts['host'],
|
||||
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
|
||||
'key_prefix' => $keyPrefix,
|
||||
'object_key' => $objectKey,
|
||||
'max_size' => self::MAX_SIZE[$type],
|
||||
'duration' => self::DEFAULT_DURATION,
|
||||
'expired_time' => $sts['expiredTime'],
|
||||
@@ -93,8 +114,7 @@ class DirectUploadService
|
||||
}
|
||||
|
||||
$key = ltrim((string)($params['key'] ?? ''), '/');
|
||||
$allowedPrefix = self::buildKeyPrefix($type);
|
||||
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
|
||||
if (!self::isAllowedObjectKey($type, $key, (int)($params['admin_id'] ?? 0))) {
|
||||
throw new Exception('对象 Key 非法');
|
||||
}
|
||||
|
||||
@@ -112,6 +132,7 @@ class DirectUploadService
|
||||
if ($name === '') {
|
||||
$name = basename($key);
|
||||
}
|
||||
self::validateFileExtension($type, $key, $name);
|
||||
if (strlen($name) > 128) {
|
||||
$name = substr($name, 0, 123) . substr($name, -5);
|
||||
}
|
||||
@@ -137,9 +158,16 @@ class DirectUploadService
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildKeyPrefix(string $type): string
|
||||
private static function buildKeyPrefix(string $type, int $adminId = 0): string
|
||||
{
|
||||
return 'uploads/' . $type . '/' . date('Ymd') . '/';
|
||||
$prefix = 'uploads/' . $type . '/';
|
||||
if ($type === self::TYPE_DESKTOP_PACKAGE) {
|
||||
if ($adminId <= 0) {
|
||||
throw new Exception('安装包上传账号无效');
|
||||
}
|
||||
$prefix .= $adminId . '/';
|
||||
}
|
||||
return $prefix . date('Ymd') . '/';
|
||||
}
|
||||
|
||||
private static function resolveFileType(string $type): int
|
||||
@@ -150,4 +178,46 @@ class DirectUploadService
|
||||
default => FileEnum::FILE_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面安装包是可执行文件,只允许发布流程所需的 EXE / ZIP。
|
||||
*/
|
||||
private static function validateFileExtension(string $type, string $key, string $name): void
|
||||
{
|
||||
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nameExtension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
|
||||
$keyExtension = strtolower((string)pathinfo($key, PATHINFO_EXTENSION));
|
||||
$allowedExtensions = ['exe', 'zip'];
|
||||
if (!in_array($nameExtension, $allowedExtensions, true)
|
||||
|| $nameExtension !== $keyExtension) {
|
||||
throw new Exception('桌面安装包仅支持 EXE 或 ZIP 文件');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装包 Key 绑定上传管理员,并兼容跨午夜完成的上传。
|
||||
*/
|
||||
private static function isAllowedObjectKey(string $type, string $key, int $adminId): bool
|
||||
{
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
|
||||
return strpos($key, self::buildKeyPrefix($type)) === 0;
|
||||
}
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownerPrefix = 'uploads/' . self::TYPE_DESKTOP_PACKAGE . '/' . $adminId . '/';
|
||||
if (strpos($key, $ownerPrefix) !== 0) {
|
||||
return false;
|
||||
}
|
||||
$date = substr($key, strlen($ownerPrefix), 8);
|
||||
return in_array($date, [date('Ymd'), date('Ymd', time() - 86400)], true)
|
||||
&& substr($key, strlen($ownerPrefix) + 8, 1) === '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,9 +422,17 @@ SQL;
|
||||
* multiplies facts. Enterprise tag channels use the normalized relation
|
||||
* table, while legacy name-only channels keep a deduplicated JSON fallback.
|
||||
*
|
||||
* When the fact has an employee dimension, pass $followUserField so a tag
|
||||
* applied by employee A cannot make employee B's event match the channel.
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
|
||||
public static function applyExternalUserChannelFilter(
|
||||
Query $query,
|
||||
string $field,
|
||||
?array $channel,
|
||||
?string $followUserField = null
|
||||
): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
@@ -437,10 +445,14 @@ SQL;
|
||||
$tagPredicate = count($tagIds) === 1
|
||||
? 'channel_tag.tag_id = ?'
|
||||
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
||||
$followUserPredicate = $followUserField === null
|
||||
? ''
|
||||
: "AND channel_tag.follow_user_id = {$followUserField} ";
|
||||
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
|
||||
$query->whereRaw(
|
||||
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
|
||||
. "WHERE channel_tag.external_userid = {$field} "
|
||||
. $followUserPredicate
|
||||
. "AND {$tagPredicate} "
|
||||
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
|
||||
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
|
||||
@@ -472,6 +484,92 @@ SQL;
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an add-event fact by its append-only channel snapshot. Events that
|
||||
* pre-date the snapshot migration explicitly fall back to the old projection,
|
||||
* but the fallback is constrained to the event's exact employee.
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyExternalUserEventChannelFilter(
|
||||
Query $query,
|
||||
string $eventIdField,
|
||||
string $externalUserField,
|
||||
string $followUserField,
|
||||
?array $channel,
|
||||
bool $historicalContact = false
|
||||
): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tagIds = self::channelTagIds($channel);
|
||||
if ($tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()) {
|
||||
if ($historicalContact) {
|
||||
self::applyHistoricalExternalUserChannelFilter(
|
||||
$query,
|
||||
$externalUserField,
|
||||
$channel,
|
||||
$followUserField
|
||||
);
|
||||
} else {
|
||||
self::applyExternalUserChannelFilter($query, $externalUserField, $channel, $followUserField);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshotTable = self::tableWithPrefix('qywx_external_contact_event_tag');
|
||||
$snapshotPredicate = count($tagIds) === 1
|
||||
? 'event_channel_tag.tag_id = ?'
|
||||
: 'event_channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
||||
$snapshotMatch = "EXISTS (SELECT 1 FROM {$snapshotTable} event_channel_tag"
|
||||
. " WHERE event_channel_tag.event_id = {$eventIdField}"
|
||||
. " AND event_channel_tag.follow_user_id = {$followUserField}"
|
||||
. " AND {$snapshotPredicate})";
|
||||
$snapshotMissing = "NOT EXISTS (SELECT 1 FROM {$snapshotTable} captured_event_channel"
|
||||
. " WHERE captured_event_channel.event_id = {$eventIdField}"
|
||||
. " AND captured_event_channel.follow_user_id = {$followUserField}"
|
||||
. " AND captured_event_channel.tag_id = '')";
|
||||
|
||||
$query->where(function ($channelQuery) use (
|
||||
$snapshotMatch,
|
||||
$snapshotMissing,
|
||||
$tagIds,
|
||||
$historicalContact,
|
||||
$externalUserField,
|
||||
$followUserField,
|
||||
$channel
|
||||
): void {
|
||||
$channelQuery->whereRaw($snapshotMatch, $tagIds)
|
||||
->whereOr(function ($legacyQuery) use (
|
||||
$snapshotMissing,
|
||||
$historicalContact,
|
||||
$externalUserField,
|
||||
$followUserField,
|
||||
$channel
|
||||
): void {
|
||||
$legacyQuery->whereRaw($snapshotMissing);
|
||||
if ($historicalContact) {
|
||||
self::applyHistoricalExternalUserChannelFilter(
|
||||
$legacyQuery,
|
||||
$externalUserField,
|
||||
$channel,
|
||||
$followUserField
|
||||
);
|
||||
} else {
|
||||
self::applyExternalUserChannelFilter(
|
||||
$legacyQuery,
|
||||
$externalUserField,
|
||||
$channel,
|
||||
$followUserField
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an external_userid fact by the channel snapshot retained in
|
||||
* qywx_external_contact.follow_users, including soft-deleted contacts.
|
||||
@@ -481,14 +579,48 @@ SQL;
|
||||
* contact row itself retains follow_users and is the best available channel
|
||||
* snapshot for this specific historical statistic.
|
||||
*
|
||||
* When $followUserField is provided, JSON_SEARCH first locates that exact
|
||||
* employee object and JSON_CONTAINS checks only its tag array.
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyHistoricalExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
|
||||
public static function applyHistoricalExternalUserChannelFilter(
|
||||
Query $query,
|
||||
string $field,
|
||||
?array $channel,
|
||||
?string $followUserField = null
|
||||
): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$tagIds = self::channelTagIds($channel);
|
||||
if ($followUserField !== null && $tagIds !== []) {
|
||||
$safeFollowUsers = "IF(JSON_VALID(historical_channel_contact.follow_users),"
|
||||
. ' historical_channel_contact.follow_users, JSON_ARRAY())';
|
||||
$userPath = "JSON_UNQUOTE(JSON_SEARCH({$safeFollowUsers}, 'one', {$followUserField},"
|
||||
. " NULL, '$[*].userid'))";
|
||||
$tagsPath = "IFNULL(REPLACE({$userPath}, '.userid', '.tags'), '$.__missing__')";
|
||||
$tagsJson = "JSON_EXTRACT({$safeFollowUsers}, {$tagsPath})";
|
||||
$tagPredicates = [];
|
||||
$bindings = [];
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tagPredicates[] = "JSON_CONTAINS({$tagsJson}, JSON_OBJECT('tag_id', ?))";
|
||||
$bindings[] = $tagId;
|
||||
}
|
||||
$query->whereRaw(
|
||||
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
|
||||
. " WHERE historical_channel_contact.external_userid = {$field}"
|
||||
. " AND {$userPath} IS NOT NULL"
|
||||
. ' AND (' . implode(' OR ', $tagPredicates) . '))',
|
||||
$bindings
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$patterns = self::buildLikePatterns($channel);
|
||||
if ($patterns === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
@@ -502,7 +634,6 @@ SQL;
|
||||
$segments[] = 'historical_channel_contact.follow_users LIKE ?';
|
||||
$bindings[] = $pattern;
|
||||
}
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$query->whereRaw(
|
||||
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
|
||||
. " WHERE historical_channel_contact.external_userid = {$field}"
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信新增事件的标签快照。
|
||||
*
|
||||
* qywx_external_contact_tag 是当前状态投影,客户改标签或删除后会被覆盖/清理;
|
||||
* 本服务只在 add_external_contact 发生时写入,之后永不更新或删除。
|
||||
*/
|
||||
class QywxExternalContactEventTagSnapshotService
|
||||
{
|
||||
public const SOURCE_CONTACT_DETAIL = 1;
|
||||
public const SOURCE_PROMOTION_TASK = 2;
|
||||
|
||||
private static ?bool $installed = null;
|
||||
|
||||
/**
|
||||
* 灰度发布保护:代码先于迁移生效时,读取侧可以显式降级而不是返回 500。
|
||||
*/
|
||||
public static function installed(): bool
|
||||
{
|
||||
if (self::$installed !== null) {
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
try {
|
||||
self::$installed = Db::name('qywx_external_contact_event_tag')->getFields() !== [];
|
||||
|
||||
return self::$installed;
|
||||
} catch (\Throwable $e) {
|
||||
$message = $e->getMessage();
|
||||
if (str_contains($message, '42S02')
|
||||
|| str_contains($message, '1146')
|
||||
|| str_contains($message, 'no such table')) {
|
||||
self::$installed = false;
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 /externalcontact/get 的 follow_user[] 中,只截取产生事件的员工标签。
|
||||
* 找不到该员工时不写空快照,避免把一次不完整同步误判为“当时无标签”。
|
||||
*
|
||||
* @param array<int, mixed> $followUsers
|
||||
*/
|
||||
public static function captureFromFollowUsers(int $eventId, string $followUserId, array $followUsers): void
|
||||
{
|
||||
$followUserId = trim($followUserId);
|
||||
if ($eventId <= 0 || $followUserId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($followUsers as $followUser) {
|
||||
if (!is_array($followUser)
|
||||
|| trim((string) ($followUser['userid'] ?? '')) !== $followUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::capture(
|
||||
$eventId,
|
||||
$followUserId,
|
||||
is_array($followUser['tags'] ?? null) ? $followUser['tags'] : [],
|
||||
self::SOURCE_CONTACT_DETAIL
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调入库后,用同一推广任务的不可变 config_json 补写快照。
|
||||
*/
|
||||
public static function captureForPromotionEvent(int $eventId): void
|
||||
{
|
||||
if ($eventId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$event = Db::name('qywx_external_contact_event')->where('id', $eventId)->find();
|
||||
if (!$event || (string) ($event['change_type'] ?? '') !== 'add_external_contact') {
|
||||
return;
|
||||
}
|
||||
|
||||
$task = Db::name('qywx_promotion_automation_task')
|
||||
->where('change_type', (string) $event['change_type'])
|
||||
->where('userid', (string) $event['user_id'])
|
||||
->where('external_userid', (string) $event['external_userid'])
|
||||
->where('event_time', (int) $event['event_time'])
|
||||
->find();
|
||||
if ($task) {
|
||||
self::captureFromPromotionTask($task, $eventId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
self::logFailure($e, $eventId, 'promotion_event');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签动作成功后追加推广配置中的确定标签,但不写完成标记。
|
||||
* 推广配置只描述自动添加的标签,不能证明客户当时没有其他标签;完整快照由后续客户详情同步完成。
|
||||
*
|
||||
* @param array<string, mixed> $task
|
||||
*/
|
||||
public static function captureFromPromotionTask(array $task, int $knownEventId = 0): void
|
||||
{
|
||||
if ((string) ($task['change_type'] ?? '') !== 'add_external_contact') {
|
||||
return;
|
||||
}
|
||||
|
||||
$actions = json_decode((string) ($task['actions_json'] ?? ''), true);
|
||||
$tagStatus = is_array($actions)
|
||||
? (string) ($actions['tags']['status'] ?? '')
|
||||
: '';
|
||||
if ($tagStatus !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$eventId = $knownEventId;
|
||||
if ($eventId <= 0) {
|
||||
$eventId = (int) Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('user_id', (string) ($task['userid'] ?? ''))
|
||||
->where('external_userid', (string) ($task['external_userid'] ?? ''))
|
||||
->where('event_time', (int) ($task['event_time'] ?? 0))
|
||||
->value('id');
|
||||
}
|
||||
if ($eventId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
$config = json_decode((string) ($task['config_json'] ?? ''), true);
|
||||
foreach ((array) ($config['tag_ids'] ?? []) as $tagId) {
|
||||
$tagId = trim((string) $tagId);
|
||||
if ($tagId !== '') {
|
||||
$tags[] = ['tag_id' => $tagId];
|
||||
}
|
||||
}
|
||||
if ($tags === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::appendTags(
|
||||
$eventId,
|
||||
(string) ($task['userid'] ?? ''),
|
||||
$tags,
|
||||
self::SOURCE_PROMOTION_TASK
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
self::logFailure($e, $knownEventId, 'promotion_task');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加推广任务能够证明的标签,不写 tag_id='' 完成标记。
|
||||
*
|
||||
* @param array<int, mixed> $tags
|
||||
*/
|
||||
private static function appendTags(int $eventId, string $followUserId, array $tags, int $source): void
|
||||
{
|
||||
$followUserId = mb_substr(trim($followUserId), 0, 64);
|
||||
if ($eventId <= 0 || $followUserId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach (self::normalizeTags($tags) as $tag) {
|
||||
self::insertIgnore([
|
||||
'event_id' => $eventId,
|
||||
'follow_user_id' => $followUserId,
|
||||
'tag_id' => $tag['tag_id'],
|
||||
'tag_name' => $tag['tag_name'],
|
||||
'group_name' => $tag['group_name'],
|
||||
'snapshot_source' => $source,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
self::logFailure($e, $eventId, 'append_tags');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 先写 tag_id='' 完成标记,再写真实标签;同一事务保证不会留下半份快照。
|
||||
* 完成标记已存在时直接返回,使重复回调不会把后来新增的标签补进历史事件。
|
||||
*
|
||||
* @param array<int, mixed> $tags
|
||||
*/
|
||||
private static function capture(int $eventId, string $followUserId, array $tags, int $source): void
|
||||
{
|
||||
$followUserId = mb_substr(trim($followUserId), 0, 64);
|
||||
if ($eventId <= 0 || $followUserId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = self::normalizeTags($tags);
|
||||
|
||||
try {
|
||||
Db::transaction(static function () use ($eventId, $followUserId, $normalized, $source): void {
|
||||
$inserted = self::insertIgnore([
|
||||
'event_id' => $eventId,
|
||||
'follow_user_id' => $followUserId,
|
||||
'tag_id' => '',
|
||||
'tag_name' => '',
|
||||
'group_name' => '',
|
||||
'snapshot_source' => $source,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
if ($inserted === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($normalized as $tag) {
|
||||
self::insertIgnore([
|
||||
'event_id' => $eventId,
|
||||
'follow_user_id' => $followUserId,
|
||||
'tag_id' => $tag['tag_id'],
|
||||
'tag_name' => $tag['tag_name'],
|
||||
'group_name' => $tag['group_name'],
|
||||
'snapshot_source' => $source,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// 快照是统计增强,迁移未执行或短时 DB 异常不能阻断企微回调主链路。
|
||||
self::logFailure($e, $eventId, 'capture');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $tags
|
||||
* @return array<string, array{tag_id:string,tag_name:string,group_name:string}>
|
||||
*/
|
||||
private static function normalizeTags(array $tags): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (!is_array($tag)) {
|
||||
continue;
|
||||
}
|
||||
$tagId = mb_substr(trim((string) ($tag['tag_id'] ?? $tag['id'] ?? '')), 0, 64);
|
||||
if ($tagId === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[$tagId] = [
|
||||
'tag_id' => $tagId,
|
||||
'tag_name' => mb_substr((string) ($tag['tag_name'] ?? $tag['name'] ?? ''), 0, 128),
|
||||
'group_name' => mb_substr((string) ($tag['group_name'] ?? ''), 0, 128),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param array<string, int|string> $row */
|
||||
private static function insertIgnore(array $row): int
|
||||
{
|
||||
$table = (string) config('database.connections.mysql.prefix')
|
||||
. 'qywx_external_contact_event_tag';
|
||||
$columns = array_keys($row);
|
||||
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $columns) . '`) VALUES ('
|
||||
. implode(',', array_fill(0, count($columns), '?')) . ')';
|
||||
|
||||
return Db::execute($sql, array_values($row));
|
||||
}
|
||||
|
||||
private static function logFailure(\Throwable $e, int $eventId, string $stage): void
|
||||
{
|
||||
Log::warning('qywx external contact event tag snapshot failed: ' . $e->getMessage(), [
|
||||
'event_id' => $eventId,
|
||||
'stage' => $stage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 推广客户自动化:短时欢迎语与可补偿关系动作分开消费。 */
|
||||
class QywxPromotionAutomationService
|
||||
{
|
||||
private QywxPromotionContactApiService $api;
|
||||
private QywxPromotionMediaService $media;
|
||||
private QywxPromotionAutomationStore $store;
|
||||
private QywxPromotionCodeCipher $cipher;
|
||||
private $clock;
|
||||
private const TERMINAL = ['sent', 'success', 'skipped', 'expired', 'uncertain', 'failed'];
|
||||
|
||||
public function __construct(
|
||||
?QywxPromotionContactApiService $api = null,
|
||||
?QywxPromotionMediaService $media = null,
|
||||
?QywxPromotionAutomationStore $store = null,
|
||||
?QywxPromotionCodeCipher $cipher = null,
|
||||
?callable $clock = null
|
||||
) {
|
||||
$this->api = $api ?? new QywxPromotionContactApiService();
|
||||
$this->media = $media ?? new QywxPromotionMediaService($this->api);
|
||||
$this->store = $store ?? new QywxPromotionAutomationStore();
|
||||
$this->cipher = $cipher ?? new QywxPromotionCodeCipher();
|
||||
$this->clock = $clock ?? static fn (): int => time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。
|
||||
* 默认仅入队;回调入口可启用即时通道,在当前请求内先发欢迎语并给正式客户打标。
|
||||
*/
|
||||
public function enqueueVerifiedEvent(array $event, bool $processImmediately = false): bool
|
||||
{
|
||||
$change = (string) ($event['ChangeType'] ?? '');
|
||||
if (!in_array($change, ['add_external_contact', 'add_half_external_contact'], true)) {
|
||||
return false;
|
||||
}
|
||||
$state = trim((string) ($event['State'] ?? ''));
|
||||
$linkId = trim((string) ($event['LinkId'] ?? $event['LinkID'] ?? ''));
|
||||
$userid = trim((string) ($event['UserID'] ?? $event['UserId'] ?? ''));
|
||||
$external = trim((string) ($event['ExternalUserID'] ?? $event['ExternalUserId'] ?? ''));
|
||||
if (($state === '' && $linkId === '') || $userid === '' || $external === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!$this->store->installed()) {
|
||||
return false;
|
||||
}
|
||||
$attribution = $this->store->attribution($state, $linkId, $userid);
|
||||
if ($attribution === null) {
|
||||
return false;
|
||||
}
|
||||
$now = $this->now();
|
||||
$eventTime = max(0, (int) ($event['CreateTime'] ?? 0));
|
||||
$code = (string) ($event['WelcomeCode'] ?? '');
|
||||
$config = $attribution['config'];
|
||||
$half = $change === 'add_half_external_contact';
|
||||
$welcomeStatus = 'pending';
|
||||
$reason = '';
|
||||
if (($config['welcome_mode'] ?? 'default') !== 'channel') {
|
||||
$welcomeStatus = 'skipped';
|
||||
$reason = 'mode_' . ($config['welcome_mode'] ?? 'default');
|
||||
} elseif ($code === '') {
|
||||
$welcomeStatus = 'skipped';
|
||||
$reason = 'missing_welcome_code';
|
||||
} elseif (strlen($code) > 1024) {
|
||||
$welcomeStatus = 'failed';
|
||||
$reason = 'invalid_welcome_code';
|
||||
} elseif ($eventTime <= 0 || $eventTime > $now + 5 || $eventTime + 20 <= $now) {
|
||||
$welcomeStatus = 'expired';
|
||||
$reason = 'welcome_window_elapsed_or_invalid_event_time';
|
||||
}
|
||||
$actions = [
|
||||
'welcome' => self::action($welcomeStatus, $reason),
|
||||
'tags' => self::action(!$half && !empty($config['tags_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'remark' => self::action(!$half && !empty($config['remark_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'description' => self::action(!$half && !empty($config['description_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'dispatch' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
'range' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
];
|
||||
$corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', ''));
|
||||
$taskId = $this->store->enqueue([
|
||||
'event_key' => hash('sha256', implode('|', [$corp, $change, $userid, $external, (string) $eventTime])),
|
||||
'pool_id' => $attribution['pool_id'], 'member_admin_id' => $attribution['member_admin_id'],
|
||||
'change_type' => $change, 'userid' => $userid, 'external_userid' => $external,
|
||||
'event_time' => $eventTime, 'received_at' => $now,
|
||||
'config_json' => self::json($config), 'actions_json' => self::json($actions),
|
||||
'welcome_cipher' => $welcomeStatus === 'pending' ? $this->cipher->encrypt($code) : '',
|
||||
'welcome_code_hash' => $code !== '' ? hash('sha256', $code) : '',
|
||||
'welcome_expires_at' => $eventTime > 0 ? min($eventTime + 20, $now + 20) : 0,
|
||||
'welcome_status' => $welcomeStatus, 'welcome_next_retry' => 0,
|
||||
'status' => self::allTerminal($actions) ? 'done' : 'pending', 'next_retry' => 0,
|
||||
'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, 'update_time' => $now,
|
||||
]);
|
||||
if ($processImmediately) {
|
||||
// 部署环境暂未启动常驻 worker 时仍要抢住 20 秒欢迎码窗口。
|
||||
// 标签只在正式客户事件执行;其余慢动作仍由分钟补偿处理。
|
||||
$this->consumeIds('welcome', [$taskId]);
|
||||
if (!$half) {
|
||||
$this->consumeIds('inline_metadata', [$taskId], ['tags']);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
// 不附原异常,入库SQL可能包含密文和配置;回调层返回500触发企微重试。
|
||||
throw new QywxPromotionEnqueueException('推广自动化事件未能持久化,请检查数据库迁移和私有存储');
|
||||
}
|
||||
}
|
||||
|
||||
/** 常驻秒级worker仅处理欢迎语,不被范围/客户同步或大文件上传阻塞。 */
|
||||
public function processWelcomes(int $limit = 100): array
|
||||
{
|
||||
return $this->consume('welcome', $limit);
|
||||
}
|
||||
|
||||
/** 分钟补偿:过期欢迎语只记过期,绝不尝试补发。 */
|
||||
public function retryPending(int $limit = 100): array
|
||||
{
|
||||
return $this->consume('metadata', $limit);
|
||||
}
|
||||
|
||||
public static function selectWelcome(array $config, int $eventTime): array
|
||||
{
|
||||
if (!empty($config['welcome_schedule_enabled'])) {
|
||||
foreach ((array) ($config['welcome_schedule'] ?? []) as $slot) {
|
||||
if (QywxPromotionConfig::matches($slot, $eventTime)) {
|
||||
return ['text' => (string) ($slot['text'] ?? ''), 'attachments' => (array) ($slot['attachments'] ?? [])];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['text' => (string) ($config['welcome']['text'] ?? ''), 'attachments' => (array) ($config['welcome']['attachments'] ?? [])];
|
||||
}
|
||||
|
||||
private function consume(string $lane, int $limit): array
|
||||
{
|
||||
return $this->consumeIds($lane, $this->store->due($lane, $this->now(), $limit));
|
||||
}
|
||||
|
||||
/** @param list<int> $ids @param list<string>|null $metadataActions */
|
||||
private function consumeIds(string $lane, array $ids, ?array $metadataActions = null): array
|
||||
{
|
||||
$result = ['selected' => 0, 'processed' => 0, 'failed' => 0];
|
||||
foreach ($ids as $id) {
|
||||
++$result['selected'];
|
||||
try {
|
||||
$row = $this->store->claim($id, $lane, $this->now());
|
||||
if ($row === null) {
|
||||
continue;
|
||||
}
|
||||
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
// 即时标签与欢迎语共用同一任务,但不能把仍在 20 秒窗口内待重试的欢迎语判为过期。
|
||||
if ($lane !== 'inline_metadata' && !self::terminal($actions['welcome']['status'])) {
|
||||
if ($lane === 'welcome') {
|
||||
$this->welcome($row, $actions, $config);
|
||||
} else {
|
||||
$running = $actions['welcome']['status'] === 'running';
|
||||
$this->transition($row, $actions, 'welcome', $running ? 'uncertain' : 'expired',
|
||||
$running ? 'worker_interrupted_after_send_started' : 'welcome_worker_not_available_in_window');
|
||||
}
|
||||
}
|
||||
if ($lane !== 'welcome') {
|
||||
$this->metadata($row, $actions, $config, $metadataActions);
|
||||
}
|
||||
$row['lock_until'] = 0;
|
||||
$row['update_time'] = $this->now();
|
||||
$this->store->save($row);
|
||||
++$result['processed'];
|
||||
} catch (\Throwable) {
|
||||
// 失去DB/租约时保留running状态;欢迎语恢复时视为不确定,防止重复推送。
|
||||
++$result['failed'];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function welcome(array &$row, array &$actions, array $config): void
|
||||
{
|
||||
if ($actions['welcome']['status'] === 'running') {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'worker_interrupted_after_send_started');
|
||||
return;
|
||||
}
|
||||
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
|
||||
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed');
|
||||
return;
|
||||
}
|
||||
$sendStarted = false;
|
||||
try {
|
||||
$message = self::selectWelcome($config, (int) $row['event_time']);
|
||||
$text = $message['text'];
|
||||
if (str_contains($text, '{customer_name}') || str_contains($text, '{employee_name}') || str_contains($text, '{add_time}')) {
|
||||
$names = $this->names($row, $text, true);
|
||||
$text = QywxPromotionConfig::render($text, $names['customer'], $names['employee'], (int) $row['event_time'], 1200);
|
||||
}
|
||||
$truncated = strlen($text) > 4000;
|
||||
$text = mb_strcut($text, 0, 4000, 'UTF-8');
|
||||
$attachments = $this->media->materialize($message['attachments'], $config);
|
||||
$code = $this->cipher->decrypt($row['welcome_cipher']);
|
||||
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
|
||||
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed_during_prepare');
|
||||
return;
|
||||
}
|
||||
// running先持久化:如果HTTP成功后进程/DB断开,恢复时绝不再次使用同一code。
|
||||
$this->transition($row, $actions, 'welcome', 'running', 'send_started');
|
||||
$sendStarted = true;
|
||||
try {
|
||||
$this->api->sendWelcome($code, $text, $attachments);
|
||||
$this->transition($row, $actions, 'welcome', 'sent', $truncated ? 'sent_text_truncated_4000_bytes' : 'sent');
|
||||
} catch (QywxPromotionContactApiException $e) {
|
||||
if ($e->uncertain) {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'network_result_unknown_do_not_resend', $e->getCode());
|
||||
} elseif ($e->getCode() === 41051) {
|
||||
$this->transition($row, $actions, 'welcome', 'skipped', 'welcome_code_already_consumed', 41051);
|
||||
} else {
|
||||
$this->welcomeRetry($row, $actions, 'explicit_api_rejection', $e->getCode());
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'send_or_persist_result_unknown_do_not_resend');
|
||||
} finally {
|
||||
unset($code);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 准备阶段没有执行发送,可以安全重试,且不会把错误原文/欢迎码写日志。
|
||||
if ($sendStarted || $actions['welcome']['status'] === 'running') {
|
||||
throw $e;
|
||||
}
|
||||
$this->welcomeRetry($row, $actions, 'prepare_failed_check_media_credentials_or_key', (int) $e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private function welcomeRetry(array &$row, array &$actions, string $reason, int $code): void
|
||||
{
|
||||
$expired = (int) $row['welcome_expires_at'] <= $this->now() + 2;
|
||||
$this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1);
|
||||
}
|
||||
|
||||
/** @param list<string>|null $only */
|
||||
private function metadata(array &$row, array &$actions, array $config, ?array $only = null): void
|
||||
{
|
||||
$names = null;
|
||||
$namesToProcess = ['tags', 'remark', 'description', 'dispatch', 'range', 'sync'];
|
||||
if ($only !== null) {
|
||||
$namesToProcess = array_values(array_intersect($namesToProcess, $only));
|
||||
}
|
||||
foreach ($namesToProcess as $name) {
|
||||
if (self::terminal($actions[$name]['status']) || (int) ($actions[$name]['next_retry'] ?? 0) > $this->now()) {
|
||||
continue;
|
||||
}
|
||||
$this->transition($row, $actions, $name, 'running', 'started');
|
||||
try {
|
||||
switch ($name) {
|
||||
case 'tags':
|
||||
$this->api->markTags($row['userid'], $row['external_userid'], (array) $config['tag_ids']);
|
||||
break;
|
||||
case 'remark':
|
||||
$names = $names ?? $this->names($row, (string) $config['remark_template'], false);
|
||||
$remark = QywxPromotionConfig::render($config['remark_template'], $names['customer'], $names['employee'], (int) $row['event_time'], 20);
|
||||
$this->api->remark($row['userid'], $row['external_userid'], ['remark' => $remark]);
|
||||
break;
|
||||
case 'description':
|
||||
$this->api->remark($row['userid'], $row['external_userid'], ['description' => (string) $config['description']]);
|
||||
break;
|
||||
case 'dispatch':
|
||||
$this->store->dispatch($row);
|
||||
break;
|
||||
case 'range':
|
||||
$this->store->syncRange($row);
|
||||
break;
|
||||
case 'sync':
|
||||
$this->store->syncCustomer($row);
|
||||
break;
|
||||
}
|
||||
$this->transition($row, $actions, $name, 'success', 'completed');
|
||||
} catch (\Throwable $e) {
|
||||
$attempt = (int) $actions[$name]['attempts'];
|
||||
$failed = $attempt >= 10;
|
||||
$this->transition($row, $actions, $name, $failed ? 'failed' : 'retry',
|
||||
$failed ? 'retry_limit_reached' : 'action_failed', (int) $e->getCode(),
|
||||
$this->now() + min(3600, 15 * (2 ** min(8, $attempt))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function names(array $row, string $template, bool $welcome): array
|
||||
{
|
||||
$names = ['customer' => '', 'employee' => ''];
|
||||
try {
|
||||
$names = $this->store->localNames($row);
|
||||
} catch (\Throwable) {
|
||||
// 本地资料失败不妨碍欢迎语使用明确的文案兜底。
|
||||
}
|
||||
$budget = fn (): bool => !$welcome || (int) $row['welcome_expires_at'] > $this->now() + 7;
|
||||
if (str_contains($template, '{customer_name}') && $names['customer'] === ''
|
||||
&& $row['change_type'] !== 'add_half_external_contact' && $budget()) {
|
||||
try {
|
||||
$detail = $this->api->getExternalContact($row['external_userid']);
|
||||
$names['customer'] = (string) ($detail['external_contact']['name'] ?? '');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
if (str_contains($template, '{employee_name}') && $budget()) {
|
||||
try {
|
||||
$user = $this->api->getUser($row['userid']);
|
||||
$names['employee'] = trim((string) ($user['name'] ?? '')) ?: $names['employee'];
|
||||
} catch (\Throwable) {
|
||||
// 通讯录姓名接口权限不足时回退后台成员称呼。
|
||||
}
|
||||
}
|
||||
$names['customer'] = $names['customer'] !== '' ? $names['customer'] : '您';
|
||||
$names['employee'] = $names['employee'] !== '' ? $names['employee'] : '客户顾问';
|
||||
return $names;
|
||||
}
|
||||
|
||||
private function transition(array &$row, array &$actions, string $name, string $status, string $reason, int $code = 0, int $retryAt = 0): void
|
||||
{
|
||||
$now = $this->now();
|
||||
$action = $actions[$name];
|
||||
if ($status === 'running' || ($name === 'welcome' && $status === 'retry' && $action['status'] !== 'running')) {
|
||||
++$action['attempts'];
|
||||
}
|
||||
$action = array_replace($action, ['status' => $status, 'reason' => $reason, 'error_code' => $code,
|
||||
'next_retry' => $retryAt, 'update_time' => $now]);
|
||||
if (self::terminal($status)) {
|
||||
$action['finished_at'] = $now;
|
||||
}
|
||||
$actions[$name] = $action;
|
||||
if ($name === 'welcome') {
|
||||
$row['welcome_status'] = $status;
|
||||
$row['welcome_next_retry'] = $retryAt;
|
||||
if (self::terminal($status)) {
|
||||
$row['welcome_cipher'] = '';
|
||||
}
|
||||
}
|
||||
$row['status'] = self::allTerminal($actions) ? 'done' : 'pending';
|
||||
$retry = [];
|
||||
foreach ($actions as $key => $value) {
|
||||
if ($key !== 'welcome' && !self::terminal($value['status'])) {
|
||||
$retry[] = (int) ($value['next_retry'] ?? 0);
|
||||
}
|
||||
}
|
||||
$row['next_retry'] = $retry === [] ? 0 : min($retry);
|
||||
$row['actions_json'] = self::json($actions);
|
||||
$row['update_time'] = $now;
|
||||
$this->store->save($row, ['action' => $name, 'status' => $status, 'attempt' => $action['attempts'],
|
||||
'reason' => $reason, 'error_code' => $code, 'create_time' => $now]);
|
||||
}
|
||||
|
||||
private static function action(string $status, string $reason = ''): array
|
||||
{
|
||||
return ['status' => $status, 'reason' => $status === 'pending' ? '' : $reason, 'attempts' => 0, 'error_code' => 0, 'next_retry' => 0];
|
||||
}
|
||||
|
||||
private static function allTerminal(array $actions): bool
|
||||
{
|
||||
foreach ($actions as $action) {
|
||||
if (!self::terminal($action['status'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function terminal(string $status): bool
|
||||
{
|
||||
return in_array($status, self::TERMINAL, true);
|
||||
}
|
||||
|
||||
private static function json(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
return (int) ($this->clock)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** DB 存储与既有同步边界;单测替换此类后不初始化业务数据库。 */
|
||||
class QywxPromotionAutomationStore
|
||||
{
|
||||
public function installed(): bool
|
||||
{
|
||||
return QywxPromotionConfig::installed();
|
||||
}
|
||||
|
||||
/** State只能定位,必须再核验真实方案、正式官方链接与实际成员关系。 */
|
||||
public function attribution(string $state, string $linkId, string $userId): ?array
|
||||
{
|
||||
if ($state !== '') {
|
||||
if (!preg_match('/^zyt_pool:([1-9][0-9]{0,9})$/', $state, $match)) {
|
||||
return null;
|
||||
}
|
||||
$poolId = (int) $match[1];
|
||||
} elseif ($linkId !== '') {
|
||||
$poolId = (int) Db::name('qywx_promotion_link')->where('remote_link_id', $linkId)
|
||||
->where('remote_status', 1)->whereNull('delete_time')->value('pool_id');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->where('status', 1)->whereNull('delete_time')->find();
|
||||
$member = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', $userId)
|
||||
->whereNull('delete_time')->find();
|
||||
$links = Db::name('qywx_promotion_link')->where('pool_id', $poolId)->where('remote_status', 1)
|
||||
->where('remote_link_id', '<>', '')->whereNull('delete_time');
|
||||
if ($linkId !== '') {
|
||||
$links->where('remote_link_id', $linkId);
|
||||
}
|
||||
// 不用 enabled/当日额度验证:真实回调可能比排班切换晚到,不能漏掉已归属该方案的成员。
|
||||
if (!$pool || !$member || !$links->find()) {
|
||||
return null;
|
||||
}
|
||||
$configRow = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
|
||||
if (!$configRow) {
|
||||
// 尚未保存新增配置的旧方案仍保持原同步链路,不强制依赖新worker。
|
||||
return null;
|
||||
}
|
||||
return ['pool_id' => $poolId, 'member_admin_id' => (int) $member['admin_id'],
|
||||
'config' => QywxPromotionConfig::decode($configRow['config_json'])];
|
||||
}
|
||||
|
||||
public function enqueue(array $row): int
|
||||
{
|
||||
$row['welcome_code_hash'] = $row['welcome_code_hash'] ?: null;
|
||||
// 同一code可能同时出现在half/add:唯一索引把欢迎语消费权固定在第一次任务。
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
if ($row['welcome_code_hash'] !== null
|
||||
&& Db::name('qywx_promotion_automation_task')->where('welcome_code_hash', $row['welcome_code_hash'])->find()) {
|
||||
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$actions['welcome']['status'] = 'skipped';
|
||||
$actions['welcome']['reason'] = 'same_welcome_code_already_queued';
|
||||
$row['actions_json'] = json_encode($actions, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
$row['welcome_status'] = 'skipped';
|
||||
$row['welcome_cipher'] = '';
|
||||
$row['welcome_code_hash'] = null;
|
||||
$pending = array_filter($actions, static fn (array $a): bool => in_array($a['status'], ['pending', 'retry', 'running'], true));
|
||||
$row['status'] = $pending === [] ? 'done' : 'pending';
|
||||
}
|
||||
try {
|
||||
return (int) Db::name('qywx_promotion_automation_task')->insertGetId($row);
|
||||
} catch (\Throwable $e) {
|
||||
$existing = Db::name('qywx_promotion_automation_task')->where('event_key', $row['event_key'])->value('id');
|
||||
if ($existing) {
|
||||
return (int) $existing;
|
||||
}
|
||||
if ($attempt === 1 || $row['welcome_code_hash'] === null) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('推广任务入队失败');
|
||||
}
|
||||
|
||||
/** 两条消费通道:常驻worker只发欢迎语,分钟任务不锁住尚有时效的欢迎语任务。 */
|
||||
public function due(string $lane, int $now, int $limit): array
|
||||
{
|
||||
$query = Db::name('qywx_promotion_automation_task')->where('status', '<>', 'done')
|
||||
->where('lock_until', '<=', $now);
|
||||
if ($lane === 'welcome') {
|
||||
$query->whereIn('welcome_status', ['pending', 'retry', 'running'])->where('welcome_next_retry', '<=', $now)
|
||||
->order('welcome_expires_at', 'asc');
|
||||
} else {
|
||||
$query->where('next_retry', '<=', $now)->where(function ($q) use ($now) {
|
||||
$q->whereNotIn('welcome_status', ['pending', 'retry', 'running'])
|
||||
->whereOr('welcome_expires_at', '<=', $now);
|
||||
})->order('id', 'asc');
|
||||
}
|
||||
return array_map('intval', $query->limit(max(1, min(500, $limit)))->column('id'));
|
||||
}
|
||||
|
||||
public function claim(int $id, string $lane, int $now): ?array
|
||||
{
|
||||
return Db::transaction(function () use ($id, $lane, $now): ?array {
|
||||
$row = Db::name('qywx_promotion_automation_task')->where('id', $id)->lock(true)->find();
|
||||
if (!$row || $row['status'] === 'done' || (int) $row['lock_until'] > $now) {
|
||||
return null;
|
||||
}
|
||||
$pendingWelcome = in_array($row['welcome_status'], ['pending', 'retry', 'running'], true);
|
||||
if (($lane === 'welcome' && (!$pendingWelcome || (int) $row['welcome_next_retry'] > $now))
|
||||
|| ($lane === 'metadata' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))
|
||||
|| ($lane === 'inline_metadata' && (int) $row['next_retry'] > $now)) {
|
||||
return null;
|
||||
}
|
||||
$row['lock_token'] = bin2hex(random_bytes(16));
|
||||
$row['lock_until'] = $now + (in_array($lane, ['welcome', 'inline_metadata'], true) ? 30 : 600);
|
||||
Db::name('qywx_promotion_automation_task')->where('id', $id)->update([
|
||||
'lock_token' => $row['lock_token'], 'lock_until' => $row['lock_until'], 'update_time' => $now,
|
||||
]);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
public function save(array $row, ?array $log = null): void
|
||||
{
|
||||
Db::transaction(function () use ($row, $log): void {
|
||||
$fields = array_intersect_key($row, array_flip([
|
||||
'actions_json', 'welcome_status', 'welcome_cipher', 'welcome_next_retry', 'status',
|
||||
'next_retry', 'lock_until', 'update_time',
|
||||
]));
|
||||
// 租约令牌校验不能依赖affected rows:同秒同值更新在MySQL可能返回0。
|
||||
$current = Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->lock(true)->find();
|
||||
if (!$current || !hash_equals((string) $current['lock_token'], (string) $row['lock_token'])) {
|
||||
throw new RuntimeException('推广任务处理租约已失效');
|
||||
}
|
||||
Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->update($fields);
|
||||
if ($log !== null) {
|
||||
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
|
||||
}
|
||||
});
|
||||
|
||||
// 标签动作成功/终止后,以任务入队时的配置冻结事件渠道;重复调用由完成标记幂等保护。
|
||||
QywxExternalContactEventTagSnapshotService::captureFromPromotionTask($row);
|
||||
}
|
||||
|
||||
public function localNames(array $task): array
|
||||
{
|
||||
return [
|
||||
'customer' => (string) (Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('name') ?? ''),
|
||||
'employee' => (string) (Db::name('admin')->where('id', $task['member_admin_id'])->value('name') ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(array $task): void
|
||||
{
|
||||
$result = QywxPromotionMemberSchedulerService::recordFromState('zyt_pool:' . $task['pool_id'],
|
||||
$task['userid'], $task['external_userid'], (int) $task['event_time'], 'external_contact');
|
||||
if (!in_array($result['status'] ?? '', ['counted', 'counted_blocked', 'counted_stale', 'duplicate'], true)) {
|
||||
throw new RuntimeException('推广成员记账未完成');
|
||||
}
|
||||
}
|
||||
|
||||
public function syncRange(array $task): void
|
||||
{
|
||||
// range服务自身有持久重试与版本保护;此调用负责触发。
|
||||
(new QywxPromotionRangeSyncService())->syncPool((int) $task['pool_id']);
|
||||
}
|
||||
|
||||
public function syncCustomer(array $task): void
|
||||
{
|
||||
$started = time();
|
||||
$eventId = (int) Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('user_id', (string) $task['userid'])
|
||||
->where('external_userid', (string) $task['external_userid'])
|
||||
->where('event_time', (int) $task['event_time'])
|
||||
->value('id');
|
||||
CustomerLogic::upsertSingleExternalContactFromApi(
|
||||
$task['external_userid'],
|
||||
$eventId,
|
||||
(string) $task['userid']
|
||||
);
|
||||
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
|
||||
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
|
||||
if ($updated < $started) {
|
||||
throw new RuntimeException('推广客户资料尚未同步到本地');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 一次性欢迎码仅加密短存;密钥不写数据库。多节点须显式共享环境密钥。 */
|
||||
class QywxPromotionCodeCipher
|
||||
{
|
||||
private ?string $key;
|
||||
|
||||
public function __construct(?string $key = null)
|
||||
{
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function encrypt(string $code): string
|
||||
{
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt($code, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('无法加密欢迎码');
|
||||
}
|
||||
return base64_encode($iv . $tag . $encrypted);
|
||||
}
|
||||
|
||||
public function decrypt(string $cipher): string
|
||||
{
|
||||
$value = base64_decode($cipher, true);
|
||||
if ($value === false || strlen($value) <= 28) {
|
||||
throw new RuntimeException('欢迎码密文无效');
|
||||
}
|
||||
$code = openssl_decrypt(substr($value, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($value, 0, 12), substr($value, 12, 16));
|
||||
if ($code === false) {
|
||||
throw new RuntimeException('欢迎码解密失败,请核对工作进程密钥');
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function key(): string
|
||||
{
|
||||
if ($this->key !== null) {
|
||||
if (strlen($this->key) < 32) {
|
||||
throw new RuntimeException('欢迎码加密密钥至少32字符');
|
||||
}
|
||||
return hash('sha256', $this->key, true);
|
||||
}
|
||||
$configured = (string) config('qywx_promotion_automation.encryption_key', '');
|
||||
if ($configured !== '') {
|
||||
$this->key = $configured;
|
||||
return $this->key();
|
||||
}
|
||||
$directory = root_path('runtime') . 'qywx_promotion_private';
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('无法创建欢迎码私有密钥目录');
|
||||
}
|
||||
$path = $directory . DIRECTORY_SEPARATOR . 'welcome.key';
|
||||
$stream = @fopen($path, 'c+b');
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('无法读取欢迎码私有密钥');
|
||||
}
|
||||
try {
|
||||
// 首次回调和多个worker可能同时启动;读写均持锁,避免读取尚未写完的密钥。
|
||||
if (!flock($stream, LOCK_EX)) {
|
||||
throw new RuntimeException('无法锁定欢迎码私有密钥');
|
||||
}
|
||||
@chmod($path, 0600);
|
||||
$key = trim((string) stream_get_contents($stream));
|
||||
if ($key === '') {
|
||||
$key = bin2hex(random_bytes(32));
|
||||
rewind($stream);
|
||||
if (fwrite($stream, $key) !== strlen($key) || !fflush($stream)) {
|
||||
throw new RuntimeException('无法保存欢迎码私有密钥');
|
||||
}
|
||||
}
|
||||
if (!preg_match('/^[0-9a-f]{64}$/', $key)) {
|
||||
throw new RuntimeException('欢迎码私有密钥损坏,请恢复原密钥');
|
||||
}
|
||||
$this->key = $key;
|
||||
} finally {
|
||||
flock($stream, LOCK_UN);
|
||||
fclose($stream);
|
||||
}
|
||||
return $this->key();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客方案配置。时间规则统一使用 Asia/Shanghai,结束时间不包含在时段内。 */
|
||||
class QywxPromotionConfig
|
||||
{
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'reception_mode' => 'always', 'reception_schedule' => [],
|
||||
'backup_member_admin_ids' => [], 'backup_userids' => [],
|
||||
'tags_enabled' => false, 'tag_ids' => [],
|
||||
'remark_enabled' => false, 'remark_template' => '{customer_name}',
|
||||
'description_enabled' => false, 'description' => '',
|
||||
'welcome_mode' => 'default', 'welcome' => ['text' => '', 'attachments' => []],
|
||||
'welcome_schedule_enabled' => false, 'welcome_schedule' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function installed(): bool
|
||||
{
|
||||
try {
|
||||
foreach ([
|
||||
'qywx_promotion_config',
|
||||
'qywx_promotion_media',
|
||||
'qywx_promotion_automation_task',
|
||||
'qywx_promotion_automation_action_log',
|
||||
] as $table) {
|
||||
if (Db::name($table)->getFields() === []) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (\Throwable $error) {
|
||||
// 仅旧部署未建表时回退。数据库故障不能退回全天路由、忽略排班配置。
|
||||
if (str_contains($error->getMessage(), '42S02')
|
||||
|| str_contains($error->getMessage(), '1146')
|
||||
|| str_contains($error->getMessage(), 'no such table')) {
|
||||
return false;
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertInstalled(): void
|
||||
{
|
||||
if (!self::installed()) {
|
||||
throw new RuntimeException('请先执行 server/sql/1.9.20260831/add_wecom_promotion_automation.sql 安装获客配置与任务表');
|
||||
}
|
||||
}
|
||||
|
||||
public static function decode(mixed $json): array
|
||||
{
|
||||
$value = is_array($json) ? $json : json_decode((string) $json, true);
|
||||
return array_replace(self::defaults(), is_array($value) ? $value : []);
|
||||
}
|
||||
|
||||
public static function forPool(int $poolId): array
|
||||
{
|
||||
if (!self::installed()) {
|
||||
return self::defaults();
|
||||
}
|
||||
return self::decode(Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json'));
|
||||
}
|
||||
|
||||
public static function save(int $poolId, array $config): void
|
||||
{
|
||||
self::assertInstalled();
|
||||
if ($poolId <= 0) {
|
||||
throw new RuntimeException('分流方案不存在,无法保存自动化配置');
|
||||
}
|
||||
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
|
||||
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$data = ['config_json' => $json, 'update_time' => time()];
|
||||
if ($row) {
|
||||
Db::name('qywx_promotion_config')->where('pool_id', $poolId)->update($data);
|
||||
} else {
|
||||
Db::name('qywx_promotion_config')->insert($data + ['pool_id' => $poolId, 'create_time' => time()]);
|
||||
}
|
||||
$saved = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json');
|
||||
if (!is_string($saved) || !hash_equals($json, $saved)) {
|
||||
throw new RuntimeException('获客标签与欢迎语配置未能完整落库,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 不接受浏览器提供的 userid;成员归属必须经过现有后台数据权限校验后再绑定。 */
|
||||
public static function normalize(array $input): array
|
||||
{
|
||||
$config = self::defaults();
|
||||
foreach (['tags_enabled', 'remark_enabled', 'description_enabled', 'welcome_schedule_enabled'] as $key) {
|
||||
$value = $input[$key] ?? false;
|
||||
if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) {
|
||||
throw new RuntimeException('配置开关格式不正确');
|
||||
}
|
||||
$config[$key] = in_array($value, [true, 1, '1'], true);
|
||||
}
|
||||
$config['reception_mode'] = self::choice($input['reception_mode'] ?? 'always', ['always', 'scheduled']);
|
||||
$config['welcome_mode'] = self::choice($input['welcome_mode'] ?? 'default', ['default', 'channel', 'none']);
|
||||
$config['backup_member_admin_ids'] = self::ids($input['backup_member_admin_ids'] ?? []);
|
||||
$config['reception_schedule'] = self::schedule($input['reception_schedule'] ?? [], true);
|
||||
if ($config['reception_mode'] === 'scheduled' && $config['reception_schedule'] === []) {
|
||||
throw new RuntimeException('自动上下线模式至少需要一个接待时段');
|
||||
}
|
||||
if ($config['reception_mode'] === 'scheduled' && $config['backup_member_admin_ids'] === []) {
|
||||
throw new RuntimeException('自动上下线须配置备用员工,避免非接待时段官方链接仍路由给原成员');
|
||||
}
|
||||
if (!is_array($input['tag_ids'] ?? [])) {
|
||||
throw new RuntimeException('客户标签格式不正确');
|
||||
}
|
||||
if (count($input['tag_ids'] ?? []) > 1) {
|
||||
// 兼容旧数组字段,但不能默默截断旧方案多选;编辑时须由用户重新确认单个标签。
|
||||
throw new RuntimeException('推广方案仅支持单个客户标签,请重新选择一个标签');
|
||||
}
|
||||
$tags = [];
|
||||
foreach ($input['tag_ids'] ?? [] as $tag) {
|
||||
if (!is_string($tag) || trim($tag) === '' || strlen($tag) > 128) {
|
||||
throw new RuntimeException('企业微信标签 ID 不正确');
|
||||
}
|
||||
$tags[] = trim($tag);
|
||||
}
|
||||
$config['tag_ids'] = array_values(array_unique($tags));
|
||||
if ($config['tags_enabled'] && count($config['tag_ids']) !== 1) {
|
||||
throw new RuntimeException('启用客户标签时请选择一个企业微信标签');
|
||||
}
|
||||
$config['remark_template'] = self::text($input['remark_template'] ?? '{customer_name}', 200, '客户备注模板');
|
||||
$config['description'] = self::text($input['description'] ?? '', 150, '客户描述');
|
||||
if ($config['remark_enabled'] && $config['remark_template'] === '') {
|
||||
throw new RuntimeException('请填写客户备注模板');
|
||||
}
|
||||
if ($config['description_enabled'] && $config['description'] === '') {
|
||||
throw new RuntimeException('请填写客户描述');
|
||||
}
|
||||
$config['welcome'] = self::message($input['welcome'] ?? []);
|
||||
$config['welcome_schedule'] = self::schedule($input['welcome_schedule'] ?? [], false);
|
||||
if ($config['welcome_mode'] === 'channel') {
|
||||
self::assertMessage($config['welcome']);
|
||||
if ($config['welcome_schedule_enabled'] && $config['welcome_schedule'] === []) {
|
||||
throw new RuntimeException('请添加分时段欢迎语');
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public static function matches(array $slot, int $timestamp): bool
|
||||
{
|
||||
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
|
||||
$minute = $date->format('H:i');
|
||||
$start = (string) ($slot['start'] ?? '');
|
||||
$end = (string) ($slot['end'] ?? '');
|
||||
$weekdays = array_map('intval', (array) ($slot['weekdays'] ?? []));
|
||||
$day = (int) $date->format('N');
|
||||
if ($start < $end) {
|
||||
return in_array($day, $weekdays, true) && $minute >= $start && $minute < $end;
|
||||
}
|
||||
// 跨午夜时段归属于开始日期,例如周一 22:00—02:00 包含周二凌晨。
|
||||
return ($minute >= $start && in_array($day, $weekdays, true))
|
||||
|| ($minute < $end && in_array($day === 1 ? 7 : $day - 1, $weekdays, true));
|
||||
}
|
||||
|
||||
public static function render(string $template, string $customer, string $employee, int $timestamp, int $limit): string
|
||||
{
|
||||
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
|
||||
return mb_substr(strtr($template, [
|
||||
'{customer_name}' => $customer, '{employee_name}' => $employee,
|
||||
'{add_time}' => $date->format('Y-m-d'),
|
||||
]), 0, $limit);
|
||||
}
|
||||
|
||||
private static function schedule(mixed $value, bool $reception): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > 30) {
|
||||
throw new RuntimeException('每类时间规则最多配置 30 条');
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($value as $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('时间规则格式不正确');
|
||||
}
|
||||
$days = self::ids($row['weekdays'] ?? []);
|
||||
if ($days === [] || max($days) > 7) {
|
||||
throw new RuntimeException('请选择星期一至星期日');
|
||||
}
|
||||
$start = (string) ($row['start'] ?? '');
|
||||
$end = (string) ($row['end'] ?? '');
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $start)
|
||||
|| !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $end) || $start === $end) {
|
||||
throw new RuntimeException('时段起止时间必须不同,格式为 HH:mm;全天在线请使用全天模式');
|
||||
}
|
||||
$clean = ['weekdays' => $days, 'start' => $start, 'end' => $end];
|
||||
if ($reception) {
|
||||
$clean['member_admin_ids'] = self::ids($row['member_admin_ids'] ?? []);
|
||||
if ($clean['member_admin_ids'] === []) {
|
||||
throw new RuntimeException('每个接待时段至少选择一名接待成员');
|
||||
}
|
||||
} else {
|
||||
$clean += self::message($row);
|
||||
self::assertMessage($clean);
|
||||
}
|
||||
$rows[] = $clean;
|
||||
}
|
||||
if (!$reception) {
|
||||
// 分时欢迎语不可重叠,避免靠数组顺序决定发送内容。
|
||||
$occupied = [];
|
||||
foreach ($rows as $row) {
|
||||
[$sh, $sm] = array_map('intval', explode(':', $row['start']));
|
||||
[$eh, $em] = array_map('intval', explode(':', $row['end']));
|
||||
$from = $sh * 60 + $sm;
|
||||
$to = $eh * 60 + $em;
|
||||
$duration = ($to - $from + 1440) % 1440;
|
||||
foreach ($row['weekdays'] as $day) {
|
||||
for ($i = 0; $i < $duration; $i++) {
|
||||
$key = (($day - 1) * 1440 + $from + $i) % 10080;
|
||||
if (isset($occupied[$key])) {
|
||||
throw new RuntimeException('分时段欢迎语的时间范围不能重叠');
|
||||
}
|
||||
$occupied[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function message(mixed $value): array
|
||||
{
|
||||
if (!is_array($value) || !is_array($value['attachments'] ?? [])) {
|
||||
throw new RuntimeException('欢迎语格式不正确');
|
||||
}
|
||||
$attachments = array_values($value['attachments'] ?? []);
|
||||
if (count($attachments) > 9) {
|
||||
throw new RuntimeException('欢迎语最多添加 9 个附件');
|
||||
}
|
||||
// 附件的详细格式与素材权限由 API/素材服务进一步验证。
|
||||
foreach ($attachments as $attachment) {
|
||||
if (!is_array($attachment) || !in_array($attachment['msgtype'] ?? '', ['image', 'link', 'miniprogram', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('不支持的欢迎语附件类型');
|
||||
}
|
||||
}
|
||||
$text = self::text($value['text'] ?? '', 1200, '欢迎语');
|
||||
if (strlen($text) > 4000) {
|
||||
throw new RuntimeException('欢迎语不能超过 4000 个 UTF-8 字节(表情通常占 4 字节)');
|
||||
}
|
||||
return ['text' => $text, 'attachments' => $attachments];
|
||||
}
|
||||
|
||||
private static function assertMessage(array $message): void
|
||||
{
|
||||
if (trim($message['text']) === '' && $message['attachments'] === []) {
|
||||
throw new RuntimeException('渠道欢迎语必须包含文字或附件');
|
||||
}
|
||||
}
|
||||
|
||||
private static function choice(mixed $value, array $choices): string
|
||||
{
|
||||
if (!is_string($value) || !in_array($value, $choices, true)) {
|
||||
throw new RuntimeException('不支持的配置模式');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function ids(mixed $value): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > 500) {
|
||||
throw new RuntimeException('成员或星期列表格式不正确');
|
||||
}
|
||||
$result = [];
|
||||
foreach ($value as $id) {
|
||||
if ((!is_int($id) && !(is_string($id) && ctype_digit($id))) || (int) $id <= 0) {
|
||||
throw new RuntimeException('成员或星期 ID 必须是正整数');
|
||||
}
|
||||
$result[] = (int) $id;
|
||||
}
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
private static function text(mixed $value, int $limit, string $label): string
|
||||
{
|
||||
if (!is_string($value) || mb_strlen($value) > $limit) {
|
||||
throw new RuntimeException($label . '不能超过 ' . $limit . ' 个字符');
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 不保存 Guzzle 原异常,避免请求 URL / token / welcome_code 进入日志。 */
|
||||
class QywxPromotionContactApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, int $code = 0, public bool $uncertain = false)
|
||||
{
|
||||
parent::__construct($message, $code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use RuntimeException;
|
||||
use think\facade\Cache;
|
||||
|
||||
/** 客户联系可调用自建应用;不使用对外收款应用 Secret。 */
|
||||
class QywxPromotionContactApiService
|
||||
{
|
||||
private const PROMOTION_TAG_GROUP = '推广渠道';
|
||||
|
||||
private Client $client;
|
||||
private string $corpId;
|
||||
private string $secret;
|
||||
private $tokenResolver;
|
||||
|
||||
public function __construct(?Client $client = null, ?callable $tokenResolver = null)
|
||||
{
|
||||
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''))
|
||||
?: trim((string) config('pay.wechat_work.corp_id', ''));
|
||||
// 获客回调的 WelcomeCode 应交由相同的可调用应用发送。专用覆盖仅用于明确配置的同应用。
|
||||
$this->secret = trim((string) config('qywx_promotion_automation.contact_secret', ''))
|
||||
?: (trim((string) config('qywx_customer_acquisition.secret', ''))
|
||||
?: trim((string) config('pay.wechat_work.customer_contact_secret', '')));
|
||||
$caPath = dirname(__DIR__, 4) . '/cacert.pem';
|
||||
$this->client = $client ?? new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/',
|
||||
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
||||
'verify' => is_file($caPath) ? $caPath : true, 'allow_redirects' => false,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
$this->tokenResolver = $tokenResolver;
|
||||
}
|
||||
|
||||
public function credentialFingerprint(): string
|
||||
{
|
||||
return hash('sha256', $this->corpId . '|' . $this->secret);
|
||||
}
|
||||
|
||||
public function tagOptions(): array
|
||||
{
|
||||
$result = $this->request('POST', 'externalcontact/get_corp_tag_list', []);
|
||||
$groups = [];
|
||||
foreach ((array) ($result['tag_group'] ?? []) as $group) {
|
||||
if (!is_array($group) || !empty($group['deleted'])) {
|
||||
continue;
|
||||
}
|
||||
$tags = [];
|
||||
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
||||
if (is_array($tag) && empty($tag['deleted']) && !empty($tag['id'])) {
|
||||
$tags[] = ['id' => (string) $tag['id'], 'name' => (string) ($tag['name'] ?? '')];
|
||||
}
|
||||
}
|
||||
$groups[] = ['group_id' => (string) ($group['group_id'] ?? ''),
|
||||
'group_name' => (string) ($group['group_name'] ?? ''), 'tag' => $tags];
|
||||
}
|
||||
return ['tag_groups' => $groups];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义企业客户标签:只写固定分组,先查重;创建结果不确定时只读回,不再次创建。
|
||||
* @return array{tag:array{id:string,name:string},group_id:string,group_name:string,reused:bool}
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92117
|
||||
*/
|
||||
public function createTag(string $name): array
|
||||
{
|
||||
if (!mb_check_encoding($name, 'UTF-8') || preg_match('/[\p{C}\x{2028}\x{2029}]/u', $name)) {
|
||||
throw new RuntimeException('标签名称不能包含控制字符或不可见格式字符');
|
||||
}
|
||||
$name = preg_replace('/^\p{Z}+|\p{Z}+$/u', '', trim($name)) ?? '';
|
||||
if ($name === '' || mb_strlen($name, 'UTF-8') > 30) {
|
||||
throw new RuntimeException('标签名称须为 1-30 个字符');
|
||||
}
|
||||
$groups = $this->tagOptions()['tag_groups'];
|
||||
$existing = $this->findPromotionTag($groups, $name, true);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
$body = ['tag' => [['name' => $name]]];
|
||||
foreach ($groups as $group) {
|
||||
if (($group['group_name'] ?? '') === self::PROMOTION_TAG_GROUP && ($group['group_id'] ?? '') !== '') {
|
||||
$body['group_id'] = $group['group_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isset($body['group_id'])) {
|
||||
// 官方保证同名分组存在时向该组添加,不额外创建同名分组;空分组不受支持。
|
||||
$body['group_name'] = self::PROMOTION_TAG_GROUP;
|
||||
}
|
||||
$failure = null;
|
||||
try {
|
||||
$response = $this->request('POST', 'externalcontact/add_corp_tag', $body, true);
|
||||
$created = $this->findPromotionTag([(array) ($response['tag_group'] ?? [])], $name, false);
|
||||
if ($created !== null) {
|
||||
return $created;
|
||||
}
|
||||
} catch (QywxPromotionContactApiException $error) {
|
||||
$failure = $error;
|
||||
}
|
||||
// 同名并发、上游缺失返回ID或网络中断,均只读回一次。永不构造本地伪标签ID。
|
||||
try {
|
||||
$confirmed = $this->findPromotionTag($this->tagOptions()['tag_groups'], $name, true);
|
||||
if ($confirmed !== null) {
|
||||
return $confirmed;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
||||
}
|
||||
if ($failure !== null && !$failure->uncertain) {
|
||||
throw new RuntimeException('企业微信标签创建失败[' . $failure->getCode() . '],请检查客户联系应用权限或标签额度', $failure->getCode());
|
||||
}
|
||||
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
||||
}
|
||||
|
||||
private function findPromotionTag(array $groups, string $name, bool $reused): ?array
|
||||
{
|
||||
foreach ($groups as $group) {
|
||||
if (!is_array($group) || !empty($group['deleted'])
|
||||
|| ($group['group_name'] ?? '') !== self::PROMOTION_TAG_GROUP
|
||||
|| !is_string($group['group_id'] ?? null) || $group['group_id'] === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
||||
if (is_array($tag) && empty($tag['deleted']) && ($tag['name'] ?? '') === $name
|
||||
&& is_string($tag['id'] ?? null) && $tag['id'] !== '') {
|
||||
return ['tag' => ['id' => $tag['id'], 'name' => $name],
|
||||
'group_id' => $group['group_id'], 'group_name' => self::PROMOTION_TAG_GROUP, 'reused' => $reused];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getExternalContact(string $externalUserId, string $cursor = ''): array
|
||||
{
|
||||
$query = ['external_userid' => $externalUserId];
|
||||
if ($cursor !== '') {
|
||||
$query['cursor'] = $cursor;
|
||||
}
|
||||
return $this->request('GET', 'externalcontact/get', $query);
|
||||
}
|
||||
|
||||
public function getUser(string $userId): array
|
||||
{
|
||||
return $this->request('GET', 'user/get', ['userid' => $userId]);
|
||||
}
|
||||
|
||||
public function markTags(string $userId, string $externalUserId, array $tagIds): void
|
||||
{
|
||||
if ($tagIds === []) {
|
||||
throw new RuntimeException('企业标签不能为空');
|
||||
}
|
||||
$this->request('POST', 'externalcontact/mark_tag', [
|
||||
'userid' => $userId, 'external_userid' => $externalUserId,
|
||||
'add_tag' => array_values(array_unique($tagIds)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function remark(string $userId, string $externalUserId, array $fields): void
|
||||
{
|
||||
$body = ['userid' => $userId, 'external_userid' => $externalUserId];
|
||||
foreach (['remark' => 20, 'description' => 150] as $field => $limit) {
|
||||
if (isset($fields[$field]) && $fields[$field] !== '') {
|
||||
if (!is_string($fields[$field]) || mb_strlen($fields[$field]) > $limit) {
|
||||
throw new RuntimeException('客户备注或描述长度不正确');
|
||||
}
|
||||
$body[$field] = $fields[$field];
|
||||
}
|
||||
}
|
||||
if (count($body) === 2) {
|
||||
throw new RuntimeException('没有启用需要修改的备注字段');
|
||||
}
|
||||
$this->request('POST', 'externalcontact/remark', $body);
|
||||
}
|
||||
|
||||
public function sendWelcome(string $code, string $text, array $attachments): void
|
||||
{
|
||||
if ($code === '' || strlen($code) > 1024 || strlen($text) > 4000
|
||||
|| count($attachments) > 9 || ($text === '' && $attachments === [])) {
|
||||
throw new RuntimeException('欢迎语内容或欢迎码格式不正确');
|
||||
}
|
||||
$body = ['welcome_code' => $code];
|
||||
if ($text !== '') {
|
||||
$body['text'] = ['content' => $text];
|
||||
}
|
||||
if ($attachments !== []) {
|
||||
$body['attachments'] = array_values($attachments);
|
||||
}
|
||||
$this->request('POST', 'externalcontact/send_welcome_msg', $body, true);
|
||||
}
|
||||
|
||||
/** 仅由私有素材服务传入受控文件流,不接受 URL 或请求提供的任意路径。 */
|
||||
public function uploadMedia($stream, string $type, string $filename): array
|
||||
{
|
||||
if (!is_resource($stream) || !in_array($type, ['image', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('临时素材类型或文件流不正确');
|
||||
}
|
||||
return $this->request('POST', 'media/upload', ['type' => $type], false, [
|
||||
'multipart' => [['name' => 'media', 'contents' => Utils::streamFor($stream), 'filename' => $filename]],
|
||||
'timeout' => 45,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 仅明确的 token 失效响应允许重取一次;欢迎语/标签创建的网络异常不能直接重发。 */
|
||||
private function request(string $method, string $path, array $body, bool $nonIdempotent = false, array $extra = [], bool $retried = false): array
|
||||
{
|
||||
$token = $this->accessToken();
|
||||
$options = $extra + ['query' => ['access_token' => $token]];
|
||||
if ($method === 'GET' || isset($extra['multipart'])) {
|
||||
$options['query'] += $body;
|
||||
} else {
|
||||
$options['json'] = $body === [] ? (object) [] : $body;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request($method, 'cgi-bin/' . $path, $options);
|
||||
} catch (GuzzleException) {
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口网络异常', 0, $nonIdempotent);
|
||||
}
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300
|
||||
|| !is_array($decoded) || !array_key_exists('errcode', $decoded)) {
|
||||
// media/upload 成功返回可没有 errcode。
|
||||
if ($path === 'media/upload' && $response->getStatusCode() === 200 && is_array($decoded) && !empty($decoded['media_id'])) {
|
||||
return $decoded;
|
||||
}
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口响应无法确认', 0, $nonIdempotent);
|
||||
}
|
||||
$code = (int) $decoded['errcode'];
|
||||
if ($code === 0) {
|
||||
return $decoded;
|
||||
}
|
||||
if (!$retried && in_array($code, [40001, 40014, 42001], true)) {
|
||||
if ($this->tokenResolver === null) {
|
||||
Cache::delete('qywx_promotion_contact_token:' . $this->credentialFingerprint());
|
||||
}
|
||||
if (isset($extra['multipart'])) {
|
||||
$extra['multipart'][0]['contents']->rewind();
|
||||
}
|
||||
return $this->request($method, $path, $body, $nonIdempotent, $extra, true);
|
||||
}
|
||||
// 不回显上游 errmsg;部分错误会包含请求参数与一次性凭证。
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口失败[' . $code . ']', $code);
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->tokenResolver !== null) {
|
||||
$token = (string) ($this->tokenResolver)();
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('客户联系托管 token 为空');
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
if ($this->corpId === '' || $this->secret === '') {
|
||||
throw new RuntimeException('请配置客户联系可调用自建应用的 corp_id 和 Secret');
|
||||
}
|
||||
$key = 'qywx_promotion_contact_token:' . $this->credentialFingerprint();
|
||||
$token = (string) Cache::get($key, '');
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
|
||||
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
|
||||
]);
|
||||
} catch (GuzzleException) {
|
||||
throw new QywxPromotionContactApiException('获取客户联系 token 网络异常');
|
||||
}
|
||||
$data = json_decode((string) $response->getBody(), true);
|
||||
if ($response->getStatusCode() !== 200 || !is_array($data)
|
||||
|| (int) ($data['errcode'] ?? 0) !== 0 || empty($data['access_token'])) {
|
||||
throw new QywxPromotionContactApiException('获取客户联系 token 失败', (int) ($data['errcode'] ?? 0));
|
||||
}
|
||||
$token = (string) $data['access_token'];
|
||||
Cache::set($key, $token, max(60, (int) ($data['expires_in'] ?? 7200) - 300));
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
class QywxPromotionEnqueueException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
use think\file\UploadedFile;
|
||||
|
||||
/** 私有源文件 + 可刷新三天临时素材。欢迎语关键路径仅使用缓存,不下载/上传文件。 */
|
||||
class QywxPromotionMediaService
|
||||
{
|
||||
private QywxPromotionContactApiService $api;
|
||||
private QywxPromotionMediaStore $store;
|
||||
private string $root;
|
||||
|
||||
public function __construct(?QywxPromotionContactApiService $api = null, ?QywxPromotionMediaStore $store = null, ?string $root = null)
|
||||
{
|
||||
$this->api = $api ?? new QywxPromotionContactApiService();
|
||||
$this->store = $store ?? new QywxPromotionMediaStore();
|
||||
// runtime_path()在adminapi/api/CLI间不同;使用项目级私有目录保证上传与worker共享。
|
||||
$this->root = rtrim($root ?? (root_path('runtime') . 'qywx_promotion_private' . DIRECTORY_SEPARATOR . 'media'), '/\\');
|
||||
}
|
||||
|
||||
/** @return array{asset_id:string,name:string,type:string} */
|
||||
public function upload($file, string $type, int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0 || !$file instanceof UploadedFile || !$file->isValid()) {
|
||||
throw new RuntimeException('请上传有效文件');
|
||||
}
|
||||
if (!in_array($type, ['image', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('素材类型仅支持 image、video、file');
|
||||
}
|
||||
$size = (int) $file->getSize();
|
||||
$limit = ($type === 'file' ? 20 : 10) * 1024 * 1024;
|
||||
if ($size <= 5 || $size > $limit) {
|
||||
throw new RuntimeException($type === 'file' ? '文件须大于5字节且不超过20MB' : '图片/视频须大于5字节且不超过10MB');
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getPathname());
|
||||
$name = str_replace('\\', '/', $file->getOriginalName());
|
||||
$name = mb_substr(preg_replace('/[\x00-\x1f\x7f]/u', '', basename($name)) ?? '', 0, 180);
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($type === 'image') {
|
||||
$info = @getimagesize($file->getPathname());
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png'], true) || $info === false
|
||||
|| !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
|
||||
throw new RuntimeException('图片仅支持真实 JPG/PNG 文件');
|
||||
}
|
||||
$extension = $mime === 'image/png' ? 'png' : 'jpg';
|
||||
} elseif ($type === 'video') {
|
||||
if ($mime !== 'video/mp4' || $extension !== 'mp4') {
|
||||
throw new RuntimeException('视频仅支持 MP4');
|
||||
}
|
||||
} else {
|
||||
// 私有存储也拒绝可执行内容/HTML/SVG;按实际 MIME 与扩展名双重检查。
|
||||
$allowed = [
|
||||
'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'],
|
||||
'doc' => ['application/msword', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'ppt' => ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'],
|
||||
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'],
|
||||
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'],
|
||||
'zip' => ['application/zip'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'],
|
||||
'mp4' => ['video/mp4'],
|
||||
];
|
||||
if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) {
|
||||
throw new RuntimeException('不支持该文件格式,请上传PDF、Office、文本、ZIP、JPG/PNG或MP4');
|
||||
}
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = '素材.' . $extension;
|
||||
}
|
||||
$this->ensureRoot();
|
||||
$assetId = bin2hex(random_bytes(24));
|
||||
$storageName = $assetId . '.' . $extension;
|
||||
$hash = hash_file('sha256', $file->getPathname());
|
||||
$file->move($this->root, $storageName);
|
||||
@chmod($this->root . DIRECTORY_SEPARATOR . $storageName, 0600);
|
||||
try {
|
||||
$this->store->insert([
|
||||
'asset_id' => $assetId, 'admin_id' => $adminId, 'name' => $name, 'type' => $type,
|
||||
'mime' => $mime, 'size' => $size, 'sha256' => $hash, 'storage_name' => $storageName,
|
||||
'media_id' => '', 'media_expires_at' => 0, 'credential_hash' => '',
|
||||
'last_error' => '', 'create_time' => time(), 'update_time' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($this->root . DIRECTORY_SEPARATOR . $storageName);
|
||||
throw new RuntimeException('素材入库失败,请确认已安装推广自动化数据表', 0, $e);
|
||||
}
|
||||
// 配置阶段就上传企微素材。失败保留私有文件供后续排障,不对外提供文件路径。
|
||||
$this->mediaId($assetId, $type, true);
|
||||
return ['asset_id' => $assetId, 'name' => $name, 'type' => $type];
|
||||
}
|
||||
|
||||
/** 旧方案授权由上层完成;只白名单旧配置实际已有资产,不接受请求单独声明的白名单。 */
|
||||
public function validateConfig(array $config, int $adminId, array $existingConfig = []): array
|
||||
{
|
||||
$allowed = self::assetIds($existingConfig);
|
||||
$config['welcome']['attachments'] = $this->validateAttachments((array) ($config['welcome']['attachments'] ?? []), $adminId, $allowed);
|
||||
foreach ((array) ($config['welcome_schedule'] ?? []) as $index => $slot) {
|
||||
$config['welcome_schedule'][$index]['attachments'] = $this->validateAttachments((array) ($slot['attachments'] ?? []), $adminId, $allowed);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array
|
||||
{
|
||||
if (count($attachments) > 9) {
|
||||
throw new RuntimeException('欢迎语最多9个附件');
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
if (!is_array($attachment)) {
|
||||
throw new RuntimeException('附件格式不正确');
|
||||
}
|
||||
$type = (string) ($attachment['msgtype'] ?? '');
|
||||
$body = $attachment[$type] ?? null;
|
||||
if (!is_array($body)) {
|
||||
throw new RuntimeException('附件内容类型不匹配');
|
||||
}
|
||||
if (in_array($type, ['image', 'video', 'file'], true)) {
|
||||
// image.pic_url 限企微 uploadimg URL;本服务仅接受私有资产,避免伪装任意外部地址。
|
||||
$asset = $this->authorizedAsset((string) ($body['asset_id'] ?? ''), $type, $adminId, $allowedAssetIds);
|
||||
$body = ['asset_id' => $asset['asset_id']];
|
||||
} elseif ($type === 'link') {
|
||||
$body = [
|
||||
'title' => self::bytes($body['title'] ?? '', 128, '链接标题', true),
|
||||
'url' => self::url($body['url'] ?? ''),
|
||||
'desc' => self::bytes($body['desc'] ?? '', 512, '链接描述'),
|
||||
] + (!empty($body['picurl']) ? ['picurl' => self::url($body['picurl'])] : []);
|
||||
} elseif ($type === 'miniprogram') {
|
||||
$asset = $this->authorizedAsset((string) ($body['pic_asset_id'] ?? ''), 'image', $adminId, $allowedAssetIds);
|
||||
$appid = (string) ($body['appid'] ?? '');
|
||||
$page = self::bytes($body['page'] ?? '', 1024, '小程序页面', true);
|
||||
if (!preg_match('/^wx[0-9a-fA-F]{16}$/', $appid) || str_contains($page, '://')
|
||||
|| str_contains($page, '..') || preg_match('/[\x00-\x1f]/', $page)) {
|
||||
throw new RuntimeException('小程序 appid 或页面路径不正确');
|
||||
}
|
||||
$body = ['title' => self::bytes($body['title'] ?? '', 64, '小程序标题', true),
|
||||
'appid' => $appid, 'page' => $page, 'pic_asset_id' => $asset['asset_id']];
|
||||
} else {
|
||||
throw new RuntimeException('不支持的附件类型');
|
||||
}
|
||||
$clean[] = ['msgtype' => $type, $type => $body];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** 仅处理已授权并持久化的配置快照;绝不在欢迎语发送时进行网络文件上传。 */
|
||||
public function materialize(array $attachments, array $config): array
|
||||
{
|
||||
$attachments = $this->validateAttachments($attachments, 0, self::assetIds($config));
|
||||
foreach ($attachments as &$attachment) {
|
||||
$type = $attachment['msgtype'];
|
||||
if (in_array($type, ['image', 'video', 'file'], true)) {
|
||||
$attachment[$type] = ['media_id' => $this->mediaId($attachment[$type]['asset_id'], $type, false)];
|
||||
} elseif ($type === 'miniprogram') {
|
||||
$attachment[$type]['pic_media_id'] = $this->mediaId($attachment[$type]['pic_asset_id'], 'image', false);
|
||||
unset($attachment[$type]['pic_asset_id']);
|
||||
}
|
||||
}
|
||||
unset($attachment);
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
public function refreshReferenced(int $limit = 100): array
|
||||
{
|
||||
$result = ['selected' => 0, 'refreshed' => 0, 'failed' => 0];
|
||||
foreach ($this->store->referencedAssetIds() as $id) {
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || ($this->cacheValid($asset, 3600))) {
|
||||
continue;
|
||||
}
|
||||
if ($result['selected'] >= max(1, $limit)) {
|
||||
break;
|
||||
}
|
||||
++$result['selected'];
|
||||
try {
|
||||
$this->mediaId($id, $asset['type'], true, 3600);
|
||||
++$result['refreshed'];
|
||||
} catch (\Throwable) {
|
||||
++$result['failed'];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function assetIds(array $config): array
|
||||
{
|
||||
$ids = [];
|
||||
$messages = array_merge([(array) ($config['welcome'] ?? [])], (array) ($config['welcome_schedule'] ?? []));
|
||||
foreach ($messages as $message) {
|
||||
foreach ((array) ($message['attachments'] ?? []) as $attachment) {
|
||||
$type = $attachment['msgtype'] ?? '';
|
||||
$key = $type === 'miniprogram' ? 'pic_asset_id' : 'asset_id';
|
||||
$id = (string) ($attachment[$type][$key] ?? '');
|
||||
if (preg_match('/^[0-9a-f]{48}$/', $id)) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function authorizedAsset(string $id, string $type, int $adminId, array $allowed): array
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{48}$/', $id)) {
|
||||
throw new RuntimeException('请先上传欢迎语素材');
|
||||
}
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || $asset['type'] !== $type || ((int) $asset['admin_id'] !== $adminId && !in_array($id, $allowed, true))) {
|
||||
throw new RuntimeException('素材不存在、类型不匹配或无权使用');
|
||||
}
|
||||
return $asset;
|
||||
}
|
||||
|
||||
private function mediaId(string $id, string $type, bool $allowUpload, int $margin = 300): string
|
||||
{
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || $asset['type'] !== $type) {
|
||||
throw new RuntimeException('欢迎语素材不存在');
|
||||
}
|
||||
if ($this->cacheValid($asset, $margin)) {
|
||||
return (string) $asset['media_id'];
|
||||
}
|
||||
if (!$allowUpload) {
|
||||
throw new RuntimeException('欢迎语素材未预热或已过期,请检查素材刷新任务');
|
||||
}
|
||||
$stream = null;
|
||||
try {
|
||||
$path = $this->privatePath((string) $asset['storage_name']);
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $asset['sha256']) {
|
||||
throw new RuntimeException('欢迎语源文件缺失或完整性检查失败');
|
||||
}
|
||||
$stream = fopen($path, 'rb');
|
||||
$result = $this->api->uploadMedia($stream, $type, (string) $asset['name']);
|
||||
if (empty($result['media_id'])) {
|
||||
throw new RuntimeException('企微素材接口未返回 media_id');
|
||||
}
|
||||
$created = min(time(), (int) ($result['created_at'] ?? time()));
|
||||
$this->store->update($id, ['media_id' => (string) $result['media_id'],
|
||||
'media_expires_at' => $created + 3 * 86400, 'credential_hash' => $this->api->credentialFingerprint(),
|
||||
'last_error' => '', 'update_time' => time()]);
|
||||
return (string) $result['media_id'];
|
||||
} catch (\Throwable $e) {
|
||||
$this->store->update($id, ['last_error' => '素材预热失败[' . (int) $e->getCode() . ']', 'update_time' => time()]);
|
||||
throw $e;
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheValid(array $asset, int $margin): bool
|
||||
{
|
||||
return !empty($asset['media_id']) && (int) $asset['media_expires_at'] > time() + $margin
|
||||
&& hash_equals((string) $asset['credential_hash'], $this->api->credentialFingerprint());
|
||||
}
|
||||
|
||||
private function privatePath(string $name): string
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{48}\.[a-z0-9]{1,8}$/', $name)) {
|
||||
throw new RuntimeException('素材存储标识不正确');
|
||||
}
|
||||
$root = realpath($this->root);
|
||||
$path = realpath($this->root . DIRECTORY_SEPARATOR . $name);
|
||||
if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('素材文件不在私有存储目录');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function ensureRoot(): void
|
||||
{
|
||||
if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) {
|
||||
throw new RuntimeException('无法创建私有素材目录');
|
||||
}
|
||||
}
|
||||
|
||||
private static function bytes(mixed $value, int $limit, string $label, bool $required = false): string
|
||||
{
|
||||
if (!is_string($value) || strlen($value) > $limit || ($required && trim($value) === '')) {
|
||||
throw new RuntimeException($label . '须' . ($required ? '非空且' : '') . '不超过' . $limit . '字节');
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private static function url(mixed $value): string
|
||||
{
|
||||
if (!is_string($value) || strlen($value) > 2048 || filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
throw new RuntimeException('链接地址不正确');
|
||||
}
|
||||
$parts = parse_url($value);
|
||||
if (!in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| isset($parts['user']) || isset($parts['pass'])) {
|
||||
throw new RuntimeException('链接仅支持不含账号密码的HTTP(S)地址');
|
||||
}
|
||||
// 仅向企微传递链接;服务端永远不会抓取这些URL。
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 独立存储边界,测试可使用内存替身,禁止连接业务数据库。 */
|
||||
class QywxPromotionMediaStore
|
||||
{
|
||||
public function find(string $assetId): ?array
|
||||
{
|
||||
return Db::name('qywx_promotion_media')->where('asset_id', $assetId)->find() ?: null;
|
||||
}
|
||||
|
||||
public function insert(array $row): void
|
||||
{
|
||||
Db::name('qywx_promotion_media')->insert($row);
|
||||
}
|
||||
|
||||
public function update(string $assetId, array $fields): void
|
||||
{
|
||||
Db::name('qywx_promotion_media')->where('asset_id', $assetId)->update($fields);
|
||||
}
|
||||
|
||||
/** 只预热已保存方案引用的素材;未使用上传不永久续期。 */
|
||||
public function referencedAssetIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (Db::name('qywx_promotion_config')->alias('cfg')
|
||||
->join('qywx_promotion_pool pool', 'pool.id = cfg.pool_id')
|
||||
->whereNull('pool.delete_time')->column('cfg.config_json') as $json) {
|
||||
$ids = array_merge($ids, QywxPromotionMediaService::assetIds(QywxPromotionConfig::decode($json)));
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,22 @@ class QywxPromotionMemberRange
|
||||
* @param list<array<string,mixed>> $members
|
||||
* @return array{userids:list<string>,members:list<array<string,mixed>>,eligible_count:int}
|
||||
*/
|
||||
public static function evaluate(array $members, string $today, int $now): array
|
||||
public static function evaluate(array $members, string $today, int $now, array $config = []): array
|
||||
{
|
||||
$userIds = [];
|
||||
$backups = array_fill_keys((array) ($config['backup_userids'] ?? []), true);
|
||||
$backupIds = [];
|
||||
$scheduled = ($config['reception_mode'] ?? 'always') === 'scheduled';
|
||||
$scheduledUsers = [];
|
||||
if ($scheduled) {
|
||||
foreach ((array) ($config['reception_schedule'] ?? []) as $slot) {
|
||||
if (QywxPromotionConfig::matches($slot, $now)) {
|
||||
foreach ((array) ($slot['member_userids'] ?? []) as $userId) {
|
||||
$scheduledUsers[$userId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($members as &$member) {
|
||||
if ((string) ($member['today_date'] ?? '') !== $today) {
|
||||
$member['today_date'] = $today;
|
||||
@@ -25,15 +38,24 @@ class QywxPromotionMemberRange
|
||||
}
|
||||
$userId = trim((string) ($member['userid'] ?? ''));
|
||||
if ($userId !== '') {
|
||||
$userIds[$userId] = true;
|
||||
if (isset($backups[$userId])) {
|
||||
$backupIds[$userId] = true;
|
||||
} elseif (!$scheduled || isset($scheduledUsers[$userId])) {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($member);
|
||||
|
||||
$usingBackup = $userIds === [] && $backupIds !== [];
|
||||
if ($usingBackup) {
|
||||
$userIds = $backupIds;
|
||||
}
|
||||
return [
|
||||
'userids' => array_keys($userIds),
|
||||
'members' => array_values($members),
|
||||
'eligible_count' => count($userIds),
|
||||
'using_backup' => $usingBackup,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class QywxPromotionMemberSchedulerService
|
||||
string $today,
|
||||
int $now
|
||||
): array {
|
||||
$range = QywxPromotionMemberRange::evaluate($members, $today, $now);
|
||||
$range = QywxPromotionMemberRange::evaluate($members, $today, $now, QywxPromotionConfig::forPool($poolId));
|
||||
self::persistMemberCursors($range['members'], $now);
|
||||
if ($range['userids'] === []) {
|
||||
self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 分流方案共享操作人产生的页面入口与专用数据范围。 */
|
||||
final class QywxPromotionOperatorAccess
|
||||
{
|
||||
public const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
|
||||
|
||||
public static function hasBasePagePermission(int $adminId, array $adminInfo = []): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('admin_role')->alias('ar')
|
||||
->join('system_role_menu rm', 'rm.role_id = ar.role_id')
|
||||
->join('system_menu m', 'm.id = rm.menu_id')
|
||||
->where('ar.admin_id', $adminId)
|
||||
->where('m.perms', self::PAGE_PERMISSION)
|
||||
->where('m.is_disable', 0)
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
public static function hasSharedPagePermission(int $adminId): bool
|
||||
{
|
||||
if ($adminId <= 0 || !self::pageMenuEnabled()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Db::name('qywx_promotion_pool_operator')->alias('po')
|
||||
->join('qywx_promotion_pool p', 'p.id = po.pool_id')
|
||||
->where('po.admin_id', $adminId)
|
||||
->whereNull('po.delete_time')
|
||||
->whereNull('p.delete_time')
|
||||
->count() > 0;
|
||||
} catch (\Throwable $error) {
|
||||
if (self::isMissingTable($error)) {
|
||||
return false;
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
public static function hasPagePermission(int $adminId, array $adminInfo = []): bool
|
||||
{
|
||||
return self::hasBasePagePermission($adminId, $adminInfo)
|
||||
|| self::hasSharedPagePermission($adminId);
|
||||
}
|
||||
|
||||
/** 基础页面权限沿用角色数据范围;纯共享账号只能通过 operator pool 范围访问。 */
|
||||
public static function visibleAdminIds(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
return self::hasBasePagePermission($adminId, $adminInfo)
|
||||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public static function activePoolIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$ids = Db::name('qywx_promotion_pool_operator')->alias('po')
|
||||
->join('qywx_promotion_pool p', 'p.id = po.pool_id')
|
||||
->where('po.admin_id', $adminId)
|
||||
->whereNull('po.delete_time')
|
||||
->whereNull('p.delete_time')
|
||||
->column('po.pool_id');
|
||||
} catch (\Throwable $error) {
|
||||
if (self::isMissingTable($error)) {
|
||||
return [];
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($value): int => (int) $value,
|
||||
$ids
|
||||
), static fn (int $value): bool => $value > 0)));
|
||||
}
|
||||
|
||||
private static function pageMenuEnabled(): bool
|
||||
{
|
||||
return Db::name('system_menu')
|
||||
->where('perms', self::PAGE_PERMISSION)
|
||||
->where('is_disable', 0)
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
private static function isMissingTable(\Throwable $error): bool
|
||||
{
|
||||
$message = strtolower($error->getMessage());
|
||||
|
||||
return str_contains($message, '42s02')
|
||||
|| str_contains($message, '1146')
|
||||
|| str_contains($message, 'no such table');
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class QywxPromotionRangeSyncService
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('官方链接 ID 为空');
|
||||
}
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time());
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
|
||||
$desiredUserIds = $range['userids'];
|
||||
if ($desiredUserIds === []) {
|
||||
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
|
||||
@@ -93,7 +93,7 @@ class QywxPromotionRangeSyncService
|
||||
$this->api->updateLink([
|
||||
'link_id' => $remoteLinkId,
|
||||
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
|
||||
'range' => ['user_list' => $desiredUserIds],
|
||||
'range' => ['user_list' => $desiredUserIds, 'department_list' => []],
|
||||
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
|
||||
]);
|
||||
$response = $this->api->getLink($remoteLinkId);
|
||||
|
||||
@@ -116,13 +116,19 @@ class Qcloud extends Server
|
||||
|
||||
/**
|
||||
* @notes 获取 STS 临时凭证(用于浏览器直传)
|
||||
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
|
||||
* @param string $keyScope 资源前缀或完整对象 Key
|
||||
* @param int $maxSizeBytes 单文件大小上限(字节)
|
||||
* @param int $durationSeconds 凭证有效期(秒)
|
||||
* @param bool $exactObject 是否只授权单个对象 Key
|
||||
* @return array {credentials, expiredTime, requestId}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
|
||||
public function getStsCredentials(
|
||||
string $keyScope,
|
||||
int $maxSizeBytes,
|
||||
int $durationSeconds = 1800,
|
||||
bool $exactObject = false
|
||||
): array
|
||||
{
|
||||
$bucket = $this->config['bucket'];
|
||||
// bucket 形如 likeadmin-1300000000,appId 即末段
|
||||
@@ -137,15 +143,19 @@ class Qcloud extends Server
|
||||
|
||||
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
|
||||
$region = $this->config['region'];
|
||||
$prefix = ltrim($keyPrefix, '/');
|
||||
if ($prefix === '' || substr($prefix, -1) !== '/') {
|
||||
$prefix = $prefix . '/';
|
||||
$scope = ltrim($keyScope, '/');
|
||||
if ($scope === '') {
|
||||
throw new Exception('COS 授权对象不能为空');
|
||||
}
|
||||
if (!$exactObject && substr($scope, -1) !== '/') {
|
||||
$scope .= '/';
|
||||
}
|
||||
|
||||
$duration = max(900, min($durationSeconds, 7200));
|
||||
|
||||
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploads(cos-js-sdk-v5 续传探测必需)
|
||||
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
|
||||
$objectResource = $exactObject ? $scope : $scope . '*';
|
||||
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s', $region, $appId, $bucket, $objectResource);
|
||||
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
|
||||
|
||||
$policy = [
|
||||
|
||||
@@ -34,6 +34,9 @@ return [
|
||||
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
|
||||
// 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
|
||||
'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
|
||||
'qywx:work-promotion-automation' => 'app\\command\\QywxWorkPromotionAutomation',
|
||||
'qywx:retry-promotion-automation' => 'app\\command\\QywxRetryPromotionAutomation',
|
||||
'qywx:refresh-promotion-media' => 'app\\command\\QywxRefreshPromotionMedia',
|
||||
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST)
|
||||
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
||||
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 缺省使用获客助手相同的可调用自建应用,绝不回退对外收款 Secret。
|
||||
'contact_secret' => env('WECHAT_WORK_PROMOTION_CONTACT_SECRET', ''),
|
||||
// 至少32字符的随机值;多节点必须使用同一密钥。缺省在私有runtime目录生成0600密钥。
|
||||
'encryption_key' => env('WECHAT_WORK_PROMOTION_ENCRYPTION_KEY', ''),
|
||||
];
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_change_user_ext_time` (`change_type`, `user_id`, `external_userid`, `event_time`),
|
||||
KEY `idx_change_time` (`change_type`, `event_time`),
|
||||
KEY `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`),
|
||||
KEY `idx_event_time` (`event_time`),
|
||||
KEY `idx_state` (`state`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业微信外部联系人事件流水(用于进入计数)';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- 企业微信新增事件标签快照:历史渠道归属只追加,不随当前标签的修改/删除而回落。
|
||||
-- 上线顺序:先执行本迁移,再发布读取 qywx_external_contact_event_tag 的代码。
|
||||
CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event_tag` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`event_id` bigint unsigned NOT NULL COMMENT 'qywx_external_contact_event.id',
|
||||
`follow_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '产生新增事件的企微员工userid',
|
||||
`tag_id` varchar(64) NOT NULL DEFAULT '' COMMENT '事件发生时标签ID;空串为快照完成标记',
|
||||
`tag_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签名',
|
||||
`group_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签组名',
|
||||
`snapshot_source` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '1客户详情 2推广任务 3上线时当前关系回填',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_event_user_tag` (`event_id`, `follow_user_id`, `tag_id`),
|
||||
KEY `idx_tag_event_user` (`tag_id`, `event_id`, `follow_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微新增事件标签快照(append-only)';
|
||||
|
||||
-- 优先追加推广自动化任务中已确认成功的标签证据;这里不写完成标记,
|
||||
-- 因为任务配置不能证明客户当时没有其他企微标签。
|
||||
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
|
||||
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
|
||||
SELECT
|
||||
e.`id`,
|
||||
e.`user_id`,
|
||||
LEFT(
|
||||
COALESCE(
|
||||
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
|
||||
''
|
||||
),
|
||||
64
|
||||
),
|
||||
'',
|
||||
'',
|
||||
2,
|
||||
UNIX_TIMESTAMP()
|
||||
FROM `zyt_qywx_promotion_automation_task` t
|
||||
INNER JOIN `zyt_qywx_external_contact_event` e
|
||||
ON e.`change_type` = t.`change_type`
|
||||
AND e.`user_id` = t.`userid`
|
||||
AND e.`external_userid` = t.`external_userid`
|
||||
AND e.`event_time` = t.`event_time`
|
||||
WHERE t.`change_type` = 'add_external_contact'
|
||||
AND JSON_UNQUOTE(
|
||||
JSON_EXTRACT(IF(JSON_VALID(t.`actions_json`), t.`actions_json`, '{}'), '$.tags.status')
|
||||
) = 'success'
|
||||
AND COALESCE(
|
||||
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
|
||||
''
|
||||
) <> '';
|
||||
|
||||
-- 尚未完成快照的老事件,仅按同一员工的当前标签尽力冻结;不做跨员工补偿。
|
||||
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
|
||||
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
|
||||
SELECT
|
||||
e.`id`,
|
||||
e.`user_id`,
|
||||
current_tag.`tag_id`,
|
||||
current_tag.`tag_name`,
|
||||
current_tag.`group_name`,
|
||||
3,
|
||||
UNIX_TIMESTAMP()
|
||||
FROM `zyt_qywx_external_contact_event` e
|
||||
INNER JOIN `zyt_qywx_external_contact_tag` current_tag
|
||||
ON current_tag.`external_userid` = e.`external_userid`
|
||||
AND current_tag.`follow_user_id` = e.`user_id`
|
||||
WHERE e.`change_type` = 'add_external_contact'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM `zyt_qywx_external_contact` active_contact
|
||||
WHERE active_contact.`external_userid` = e.`external_userid`
|
||||
AND active_contact.`delete_time` IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `zyt_qywx_external_contact_event_tag` existing_snapshot
|
||||
WHERE existing_snapshot.`event_id` = e.`id`
|
||||
AND existing_snapshot.`follow_user_id` = e.`user_id`
|
||||
AND existing_snapshot.`tag_id` = ''
|
||||
);
|
||||
|
||||
-- 给“当前关系回填”补完成标记,确保后续改标签不会再追加到同一历史事件。
|
||||
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
|
||||
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
|
||||
SELECT DISTINCT
|
||||
snapshot_row.`event_id`,
|
||||
snapshot_row.`follow_user_id`,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
3,
|
||||
UNIX_TIMESTAMP()
|
||||
FROM `zyt_qywx_external_contact_event_tag` snapshot_row
|
||||
WHERE snapshot_row.`snapshot_source` = 3
|
||||
AND snapshot_row.`tag_id` <> '';
|
||||
@@ -38,6 +38,8 @@ HOST = "https://公开访问域名"
|
||||
|
||||
创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。
|
||||
|
||||
批量修改方案支持按员工统一上线或下线。操作只影响所选方案中已经存在的员工规则,不会把员工自动加入其他方案;下线操作如果会令任一方案没有当前可用的上线员工,则整批在写入前拒绝。员工上下线需与其他方案配置分开保存;状态保存与同步意图在同一事务内提交,每个受影响方案只重算一次成员范围,并进入企业微信后台同步队列。
|
||||
|
||||
企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。
|
||||
|
||||
系统只使用企业微信获客助手生成的链接:
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-BRBhqisw.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-ypE3v-gF.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-BRBhqisw.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-ypE3v-gF.js";import{o,q as r,r as t,v as s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Dwh6tNxD.js";import{a as V}from"./doctor-CD6XYjKU.js";import{m as A,_ as M}from"./index-CLwe6ftX.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CeZEm9rq.js";import{a as V}from"./doctor-BR8GzTlW.js";import{m as A,_ as M}from"./index-vflU_jNS.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Dwh6tNxD.js";import{ae as V}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CeZEm9rq.js";import{af as V}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,di as c}from"./.pnpm-CeZEm9rq.js";import{ag as Y}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-CMHUdU8f.js";import{U as k}from"./index-BWeecgSM.js";import{i as c,_ as q}from"./index-CLwe6ftX.js";import{aj as K,ak as x,al as A}from"./tcm-BzzBMfBw.js";import"./RecordingVideoPlayer-CDqcv1v9.js";import"./file-CAEOOa9_.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,dk as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CeZEm9rq.js";import j from"./RecordingPlaybackBlock-DGBQb6Ox.js";import{U as k}from"./index-BP4odEtD.js";import{i as c,_ as q}from"./index-vflU_jNS.js";import{ak as K,al as x,am as A}from"./tcm-BmNVoRI3.js";import"./RecordingVideoPlayer-jwST1qx7.js";import"./file-BXLECkux.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Dwh6tNxD.js";import{am as q}from"./tcm-BzzBMfBw.js";import{_ as H}from"./index-CLwe6ftX.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CeZEm9rq.js";import{an as q}from"./tcm-BmNVoRI3.js";import{_ as H}from"./index-vflU_jNS.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-C6AeSFwh.js";import"./.pnpm-CeZEm9rq.js";import"./tcm-BmNVoRI3.js";import"./index-vflU_jNS.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-hLNJ9AIP.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm-BzzBMfBw.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Dwh6tNxD.js";import{p as j}from"./tcm-BzzBMfBw.js";import{i as C}from"./index-CLwe6ftX.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CeZEm9rq.js";import{p as j}from"./tcm-BmNVoRI3.js";import{i as C}from"./index-vflU_jNS.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm-BzzBMfBw.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-CLwe6ftX.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cX as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as X,M as m,p as Q,ae as U,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-CeZEm9rq.js";import{d as te}from"./dayjs-CyERfvvz.js";import{as as ne,at as oe}from"./tcm-BmNVoRI3.js";import{p as re}from"./im-business-message-parse-Bzp_WHkq.js";import{_ as le}from"./index-vflU_jNS.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=Q(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=se,z=W,A=X;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:U(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(Y,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(V,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B_HCfW5z.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-CF1cFioe.js";import"./.pnpm-CeZEm9rq.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-Dwh6tNxD.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CeZEm9rq.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Dwh6tNxD.js";import{t as j,_ as J}from"./index-CLwe6ftX.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CeZEm9rq.js";import{t as j,_ as J}from"./index-vflU_jNS.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-Dr1-lH_q.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-CLwe6ftX.js";import{a as T,d as he}from"./patient-4QJ3YgMJ.js";import{h as ke}from"./perm-CHalL7pT.js";import"./index-De13kk0O.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d9 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CeZEm9rq.js";import{_ as fe}from"./picker-DeM3E1-N.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-vflU_jNS.js";import{a as T,d as he}from"./patient-DPw8Zw1m.js";import{h as ke}from"./perm-BCohqYdQ.js";import"./index-CxXw87QU.js";import"./index-mw3GnHGJ.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./index-IdU3279O.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";import"./usePaging-DUs81Q_K.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Dwh6tNxD.js";import{_ as V}from"./index-CLwe6ftX.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-CeZEm9rq.js";import{_ as V}from"./index-vflU_jNS.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-3fe6d04f] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-3fe6d04f]{border-radius:8px}.stat-title[data-v-3fe6d04f]{font-weight:500}.po-panel[data-v-3fe6d04f]{border-radius:8px;transition:all .3s}.po-panel[data-v-3fe6d04f] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-3fe6d04f] .el-card__body{padding:16px}.po-desc[data-v-3fe6d04f] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-3fe6d04f]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-3fe6d04f]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-3fe6d04f]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-3fe6d04f]{text-align:center;line-height:1.1}.stamp-pass[data-v-3fe6d04f]{color:var(--el-color-success)}.stamp-reject[data-v-3fe6d04f]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
@@ -1 +0,0 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-04a9fc6d] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-04a9fc6d]{border-radius:8px}.stat-title[data-v-04a9fc6d]{font-weight:500}.po-panel[data-v-04a9fc6d]{border-radius:8px;transition:all .3s}.po-panel[data-v-04a9fc6d] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-04a9fc6d] .el-card__body{padding:16px}.po-desc[data-v-04a9fc6d] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-04a9fc6d]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-04a9fc6d]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-04a9fc6d]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-04a9fc6d]{text-align:center;line-height:1.1}.stamp-pass[data-v-04a9fc6d]{color:var(--el-color-success)}.stamp-reject[data-v-04a9fc6d]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.automation-form[data-v-a7e42c87]{width:100%}.automation-note[data-v-a7e42c87]{margin-top:22px}.automation-note[data-v-a7e42c87] .el-alert__description{line-height:1.7}.automation-section[data-v-a7e42c87]{min-width:0;transition:opacity .2s ease}.automation-section.is-disabled[data-v-a7e42c87]{opacity:.58}.form-section-title[data-v-a7e42c87]{margin:28px 0 18px;padding:0 0 12px;border-bottom:1px solid #ebeef5;font-size:15px;font-weight:600;color:#303133}.field-help[data-v-a7e42c87]{width:100%;font-size:12px;line-height:1.7;margin:6px 0 0;color:#909399}.warning-help[data-v-a7e42c87]{color:#9f6d14}.full-width[data-v-a7e42c87]{width:100%}.inline-error[data-v-a7e42c87]{width:100%;color:#d93026;font-size:12px;line-height:1.7;margin:8px 0 0}.schedule-card[data-v-a7e42c87]{padding:16px;border:1px solid #e4e7ed;border-radius:6px;background:#fafbfd;margin-bottom:12px}.schedule-heading[data-v-a7e42c87]{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px}.weekday-select[data-v-a7e42c87]{display:flex;flex-wrap:wrap;gap:0 18px}.weekday-select[data-v-a7e42c87] .el-checkbox{margin-right:0}.time-row[data-v-a7e42c87]{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:12px 0}.time-row[data-v-a7e42c87] .el-date-editor.el-input{width:150px}.time-row>span[data-v-a7e42c87]{font-size:12px;color:#909399}.time-row>small[data-v-a7e42c87]{font-size:12px;color:#b88230}.reception-schedules[data-v-a7e42c87]{margin:0 0 20px}.tags-content[data-v-a7e42c87],.remark-content[data-v-a7e42c87],.description-input[data-v-a7e42c87]{margin-top:12px}.tag-select-row[data-v-a7e42c87]{display:flex;gap:10px;width:100%}.tag-select[data-v-a7e42c87]{flex:1;min-width:0}.token-buttons[data-v-a7e42c87]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}.token-buttons .el-button+.el-button[data-v-a7e42c87]{margin-left:0}.remark-preview[data-v-a7e42c87]{display:flex;gap:14px;align-items:center;padding:10px 12px;background:#f5f7fa;margin-top:8px;border-radius:4px;line-height:1.7}.remark-preview span[data-v-a7e42c87],.remark-preview small[data-v-a7e42c87]{color:#909399;font-size:12px}.remark-preview strong[data-v-a7e42c87]{color:#303133;font-size:13px;font-weight:500;overflow-wrap:anywhere}.remark-preview small[data-v-a7e42c87]{margin-left:auto;white-space:nowrap}.welcome-block h4[data-v-a7e42c87]{font-size:13px;font-weight:600;margin:0 0 4px}.welcome-block>.field-help[data-v-a7e42c87]{margin-bottom:12px}.schedule-switch[data-v-a7e42c87]{margin-top:24px}.switch-help[data-v-a7e42c87]{margin-left:12px;color:#909399;font-size:12px}.welcome-schedule[data-v-a7e42c87]{background:#fff}.tag-select-row .el-button+.el-button[data-v-a7e42c87]{margin-left:0}.custom-tag-editor[data-v-a7e42c87]{margin-top:12px;padding:14px;background:#f5f7fa;border:1px solid #e4e7ed;border-radius:4px}.custom-tag-editor label[data-v-a7e42c87]{display:block;font-size:13px;color:#606266;margin-bottom:8px}.custom-tag-row[data-v-a7e42c87]{display:flex;align-items:center;gap:10px}.custom-tag-row .el-input[data-v-a7e42c87]{flex:1;min-width:0}.legacy-tags-warning[data-v-a7e42c87]{margin-top:10px;padding:10px 12px;background:#fdf6ec;border:1px solid #faecd8;border-radius:4px;color:#9f6d14}.legacy-tags-warning p[data-v-a7e42c87]{margin:0 0 6px;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.tag-success[data-v-a7e42c87]{margin:8px 0 0;color:#27864c;font-size:12px;line-height:1.7}@media (max-width: 620px){.tag-select-row[data-v-a7e42c87]{flex-direction:column}.remark-preview[data-v-a7e42c87]{flex-wrap:wrap}.automation-form[data-v-a7e42c87] .el-radio{margin-right:14px}.weekday-select[data-v-a7e42c87]{gap:0 12px}}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-CDqcv1v9.js";import{e as I,_ as P}from"./index-CLwe6ftX.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-CeZEm9rq.js";import H from"./RecordingVideoPlayer-jwST1qx7.js";import{e as I,_ as P}from"./index-vflU_jNS.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Dwh6tNxD.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-CLwe6ftX.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Dwh6tNxD.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CeZEm9rq.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-CeZEm9rq.js";import{e as ae,_ as ne}from"./index-vflU_jNS.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-CeZEm9rq.js").then(M=>M.dP),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Dwh6tNxD.js";import{a4 as L}from"./tcm-BzzBMfBw.js";import{i as M,_ as S}from"./index-CLwe6ftX.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-CeZEm9rq.js";import{a5 as L}from"./tcm-BmNVoRI3.js";import{i as M,_ as S}from"./index-vflU_jNS.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.welcome-editor[data-v-1e5d0e03]{display:grid;grid-template-columns:minmax(0,1fr) 260px;align-items:start;gap:22px;width:100%}.welcome-editor__fields[data-v-1e5d0e03]{min-width:0}.text-tools[data-v-1e5d0e03]{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}.text-tools .el-button+.el-button[data-v-1e5d0e03]{margin-left:0}.emoji-grid[data-v-1e5d0e03]{display:grid;grid-template-columns:repeat(8,1fr);gap:4px}.emoji-grid button[data-v-1e5d0e03]{border:0;background:none;padding:4px;cursor:pointer;font-size:20px}.text-count[data-v-1e5d0e03]{text-align:right;font-size:12px;color:#909399;margin-top:4px}.text-count.is-error[data-v-1e5d0e03],.upload-error[data-v-1e5d0e03]{color:#d93026}.attachments-heading[data-v-1e5d0e03],.attachment-card__heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;gap:8px}.attachments-heading[data-v-1e5d0e03]{margin:16px 0 10px}.attachments-heading strong[data-v-1e5d0e03]{font-size:13px}.attachments-heading strong span[data-v-1e5d0e03]{color:#909399;font-weight:400}.attachment-empty[data-v-1e5d0e03]{padding:18px 12px;color:#909399;background:#f7f8fa;border:1px dashed #dcdfe6;border-radius:4px;font-size:12px}.attachment-card[data-v-1e5d0e03]{border:1px solid #e4e7ed;border-radius:5px;padding:12px;margin-top:10px}.attachment-card__heading[data-v-1e5d0e03]{margin-bottom:10px}.attachment-card__heading strong[data-v-1e5d0e03]{font-size:13px}.attachment-card__heading .el-button[data-v-1e5d0e03]{padding:4px;margin:0}.attachment-label[data-v-1e5d0e03]{display:block;font-size:12px;color:#606266;margin:10px 0 4px}.attachment-label span[data-v-1e5d0e03]{color:#909399;float:right}.upload-field[data-v-1e5d0e03]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:12px;overflow-wrap:anywhere}.mini-upload[data-v-1e5d0e03]{margin-top:12px}.asset-ready[data-v-1e5d0e03]{color:#178758}.muted[data-v-1e5d0e03]{color:#909399}.field-tip[data-v-1e5d0e03]{display:block;color:#909399;line-height:1.6;margin-top:6px}.upload-error[data-v-1e5d0e03]{font-size:12px;line-height:1.6;margin-top:6px}.file-input[data-v-1e5d0e03]{display:none}.welcome-preview[data-v-1e5d0e03]{width:260px;border:1px solid #dcdfe6;border-radius:20px;padding:7px;background:#fff;overflow:hidden}.phone-heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;padding:13px 12px;background:#ededed;border-radius:14px 14px 0 0;font-size:13px}.phone-heading>span[data-v-1e5d0e03]{font-size:19px}.phone-content[data-v-1e5d0e03]{min-height:330px;max-height:520px;overflow:auto;background:#ededed;padding:0 10px 18px}.preview-time[data-v-1e5d0e03]{font-size:10px;text-align:center;color:#999;padding:12px 0 18px}.chat-row[data-v-1e5d0e03]{display:flex;gap:7px;margin-bottom:12px;align-items:flex-start}.chat-avatar[data-v-1e5d0e03]{width:27px;height:27px;background:#6e92ae;color:#fff;flex-shrink:0;border-radius:4px;display:grid;place-items:center;font-size:11px}.chat-bubble[data-v-1e5d0e03]{background:#fff;padding:9px 10px;border-radius:4px;font-size:12px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:172px}.attachment-preview[data-v-1e5d0e03]{width:172px}.attachment-preview strong[data-v-1e5d0e03]{display:block;font-weight:500;font-size:12px}.attachment-preview p[data-v-1e5d0e03]{color:#909399;font-size:10px;margin:6px 0}.attachment-preview small[data-v-1e5d0e03]{display:block;font-size:9px;color:#909399;margin-top:7px}.attachment-preview img[data-v-1e5d0e03]{width:100%;max-height:160px;-o-object-fit:contain;object-fit:contain;display:block}.media-placeholder[data-v-1e5d0e03]{background:#f2f5f7;height:85px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:#909399;font-size:10px}.media-placeholder .el-icon[data-v-1e5d0e03],.file-icon[data-v-1e5d0e03]{font-size:28px;color:#8babc3}.preview-empty[data-v-1e5d0e03]{text-align:center;color:#aaa;font-size:12px;margin-top:100px}.phone-input[data-v-1e5d0e03]{display:flex;gap:10px;padding:9px;background:#f6f6f6;border-radius:0 0 14px 14px;align-items:center;color:#909399}.phone-input__blank[data-v-1e5d0e03]{flex:1;height:24px;border-radius:3px;background:#fff}.preview-note[data-v-1e5d0e03]{margin:10px 6px 6px;font-size:11px;color:#909399;line-height:1.6}@media (max-width: 850px){.welcome-editor[data-v-1e5d0e03]{grid-template-columns:1fr}.welcome-preview[data-v-1e5d0e03]{margin:8px auto 0}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-D8V2sQ39.js";import"./.pnpm-Dwh6tNxD.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-CWIJgHhb.js";import"./.pnpm-CeZEm9rq.js";import"./index-CxXw87QU.js";import"./index-vflU_jNS.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Dwh6tNxD.js";import{_ as L}from"./index-De13kk0O.js";import{i as V}from"./index-CLwe6ftX.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-CeZEm9rq.js";import{_ as L}from"./index-CxXw87QU.js";import{i as V}from"./index-vflU_jNS.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user