This commit is contained in:
Your Name
2026-04-21 09:49:44 +08:00
parent c9e0419895
commit c15c348b7a
454 changed files with 5391 additions and 25651 deletions
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\qywx;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\MsgArchiveLists;
use app\adminapi\lists\qywx\MsgSendTaskLists;
use app\adminapi\lists\qywx\MsgSessionLists;
use app\adminapi\logic\qywx\MessageLogic;
use app\adminapi\validate\qywx\MessageValidate;
use app\common\service\wechat\QywxMsgArchiveService;
use app\common\service\wechat\WechatWorkService;
use think\facade\Log;
/**
* 企业微信 员工↔客户 消息收发
*
* 接口一览:
* GET qywx.message/session_list 会话列表(左侧)
* GET qywx.message/archive_list 会话消息历史(中间)
* POST qywx.message/mark_read 清零会话未读
* POST qywx.message/send 创建企业群发(员工代发)任务
* GET qywx.message/send_task_list 群发任务列表
* GET qywx.message/send_task_detail 任务送达详情(会回调企微查询)
* GET qywx.message/staff_list 可代发员工(admin 表 work_wechat_userid 不空者)
* GET qywx.message/customer_of_staff 某员工已添加的客户
* POST qywx.message/upload_to_qywx 上传媒体文件到企微,返回 media_id(供附件使用)
* POST qywx.message/pull_archive 手动触发一次存档拉取(便于调试;cron 正常也会跑)
* GET qywx.message/archive_status 会话存档模块状态(SDK/私钥诊断)
*/
class MessageController extends BaseAdminController
{
public function session_list()
{
return $this->dataLists(new MsgSessionLists());
}
public function archive_list()
{
return $this->dataLists(new MsgArchiveLists());
}
public function mark_read()
{
$sessionId = (int) $this->request->post('session_id', 0);
if ($sessionId <= 0) {
return $this->fail('session_id 无效');
}
MessageLogic::markSessionRead($sessionId);
return $this->success('ok');
}
public function send()
{
$params = (new MessageValidate())->post()->goCheck('send');
$result = MessageLogic::createSendTask($params, $this->adminId);
if ($result === false) {
return $this->fail(MessageLogic::getError());
}
return $this->success('已提交,员工手机端确认后将发出', $result);
}
public function send_task_list()
{
return $this->dataLists(new MsgSendTaskLists());
}
public function send_task_detail()
{
$taskId = (int) $this->request->get('task_id', 0);
$cursor = (string) $this->request->get('cursor', '');
if ($taskId <= 0) {
return $this->fail('task_id 无效');
}
return $this->data(MessageLogic::querySendTaskResult($taskId, $cursor));
}
public function staff_list()
{
$keyword = trim((string) $this->request->get('keyword', ''));
return $this->data(MessageLogic::staffList($keyword));
}
public function customer_of_staff()
{
$staff = trim((string) $this->request->get('staff_userid', ''));
$keyword = trim((string) $this->request->get('keyword', ''));
$limit = (int) $this->request->get('limit', 200);
return $this->data(MessageLogic::customerOfStaff($staff, $keyword, $limit));
}
/**
* 上传素材到企业微信,返回 media_id(3 天有效)
*
* 入参:multipart 文件字段 filequery / form 字段 type = image|voice|video|file
*
* 使用场景:前端先上传本地文件到自家服务器(/upload/image 等)得到本地 URL 用于预览;
* 确认发送时再调本接口上传到企微拿 media_id,填入 add_msg_template 的 attachments。
*/
public function upload_to_qywx()
{
$type = (string) $this->request->param('type', 'image');
if (!in_array($type, ['image', 'voice', 'video', 'file'], true)) {
return $this->fail('不支持的媒体类型');
}
$file = $this->request->file('file');
if (!$file) {
return $this->fail('未上传文件');
}
try {
$tempDir = runtime_path() . 'qywx_upload_tmp' . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
@mkdir($tempDir, 0755, true);
}
$savePath = $tempDir . uniqid('qywx_', true) . '_' . $file->getOriginalName();
// @phpstan-ignore-next-line move returns File
move_uploaded_file($file->getPathname(), $savePath);
$service = new WechatWorkService('customer_contact');
$resp = $service->uploadMedia($type, $savePath, $file->getOriginalName());
@unlink($savePath);
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
if ($errcode !== 0 || empty($resp['media_id'])) {
Log::warning('企微上传素材失败: ' . json_encode($resp, JSON_UNESCAPED_UNICODE));
return $this->fail('企微上传失败: ' . ($resp['errmsg'] ?? '未知错误'));
}
return $this->success('ok', [
'media_id' => (string) $resp['media_id'],
'type' => $resp['type'] ?? $type,
'created_at' => $resp['created_at'] ?? time(),
]);
} catch (\Throwable $e) {
Log::error('upload_to_qywx 异常: ' . $e->getMessage());
return $this->fail($e->getMessage());
}
}
public function pull_archive()
{
$maxBatches = (int) $this->request->param('max_batches', 5);
$download = (bool) $this->request->param('download', false);
$pull = QywxMsgArchiveService::pullLoop(max(1, $maxBatches));
$media = null;
if ($download && $pull['enabled']) {
$media = QywxMsgArchiveService::downloadPendingMedia(200);
}
return $this->data([
'pull' => $pull,
'media' => $media,
]);
}
public function archive_status()
{
return $this->data(MessageLogic::archiveStatus());
}
}
@@ -27,6 +27,7 @@ class PrescriptionOrderController extends BaseAdminController
{
$params = (new PrescriptionOrderValidate())->get()->goCheck('paidPayOrders');
$exceptPo = (int) $this->request->get('prescription_order_id', 0);
$lists = OrderLogic::listPaidOrdersForDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMsgArchive;
use app\common\model\QywxMsgArchiveMedia;
/**
* 单会话消息历史
*
* 两种查询模式,二选一:
* 1) session_id 传 QywxMsgSession.id(推荐)
* 2) staff_userid + external_userid(单聊)/ roomid(群聊)
*
* 消息默认按 send_time 升序,便于前端从下往上追加。
* 支持 before_time / after_time 游标翻页。
*/
class MsgArchiveLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['roomid', 'msgtype'],
];
}
private function resolveScope(): array
{
$sessionId = (int) ($this->params['session_id'] ?? 0);
if ($sessionId > 0) {
$session = \app\common\model\QywxMsgSession::find($sessionId);
if ($session) {
return [
'staff_userid' => (string) $session['staff_userid'],
'external_userid' => (string) $session['external_userid'],
'roomid' => (string) $session['roomid'],
];
}
}
return [
'staff_userid' => trim((string) ($this->params['staff_userid'] ?? '')),
'external_userid' => trim((string) ($this->params['external_userid'] ?? '')),
'roomid' => trim((string) ($this->params['roomid'] ?? '')),
];
}
private function baseQuery()
{
$scope = $this->resolveScope();
$query = QywxMsgArchive::where($this->searchWhere);
if ($scope['roomid'] !== '') {
$query->where('roomid', $scope['roomid']);
} else {
// 单聊:两端顺序不定,from_user/tolist 都可能为员工或客户
$staff = $scope['staff_userid'];
$external = $scope['external_userid'];
if ($staff === '' && $external === '') {
$query->where('id', 0);
} else {
$query->where('roomid', '');
if ($staff !== '' && $external !== '') {
$query->where(function ($q) use ($staff, $external) {
$q->where(function ($sub) use ($staff, $external) {
$sub->where('from_user', $staff)->whereLike('to_list', '%"' . $external . '"%');
})->whereOr(function ($sub) use ($staff, $external) {
$sub->where('from_user', $external)->whereLike('to_list', '%"' . $staff . '"%');
});
});
} elseif ($staff !== '') {
$query->where(function ($q) use ($staff) {
$q->where('from_user', $staff)->whereOr('to_list', 'like', '%"' . $staff . '"%');
});
} else {
$query->where(function ($q) use ($external) {
$q->where('from_user', $external)->whereOr('to_list', 'like', '%"' . $external . '"%');
});
}
}
}
if (!empty($this->params['before_time'])) {
$query->where('send_time', '<', (int) $this->params['before_time']);
}
if (!empty($this->params['after_time'])) {
$query->where('send_time', '>', (int) $this->params['after_time']);
}
return $query;
}
public function lists(): array
{
$rows = $this->baseQuery()
->order('send_time', 'desc')
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
if (empty($rows)) {
return [];
}
$rows = array_reverse($rows); // 前端按时间升序渲染
$users = [];
foreach ($rows as $r) {
$users[$r['from_user']] = true;
$toList = $r['to_list'];
if (is_array($toList)) {
foreach ($toList as $to) {
$users[$to] = true;
}
}
}
$users = array_keys(array_filter($users, fn ($_, $k) => $k !== '', ARRAY_FILTER_USE_BOTH));
$adminMap = [];
$extMap = [];
if ($users) {
$adminMap = Admin::whereIn('work_wechat_userid', $users)
->column('id,name,avatar,work_wechat_userid', 'work_wechat_userid');
$extMap = QywxExternalContact::whereIn('external_userid', $users)
->column('external_userid,name,avatar,type,unionid', 'external_userid');
}
$msgIds = array_column($rows, 'msgid');
$mediaMap = [];
if ($msgIds) {
$mediaList = QywxMsgArchiveMedia::whereIn('msgid', $msgIds)->select()->toArray();
foreach ($mediaList as $m) {
$mediaMap[$m['msgid']][] = $m;
}
}
foreach ($rows as &$r) {
$fromId = (string) $r['from_user'];
$r['from_is_staff'] = isset($adminMap[$fromId]);
$r['from_profile'] = $adminMap[$fromId] ?? ($extMap[$fromId] ?? null);
$r['media'] = $mediaMap[$r['msgid']] ?? [];
}
unset($r);
return $rows;
}
public function count(): int
{
return $this->baseQuery()->count();
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\QywxMsgSendTask;
class MsgSendTaskLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['sender_userid', 'status', 'admin_id'],
];
}
public function lists(): array
{
return QywxMsgSendTask::where($this->searchWhere)
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
}
public function count(): int
{
return QywxMsgSendTask::where($this->searchWhere)->count();
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\qywx;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMsgSession;
/**
* 企微消息会话列表
*
* 查询参数:
* staff_userid - 过滤某员工的会话(支持传 admin_id 反查)
* external_userid- 过滤某客户
* keyword - 按客户名 / 摘要 模糊搜索
* only_unread - 仅未读
*/
class MsgSessionLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['staff_userid', 'external_userid', 'roomid', 'session_type'],
];
}
private function baseQuery()
{
$query = QywxMsgSession::where($this->searchWhere);
$adminId = (int) ($this->params['admin_id'] ?? 0);
if ($adminId > 0) {
$wxId = Admin::where('id', $adminId)->value('work_wechat_userid');
if ($wxId) {
$query->where('staff_userid', $wxId);
} else {
// 找不到对应企微 userid 时不返回任何会话
$query->where('id', 0);
}
}
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$kw = addcslashes($keyword, '%_\\');
$extIds = QywxExternalContact::whereLike('name', '%' . $kw . '%')->column('external_userid');
$query->where(function ($q) use ($kw, $extIds) {
$q->whereLike('last_msg_summary', '%' . $kw . '%');
if ($extIds) {
$q->whereOr('external_userid', 'in', $extIds);
}
});
}
if (!empty($this->params['only_unread'])) {
$query->where('unread_staff', '>', 0);
}
return $query->order('last_msg_time', 'desc');
}
public function lists(): array
{
$rows = $this->baseQuery()
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
if (empty($rows)) {
return [];
}
$extIds = array_values(array_unique(array_filter(array_map(
fn ($r) => (string) ($r['external_userid'] ?? ''),
$rows
))));
$extMap = [];
if ($extIds) {
$extMap = QywxExternalContact::whereIn('external_userid', $extIds)
->column('name,avatar,type,gender,corp_name,unionid', 'external_userid');
}
$staffIds = array_values(array_unique(array_filter(array_map(
fn ($r) => (string) ($r['staff_userid'] ?? ''),
$rows
))));
$staffMap = [];
if ($staffIds) {
$staffMap = Admin::whereIn('work_wechat_userid', $staffIds)
->column('id,name,avatar', 'work_wechat_userid');
}
foreach ($rows as &$r) {
$r['customer'] = $extMap[$r['external_userid']] ?? null;
$r['staff'] = $staffMap[$r['staff_userid']] ?? null;
}
unset($r);
return $rows;
}
public function count(): int
{
return $this->baseQuery()->count();
}
}
+64 -2
View File
@@ -924,9 +924,47 @@ class OrderLogic
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
}
/**
* 业务订单可关联支付单列表:仅展示创建时间在此日 0 点及之后的支付单(编辑时当前单已关联的旧单仍保留在列表中)
*/
private static function payOrderLinkableMinCreateTs(): int
{
return strtotime('2026-04-20 00:00:00');
}
/**
* zyt_order.create_time 可能为 int 时间戳或 datetime 字符串,不可直接 (int) 强转(字符串会得到 0)
*/
private static function orderCreateTimeToUnix(mixed $value): int
{
if ($value === null || $value === '') {
return 0;
}
if ($value instanceof \DateTimeInterface) {
return $value->getTimestamp();
}
if (is_int($value)) {
return $value;
}
if (is_float($value)) {
return (int) $value;
}
if (is_string($value)) {
if (is_numeric($value) && strlen($value) <= 12) {
return (int) $value;
}
$ts = strtotime($value);
return $ts !== false ? $ts : 0;
}
return 0;
}
/**
* 某诊单下可关联的支付单(待支付/已支付),供关联业务订单多选(主管/诊单医助看该诊单全部;否则仅本人创建)
* 已占用(关联到未撤回/未取消的业务订单)的支付单会排除;编辑某业务订单时传 $exceptPrescriptionOrderId 以保留当前单已选中的项
* 列表仅含 2026-04-20 及之后创建的支付单(已关联到当前编辑业务订单的除外)
*
* @return array<int, array<string, mixed>>
*/
@@ -974,6 +1012,7 @@ class OrderLogic
);
}
$minCreateTs = self::payOrderLinkableMinCreateTs();
$out = [];
foreach ($rows as $row) {
$oid = (int) ($row['id'] ?? 0);
@@ -985,6 +1024,10 @@ class OrderLogic
if ($busy && ! $allowed) {
continue;
}
$ct = self::orderCreateTimeToUnix($row['create_time'] ?? null);
if ($ct < $minCreateTs && ! $allowed) {
continue;
}
$out[] = $row;
}
@@ -994,11 +1037,17 @@ class OrderLogic
/**
* 校验支付单均可关联到指定诊单(已支付、归属与权限)
* 默认仅允许关联 2026-04-20 及之后创建的支付单;编辑业务订单时传入 $prescriptionOrderIdForDateGrace,已关联到该业务订单的旧单除外
*
* @param int[] $ids zyt_order.id
*/
public static function assertPayOrdersLinkable(array $ids, int $diagnosisId, int $adminId, array $adminInfo): ?string
{
public static function assertPayOrdersLinkable(
array $ids,
int $diagnosisId,
int $adminId,
array $adminInfo,
?int $prescriptionOrderIdForDateGrace = null
): ?string {
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $v): bool => $v > 0)));
if ($diagnosisId <= 0) {
return '诊单无效';
@@ -1021,6 +1070,7 @@ class OrderLogic
return '部分支付单不存在或已删除';
}
$minTs = self::payOrderLinkableMinCreateTs();
foreach ($orders as $o) {
if ((int) $o->patient_id !== $diagnosisId) {
return '支付单与诊单不匹配';
@@ -1031,6 +1081,18 @@ class OrderLogic
if (! $supervisor && ! $isDiagAssistant && (int) $o->creator_id !== $adminId) {
return '无权限关联所选支付单';
}
$ct = self::orderCreateTimeToUnix($o->create_time ?? null);
if ($ct < $minTs) {
$grace = false;
if ($prescriptionOrderIdForDateGrace !== null && $prescriptionOrderIdForDateGrace > 0) {
$grace = PrescriptionOrderPayOrder::where('prescription_order_id', $prescriptionOrderIdForDateGrace)
->where('pay_order_id', (int) $o->id)
->count() > 0;
}
if (! $grace) {
return '仅可关联 2026年4月20日及之后创建的支付单';
}
}
}
return null;
@@ -595,9 +595,36 @@ class CustomerLogic extends BaseLogic
}
$service = new WechatWorkService();
$detail = $service->getExternalContactDetail($externalUserId);
$detail = [];
$lastError = null;
// 接口偶发抖动:最多重试 1 次(共 2 次请求),间隔 1.2s。仅对「非预期错误 / 空响应」重试,
// 业务预期错误(如 84061 客户未通过、60011/60111 无权限、41068 非法 ID)不重试,避免无谓压力。
$maxAttempts = 2;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$err = null;
$detail = $service->getExternalContactDetail($externalUserId, $err);
if (!empty($detail['external_contact']) && is_array($detail['external_contact'])) {
break;
}
$lastError = $err;
$errcode = is_array($err) ? ($err['errcode'] ?? null) : null;
$shouldRetry = $attempt < $maxAttempts
&& !in_array($errcode, [84061, 41068, 60011, 60111], true);
if (!$shouldRetry) {
break;
}
usleep(1_200_000);
}
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
Log::warning('qywx external contact callback: 拉取详情为空', ['external_userid' => $externalUserId]);
$errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null;
$errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : '';
Log::warning(sprintf(
'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s',
$errcode === null ? '?' : (string) $errcode,
$errmsg !== '' ? $errmsg : '-',
$externalUserId
));
return;
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\qywx;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMsgSendTask;
use app\common\model\QywxMsgSession;
use app\common\service\wechat\WeComFinanceSdkClient;
use app\common\service\wechat\WechatWorkService;
use think\facade\Log;
/**
* 企业微信消息业务逻辑
*
* - 员工代发消息:企业群发 add_msg_template(客户端显示为员工本人,员工手机端需确认)
* - 消息历史:来自会话内容存档落库(见 QywxMsgArchiveService
* - 本类只封装"创建任务 + 查询送达 + 辅助接口",不处理拉取
*/
class MessageLogic extends BaseLogic
{
/**
* 创建企业群发任务
*
* @param array{sender_userid:string, external_userids:string[], msg_payload:array, chat_type?:string} $params
* @param int $adminId 后台发起人
*
* @return array{task_id:int, msg_template_id:string, fail_list: array}|false
*/
public static function createSendTask(array $params, int $adminId)
{
$chatType = (string) ($params['chat_type'] ?? 'single');
$sender = (string) $params['sender_userid'];
$externalIds = array_values(array_filter(array_map('strval', $params['external_userids'] ?? [])));
$payload = (array) $params['msg_payload'];
if ($externalIds === []) {
self::setError('请至少选择 1 位客户');
return false;
}
$body = self::buildAddMsgTemplateBody($chatType, $sender, $externalIds, $payload);
if ($body === null) {
// setError 已在 buildAddMsgTemplateBody 内调
return false;
}
$task = QywxMsgSendTask::create([
'admin_id' => $adminId,
'sender_userid' => $sender,
'external_userids' => $externalIds,
'chat_type' => $chatType === 'group' ? 2 : 1,
'msg_payload' => $payload,
'status' => QywxMsgSendTask::STATUS_PENDING,
'create_time' => time(),
'update_time' => time(),
]);
try {
$service = new WechatWorkService('customer_contact');
$resp = $service->addMsgTemplate($body);
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
$errmsg = (string) ($resp['errmsg'] ?? '');
$msgid = (string) ($resp['msgid'] ?? '');
$failList = isset($resp['fail_list']) && is_array($resp['fail_list']) ? $resp['fail_list'] : [];
if ($errcode !== 0 || $msgid === '') {
QywxMsgSendTask::where('id', $task['id'])->update([
'status' => QywxMsgSendTask::STATUS_FAILED,
'error' => sprintf('errcode=%d errmsg=%s', $errcode, $errmsg),
'fail_list' => $failList,
'update_time' => time(),
]);
self::setError(sprintf('企微群发失败: [%d] %s', $errcode, $errmsg ?: '未知错误'));
return false;
}
QywxMsgSendTask::where('id', $task['id'])->update([
'status' => QywxMsgSendTask::STATUS_SUBMITTED,
'msg_template_id' => $msgid,
'fail_list' => $failList,
'update_time' => time(),
]);
return [
'task_id' => (int) $task['id'],
'msg_template_id' => $msgid,
'fail_list' => $failList,
];
} catch (\Throwable $e) {
Log::error('企微群发创建异常: ' . $e->getMessage());
QywxMsgSendTask::where('id', $task['id'])->update([
'status' => QywxMsgSendTask::STATUS_FAILED,
'error' => mb_substr('exception: ' . $e->getMessage(), 0, 500),
'update_time' => time(),
]);
self::setError($e->getMessage());
return false;
}
}
/**
* 查询群发任务送达结果(企业微信返回每客户的确认/送达状态)
*
* @return array<string, mixed>
*/
public static function querySendTaskResult(int $taskId, string $cursor = ''): array
{
$task = QywxMsgSendTask::find($taskId);
if (!$task || $task['msg_template_id'] === '') {
return ['detail_list' => [], 'next_cursor' => '', 'task' => $task];
}
$service = new WechatWorkService('customer_contact');
$resp = $service->getGroupMsgSendResult($task['msg_template_id'], $task['sender_userid'], $cursor, 500);
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
if ($errcode !== 0) {
return [
'detail_list' => [],
'next_cursor' => '',
'task' => $task,
'errcode' => $errcode,
'errmsg' => (string) ($resp['errmsg'] ?? ''),
];
}
$detailList = $resp['detail_list'] ?? [];
$nextCursor = (string) ($resp['next_cursor'] ?? '');
if (is_array($detailList) && !empty($detailList)) {
$sentCount = 0;
foreach ($detailList as $d) {
if (($d['status'] ?? 0) == 1) { // 1=已送达
$sentCount++;
}
}
if ($sentCount > 0 && $task['status'] == QywxMsgSendTask::STATUS_SUBMITTED) {
QywxMsgSendTask::where('id', $task['id'])->update([
'status' => QywxMsgSendTask::STATUS_SENT,
'update_time' => time(),
]);
}
}
return [
'detail_list' => $detailList,
'next_cursor' => $nextCursor,
'task' => $task->refresh(),
];
}
/**
* 可代发员工列表(只返回已绑定企微 userid 的 admin
*
* @return array<int, array<string, mixed>>
*/
public static function staffList(string $keyword = ''): array
{
$query = Admin::where('work_wechat_userid', '<>', '')
->whereNotNull('work_wechat_userid');
if ($keyword !== '') {
$kw = addcslashes($keyword, '%_\\');
$query->where(function ($q) use ($kw) {
$q->whereLike('name', '%' . $kw . '%')
->whereOr('work_wechat_userid', 'like', '%' . $kw . '%');
});
}
return $query->field('id,name,avatar,work_wechat_userid as userid,department')
->limit(200)
->order('id', 'asc')
->select()
->toArray();
}
/**
* 某员工的已添加客户列表(基于 follow_users JSON 查询)
*
* @return array<int, array<string, mixed>>
*/
public static function customerOfStaff(string $staffUserid, string $keyword = '', int $limit = 200): array
{
if ($staffUserid === '') {
return [];
}
$query = QywxExternalContact::whereLike('follow_users', '%"userid":"' . $staffUserid . '"%');
if ($keyword !== '') {
$kw = addcslashes($keyword, '%_\\');
$query->whereLike('name', '%' . $kw . '%');
}
return $query->field('external_userid,name,avatar,type,gender,corp_name,unionid')
->limit($limit)
->order('id', 'desc')
->select()
->toArray();
}
public static function markSessionRead(int $sessionId): bool
{
$affected = QywxMsgSession::where('id', $sessionId)->update([
'unread_staff' => 0,
'update_time' => time(),
]);
return $affected >= 0;
}
/**
* 会话存档模块状态
*/
public static function archiveStatus(): array
{
$client = new WeComFinanceSdkClient();
return [
'enabled' => (bool) config('pay.wechat_work.msgaudit_enabled', false),
'available' => $client->isAvailable(),
'error' => $client->getLastError(),
'lib_path' => (string) config('pay.wechat_work.msgaudit_sdk_lib_path', ''),
'public_key_ver' => (int) config('pay.wechat_work.msgaudit_public_key_ver', 0),
'has_private_key' => self::hasPrivateKeyConfigured(),
];
}
private static function hasPrivateKeyConfigured(): bool
{
$path = (string) config('pay.wechat_work.msgaudit_private_key_path', '');
if ($path !== '' && is_file($path)) {
return true;
}
return ((string) config('pay.wechat_work.msgaudit_private_key', '')) !== '';
}
/**
* 构建 add_msg_template 请求体
*
* msg_payload 标准形:
* {
* "text": { "content": "你好" },
* "attachments": [
* { "msgtype":"image", "image":{"media_id":"xxx"} },
* { "msgtype":"video", "video":{"media_id":"xxx"} },
* { "msgtype":"file", "file":{"media_id":"xxx"} },
* { "msgtype":"link", "link":{"title":"...","picurl":"...","desc":"...","url":"https://..."} },
* { "msgtype":"miniprogram", "miniprogram":{"title":"...","pic_media_id":"...","appid":"wx...","page":"/pages/x/x"} }
* ]
* }
*
* @return array<string, mixed>|null
*/
private static function buildAddMsgTemplateBody(
string $chatType,
string $sender,
array $externalIds,
array $payload
): ?array {
$text = $payload['text'] ?? null;
$attachments = $payload['attachments'] ?? null;
if (($text === null || !isset($text['content']) || trim((string) $text['content']) === '')
&& empty($attachments)) {
self::setError('消息内容不能为空:至少填写文本或添加 1 个附件');
return null;
}
if (is_array($attachments) && count($attachments) > 9) {
self::setError('最多只能添加 9 个附件');
return null;
}
$body = [
'chat_type' => $chatType === 'group' ? 'group' : 'single',
'external_userid' => array_values($externalIds),
'sender' => $sender,
];
if (is_array($text) && trim((string) ($text['content'] ?? '')) !== '') {
$body['text'] = ['content' => (string) $text['content']];
}
if (is_array($attachments) && $attachments !== []) {
$body['attachments'] = array_values(array_filter($attachments, 'is_array'));
}
return $body;
}
}
@@ -680,6 +680,17 @@ class PrescriptionOrderLogic
return false;
}
// 已成功提交甘草 SCM(生成了甘草处方单号 或 submit_time>0),再改本地信息会与甘草侧不一致,禁止编辑
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
$gcSubmitTime = (int) $order->gancao_submit_time;
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
self::$error = sprintf(
'订单已提交甘草(处方单号:%s),不可再编辑;如需修改请先走取消流程',
$gcOrderNo !== '' ? $gcOrderNo : '—'
);
return false;
}
$medDays = $params['medication_days'] ?? null;
$medDays = $medDays === '' || $medDays === null ? null : (int) $medDays;
@@ -727,7 +738,7 @@ class PrescriptionOrderLogic
$payOrderIds = array_values(array_intersect($payOrderIds, $existingIds));
}
$linkErr = OrderLogic::assertPayOrdersLinkable($payOrderIds, $diagId, $adminId, $adminInfo);
$linkErr = OrderLogic::assertPayOrdersLinkable($payOrderIds, $diagId, $adminId, $adminInfo, (int) $order->id);
if ($linkErr !== null) {
self::$error = $linkErr;
@@ -748,7 +759,7 @@ class PrescriptionOrderLogic
$order->linked_pay_order_id = null;
} else {
$single = [(int) $lp];
$linkErr = OrderLogic::assertPayOrdersLinkable($single, $diagId, $adminId, $adminInfo);
$linkErr = OrderLogic::assertPayOrdersLinkable($single, $diagId, $adminId, $adminInfo, (int) $order->id);
if ($linkErr !== null) {
self::$error = $linkErr;
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\qywx;
use app\common\validate\BaseValidate;
class MessageValidate extends BaseValidate
{
protected $rule = [
'sender_userid' => 'require',
'external_userids' => 'require|array|min:1',
'msg_payload' => 'require|array',
'chat_type' => 'in:single,group',
];
protected $message = [
'sender_userid.require' => '请选择代发员工',
'external_userids.require' => '请选择至少一位客户',
'external_userids.array' => '客户列表格式错误',
'external_userids.min' => '至少选择 1 位客户',
'msg_payload.require' => '请填写消息内容',
'msg_payload.array' => '消息内容格式错误',
'chat_type.in' => '会话类型不合法',
];
public function sceneSend()
{
return $this->only(['sender_userid', 'external_userids', 'msg_payload', 'chat_type']);
}
public function sceneMarkRead()
{
return $this->only(['session_id'])->append('session_id', 'require|integer|>:0');
}
}
@@ -14,6 +14,15 @@ use think\facade\Log;
* 企业微信「客户联系」事件回调(接收事件服务器)
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*
* ⚠️ 关于"员工↔客户消息内容"接收:
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
* 这里只处理"添加 / 编辑 / 删除客户"等业务事件(change_external_contact 变体)。
* 实时消息接收走「会话内容存档」独立通道:
* - 命令: php think qywx:sync-msg-archive
* - 服务: app\common\service\wechat\QywxMsgArchiveService
* - SDK: app\common\service\wechat\WeComFinanceSdkClient (FFI 调 libWeWorkFinanceSdk_C.so)
* 配置项:pay.wechat_work.msgaudit_*。
*/
class QywxExternalContactCallbackController extends BaseApiController
{
@@ -23,12 +32,16 @@ class QywxExternalContactCallbackController extends BaseApiController
public function notify()
{
$corpId = (string) config('pay.wechat_work.corp_id', '');
$secret = (string) config('pay.wechat_work.external_pay_secret', '');
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
$secret = $customerSecret !== '' ? $customerSecret : $payContactSecret;
$token = (string) config('pay.wechat_work.contact_callback_token', '');
$aesKey = (string) config('pay.wechat_work.contact_callback_aes_key', '');
if ($corpId === '' || $secret === '' || $token === '' || $aesKey === '') {
Log::error('qywx external contact callback: 缺少配置 corp_id / external_pay_secret / contact_callback_token / contact_callback_aes_key');
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或 external_pay_secret) / contact_callback_token / contact_callback_aes_key');
return response('config error', 503, ['Content-Type' => 'text/plain; charset=utf-8']);
}
@@ -84,14 +97,27 @@ class QywxExternalContactCallbackController extends BaseApiController
{
$changeType = (string) ($message['ChangeType'] ?? '');
$extId = trim((string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''));
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? ''));
$state = (string) ($message['State'] ?? '');
$welcomeCode = (string) ($message['WelcomeCode'] ?? '');
$failReason = (string) ($message['FailReason'] ?? '');
if ($extId === '') {
Log::info('qywx external contact callback: 无 ExternalUserID', ['ChangeType' => $changeType]);
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
return;
}
Log::info('qywx external contact callback', ['ChangeType' => $changeType, 'ExternalUserID' => $extId]);
// 关键字段直接拼到消息里,便于在 ThinkPHP file 日志格式下一眼定位 ChangeType 分布
Log::info(sprintf(
'qywx external contact callback type=%s user=%s ext=%s state=%s welcome=%s fail=%s',
$changeType !== '' ? $changeType : '-',
$userId !== '' ? $userId : '-',
$extId,
$state !== '' ? $state : '-',
$welcomeCode !== '' ? '***' : '-',
$failReason !== '' ? $failReason : '-'
));
if ($changeType === 'del_external_contact') {
CustomerLogic::softDeleteExternalContactRow($extId);
@@ -99,7 +125,15 @@ class QywxExternalContactCallbackController extends BaseApiController
return;
}
// 其余变更(添加/编辑/删跟进人等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
if ($changeType === 'add_half_external_contact') {
// 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类,
// 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。
Log::info(sprintf('qywx external contact callback: half add,跳过落库 ext=%s user=%s', $extId, $userId));
return;
}
// 其余变更(添加/编辑/删跟进人/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\wechat\QywxMsgArchiveService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Log;
/**
* 企业微信会话内容存档同步
*
* 使用方法:
* php think qywx:sync-msg-archive # 只拉消息
* php think qywx:sync-msg-archive --download # 拉完后同时下载 pending 媒体
* php think qywx:sync-msg-archive --only-media # 仅下载已登记的 pending 媒体
*
* crontab 建议:每 30 秒拉一次(用 sleep 模拟半分钟级)
* * * * * * cd /path/to/project && php think qywx:sync-msg-archive --download >> /dev/null 2>&1
* * * * * * cd /path/to/project && sleep 30 && php think qywx:sync-msg-archive --download >> /dev/null 2>&1
*
* 并发控制:通过文件锁,同一时刻只会跑一个,多开直接退出。
*/
class QywxSyncMsgArchive extends Command
{
protected function configure()
{
$this->setName('qywx:sync-msg-archive')
->addOption('download', 'd', Option::VALUE_NONE, '拉完消息后顺便下载 pending 媒体')
->addOption('only-media', null, Option::VALUE_NONE, '仅下载已登记的 pending 媒体')
->addOption('max-batches', 'b', Option::VALUE_REQUIRED, '单次运行最多拉多少批 (默认 20)', 20)
->addOption('max-media', 'm', Option::VALUE_REQUIRED, '单次最多下载多少条媒体 (默认 200)', 200)
->setDescription('拉取企业微信会话内容存档');
}
protected function execute(Input $input, Output $output)
{
$lockFile = runtime_path() . 'lock' . DIRECTORY_SEPARATOR . 'qywx_msg_archive.lock';
$lockDir = dirname($lockFile);
if (!is_dir($lockDir)) {
@mkdir($lockDir, 0755, true);
}
$fp = @fopen($lockFile, 'c');
if ($fp === false) {
$output->writeln('<error>无法创建锁文件: ' . $lockFile . '</error>');
return 1;
}
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
$output->writeln('另一个会话存档同步进程正在运行,跳过本次。');
return 0;
}
try {
$onlyMedia = (bool) $input->getOption('only-media');
$download = (bool) $input->getOption('download');
$maxBatches = max(1, (int) $input->getOption('max-batches'));
$maxMedia = max(1, (int) $input->getOption('max-media'));
if (!$onlyMedia) {
$pull = QywxMsgArchiveService::pullLoop($maxBatches);
if (!$pull['enabled']) {
$output->writeln('<comment>会话存档未启用或 SDK 不可用:' . implode('; ', $pull['errors']) . '</comment>');
return 0;
}
$output->writeln(sprintf(
'消息拉取:batches=%d pulled=%d inserted=%d updated=%d media_pending=%d last_seq=%d',
$pull['batches'],
$pull['pulled'],
$pull['inserted'],
$pull['updated'],
$pull['media_pending'],
$pull['last_seq']
));
if (!empty($pull['errors'])) {
foreach ($pull['errors'] as $err) {
Log::warning('qywx:sync-msg-archive pull error: ' . $err);
$output->writeln('<comment>error: ' . $err . '</comment>');
}
}
}
if ($onlyMedia || $download) {
$media = QywxMsgArchiveService::downloadPendingMedia($maxMedia);
if (!$media['enabled']) {
$output->writeln('<comment>媒体下载-SDK 未启用</comment>');
} else {
$output->writeln(sprintf(
'媒体下载:ok=%d failed=%d skipped=%d',
$media['ok'],
$media['failed'],
$media['skipped']
));
}
}
return 0;
} catch (\Throwable $e) {
$output->writeln('<error>同步异常: ' . $e->getMessage() . '</error>');
Log::error('qywx:sync-msg-archive 异常: ' . $e->getMessage());
return 1;
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace app\common\model;
/**
* 企业微信会话存档消息
*
* 数据来源为「会话内容存档」SDK 拉取后解密落库,msgid 官方唯一。
* 消息体字段根据 msgtype 有不同含义,统一用 content(摘要/文本)+ raw(原始 JSON)组合存储,
* 具体渲染放到业务层(前端或 Logic)再按需展开。
*/
class QywxMsgArchive extends BaseModel
{
protected $name = 'qywx_msg_archive';
protected $autoWriteTimestamp = 'int';
protected $type = [
'send_time' => 'integer',
'seq' => 'integer',
'file_size' => 'integer',
'play_length' => 'integer',
];
/** JSON 反序列化 to_list 访问器,便于业务层直接拿数组 */
public function getToListAttr($value): array
{
if ($value === null || $value === '') {
return [];
}
if (is_array($value)) {
return $value;
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : [];
}
public function setToListAttr($value): string
{
if (is_string($value)) {
return $value;
}
return json_encode($value ?? [], JSON_UNESCAPED_UNICODE);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace app\common\model;
class QywxMsgArchiveCursor extends BaseModel
{
protected $name = 'qywx_msg_archive_cursor';
protected $autoWriteTimestamp = 'int';
public const DEFAULT_KEY = 'global';
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace app\common\model;
class QywxMsgArchiveMedia extends BaseModel
{
protected $name = 'qywx_msg_archive_media';
protected $autoWriteTimestamp = 'int';
public const STATUS_PENDING = 0;
public const STATUS_SUCCESS = 1;
public const STATUS_FAILED = 2;
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace app\common\model;
/**
* 员工代发消息(企业群发)任务
*
* 企业群发 add_msg_template 的特性:
* - 必须以「员工身份」为 sender 代发,客户端显示为员工本人
* - 员工手机端会弹一次"发送确认",员工点确认后才真正送达客户;admin 后台无法强制送达
* - 返回的 msgid 可通过 get_group_msg_send_result 查询确认 / 送达结果
*
* 状态:
* 0=待提交、1=已提交等待员工确认、2=员工已确认发送、3=失败
*/
class QywxMsgSendTask extends BaseModel
{
protected $name = 'qywx_msg_send_task';
protected $autoWriteTimestamp = 'int';
public const STATUS_PENDING = 0;
public const STATUS_SUBMITTED = 1;
public const STATUS_SENT = 2;
public const STATUS_FAILED = 3;
public function getExternalUseridsAttr($value): array
{
if ($value === null || $value === '') {
return [];
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : [];
}
public function setExternalUseridsAttr($value): string
{
if (is_string($value)) {
return $value;
}
return json_encode($value ?? [], JSON_UNESCAPED_UNICODE);
}
public function getMsgPayloadAttr($value)
{
if ($value === null || $value === '') {
return [];
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : [];
}
public function setMsgPayloadAttr($value): string
{
if (is_string($value)) {
return $value;
}
return json_encode($value ?? [], JSON_UNESCAPED_UNICODE);
}
public function getFailListAttr($value)
{
if ($value === null || $value === '') {
return [];
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : [];
}
public function setFailListAttr($value): string
{
if (is_string($value)) {
return $value;
}
return json_encode($value ?? [], JSON_UNESCAPED_UNICODE);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\common\model;
/**
* 企业微信 员工×客户 会话索引(用于 admin 聊天页左侧会话列表)
*/
class QywxMsgSession extends BaseModel
{
protected $name = 'qywx_msg_session';
protected $autoWriteTimestamp = 'int';
}
@@ -62,14 +62,18 @@ final class GancaoOpenApiTransport
'Expect:',
];
// cURL 常量在部分精简构建里可能未注册,统一用 defined() 做优雅降级,值取自官方枚举
$httpVer11 = defined('CURL_HTTP_VERSION_1_1') ? CURL_HTTP_VERSION_1_1 : 2;
$ipv4Only = defined('CURL_IPRESOLVE_V4') ? CURL_IPRESOLVE_V4 : 1;
$ch = curl_init($this->url);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); // 改用 HTTP/1.1
curl_setopt($ch, CURLOPT_HTTP_VERSION, $httpVer11); // 改用 HTTP/1.1
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_ENCODING, 'gzip');
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
curl_setopt($ch, CURLOPT_IPRESOLVE, $ipv4Only);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 增加连接超时
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_POSTFIELDS, $cipher);
@@ -77,14 +81,25 @@ final class GancaoOpenApiTransport
if (str_starts_with($this->url, 'https:')) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 改为 0,完全禁用主机验证
// 添加 SSL 相关选项
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2); // 强制使用 TLS 1.2
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); // 降低安全级别
// 强制 TLS 1.2;常量值为 6,但部分 PHP/cURL 构建未注册该常量,运行时未定义时退化为默认 TLS
$tls12 = defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6;
curl_setopt($ch, CURLOPT_SSLVERSION, $tls12);
// 可选 cipher list。默认不设(交给 cURL 自带默认,兼容性最好)。
// 需要兼容弱加密服务器时,在 config/gancao_scm.php 或 .env 里设:
// GANCAO_SCM_TLS_CIPHERS="DEFAULT@SECLEVEL=1" // 仅 OpenSSL 构建的 cURL 支持此语法
// GANCAO_SCM_TLS_CIPHERS="DEFAULT:!aNULL:!eNULL" // 通用写法
// cURL 是 LibreSSL/NSS/GnuTLS/BoringSSL 时,@SECLEVEL= 会直接报 CURLE_SSL_CIPHER(59)。
$cipherList = trim((string) \think\facade\Config::get('gancao_scm.tls_ciphers', ''));
if ($cipherList !== '') {
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, $cipherList);
}
}
// 添加 TCP keepalive(部分旧 libcurl 可能没注册 CURLOPT_TCP_KEEPALIVE,守一下)
if (defined('CURLOPT_TCP_KEEPALIVE')) {
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
}
// 添加 TCP keepalive
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
$raw = curl_exec($ch);
$info = curl_getinfo($ch);
@@ -0,0 +1,516 @@
<?php
declare(strict_types=1);
namespace app\common\service\wechat;
use app\common\model\QywxMsgArchive;
use app\common\model\QywxMsgArchiveCursor;
use app\common\model\QywxMsgArchiveMedia;
use app\common\model\QywxMsgSession;
use think\facade\Db;
use think\facade\Log;
/**
* 会话存档拉取与落库
*
* 工作流程:
* 1) 读取游标 seq
* 2) 调用 WeComFinanceSdkClient->pullChatData(seq)
* 3) 遍历解密后的消息,按 msgtype 归一化字段后 UPSERT qywx_msg_archive
* 4) 同步刷新 qywx_msg_session 列表摘要
* 5) 媒体消息登记 qywx_msg_archive_media(待下载)
* 6) 更新 cursor
*
* 媒体下载由 downloadPendingMedia() 独立跑,避免拉取慢。
*/
class QywxMsgArchiveService
{
private const MEDIA_TYPES = ['image', 'voice', 'video', 'file', 'emotion'];
/**
* 拉一次,直到本批为空或达到 maxBatches
*
* @return array{
* enabled: bool,
* batches: int,
* pulled: int,
* inserted: int,
* updated: int,
* media_pending: int,
* last_seq: int,
* errors: string[]
* }
*/
public static function pullLoop(int $maxBatches = 20, string $cursorKey = QywxMsgArchiveCursor::DEFAULT_KEY): array
{
$result = [
'enabled' => true,
'batches' => 0,
'pulled' => 0,
'inserted' => 0,
'updated' => 0,
'media_pending' => 0,
'last_seq' => 0,
'errors' => [],
];
$sdk = new WeComFinanceSdkClient();
if (!$sdk->isAvailable()) {
$result['enabled'] = false;
$result['errors'][] = $sdk->getLastError();
return $result;
}
$seq = self::getCursorSeq($cursorKey);
$result['last_seq'] = $seq;
for ($i = 0; $i < $maxBatches; $i++) {
$batch = $sdk->pullChatData($seq);
$result['batches']++;
if (!$batch['ok']) {
$result['errors'][] = $batch['error'];
self::touchCursor($cursorKey, $seq, 0, $batch['error']);
break;
}
$messages = $batch['messages'];
$count = count($messages);
if ($count === 0) {
self::touchCursor($cursorKey, $seq, 0, '');
break;
}
[$inserted, $updated, $mediaPending] = self::persistBatch($messages);
$result['pulled'] += $count;
$result['inserted'] += $inserted;
$result['updated'] += $updated;
$result['media_pending'] += $mediaPending;
$seq = (int) $batch['next_seq'];
$result['last_seq'] = $seq;
self::touchCursor($cursorKey, $seq, $count, '');
if ($count < $sdk->getLimit()) {
// 本批不足 limit,说明暂无更多,结束本轮
break;
}
}
return $result;
}
/**
* 下载 pending 的媒体文件
*
* @return array{enabled: bool, ok: int, failed: int, skipped: int, errors: string[]}
*/
public static function downloadPendingMedia(int $max = 200): array
{
$result = ['enabled' => true, 'ok' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []];
$sdk = new WeComFinanceSdkClient();
if (!$sdk->isAvailable()) {
$result['enabled'] = false;
$result['errors'][] = $sdk->getLastError();
return $result;
}
$rows = QywxMsgArchiveMedia::where('status', QywxMsgArchiveMedia::STATUS_PENDING)
->where('retry_count', '<', 5)
->order('id', 'asc')
->limit($max)
->select()
->toArray();
foreach ($rows as $row) {
$sdkFileid = (string) $row['sdkfileid'];
if ($sdkFileid === '') {
$result['skipped']++;
continue;
}
$relPath = self::buildMediaRelPath((string) $row['msgid'], $sdkFileid);
$absPath = self::absoluteMediaPath($relPath);
$ret = $sdk->downloadMedia($sdkFileid, $absPath);
$now = time();
if ($ret['ok']) {
QywxMsgArchiveMedia::where('id', $row['id'])->update([
'status' => QywxMsgArchiveMedia::STATUS_SUCCESS,
'file_path' => $relPath,
'file_size' => $ret['size'],
'error' => '',
'update_time' => $now,
]);
$result['ok']++;
} else {
QywxMsgArchiveMedia::where('id', $row['id'])->update([
'retry_count' => (int) $row['retry_count'] + 1,
'status' => ((int) $row['retry_count'] + 1) >= 5 ? QywxMsgArchiveMedia::STATUS_FAILED : QywxMsgArchiveMedia::STATUS_PENDING,
'error' => mb_substr($ret['error'], 0, 500),
'update_time' => $now,
]);
$result['failed']++;
$result['errors'][] = sprintf('msgid=%s err=%s', (string) $row['msgid'], $ret['error']);
}
}
return $result;
}
/**
* @param array<int, array<string, mixed>> $messages
*
* @return array{0:int,1:int,2:int} [inserted, updated, mediaPending]
*/
private static function persistBatch(array $messages): array
{
$inserted = 0;
$updated = 0;
$mediaPending = 0;
$now = time();
foreach ($messages as $msg) {
$norm = self::normalize($msg);
if ($norm === null) {
continue;
}
try {
$isUpdate = false;
Db::transaction(function () use ($norm, $now, &$inserted, &$updated, &$mediaPending, &$isUpdate) {
$exists = QywxMsgArchive::where('msgid', $norm['msgid'])->findOrEmpty();
if ($exists->isEmpty()) {
QywxMsgArchive::create(array_merge($norm, [
'create_time' => $now,
'update_time' => $now,
]));
$inserted++;
} else {
// recall/switch 会重发相同 msgid,更新 action / content
QywxMsgArchive::where('msgid', $norm['msgid'])->update(array_merge($norm, [
'update_time' => $now,
]));
$updated++;
$isUpdate = true;
}
if ($norm['media_id'] !== '' && in_array($norm['msgtype'], self::MEDIA_TYPES, true)) {
$existsMedia = QywxMsgArchiveMedia::where('msgid', $norm['msgid'])
->where('sdkfileid', $norm['media_id'])
->findOrEmpty();
if ($existsMedia->isEmpty()) {
QywxMsgArchiveMedia::create([
'msgid' => $norm['msgid'],
'sdkfileid' => $norm['media_id'],
'status' => QywxMsgArchiveMedia::STATUS_PENDING,
'md5sum' => $norm['md5sum'],
'create_time' => $now,
'update_time' => $now,
]);
$mediaPending++;
}
}
self::upsertSession($norm, $now);
});
} catch (\Throwable $e) {
Log::error(sprintf(
'会话存档-消息落库异常 msgid=%s err=%s',
(string) ($norm['msgid'] ?? '-'),
$e->getMessage()
));
}
}
return [$inserted, $updated, $mediaPending];
}
/**
* 把解密后的原始消息归一化为数据库字段
*
* @param array<string, mixed> $msg
*
* @return array<string, mixed>|null
*/
public static function normalize(array $msg): ?array
{
$msgid = (string) ($msg['msgid'] ?? $msg['__msgid_sdk'] ?? '');
if ($msgid === '') {
return null;
}
$action = (string) ($msg['action'] ?? 'send'); // send / recall / switch
$msgtype = (string) ($msg['msgtype'] ?? '');
$from = (string) ($msg['from'] ?? '');
$roomid = (string) ($msg['roomid'] ?? '');
$tolist = isset($msg['tolist']) && is_array($msg['tolist']) ? $msg['tolist'] : [];
$sendTime = isset($msg['msgtime']) ? (int) (((int) $msg['msgtime']) / 1000) : 0;
if ($sendTime === 0 && isset($msg['msgtime'])) {
// 兼容已经是秒的情况
$sendTime = (int) $msg['msgtime'];
}
$content = '';
$mediaId = '';
$md5sum = '';
$fileName = '';
$fileSize = 0;
$fileExt = '';
$playLength = 0;
switch ($msgtype) {
case 'text':
$content = (string) ($msg['text']['content'] ?? '');
break;
case 'markdown':
$content = (string) ($msg['markdown']['content'] ?? '');
break;
case 'image':
$mediaId = (string) ($msg['image']['sdkfileid'] ?? '');
$md5sum = (string) ($msg['image']['md5sum'] ?? '');
$fileSize = (int) ($msg['image']['filesize'] ?? 0);
$content = '[图片]';
break;
case 'voice':
$mediaId = (string) ($msg['voice']['sdkfileid'] ?? '');
$md5sum = (string) ($msg['voice']['md5sum'] ?? '');
$fileSize = (int) ($msg['voice']['voice_size'] ?? 0);
$playLength = (int) ($msg['voice']['play_length'] ?? 0);
$content = sprintf('[语音 %ds]', $playLength);
break;
case 'video':
$mediaId = (string) ($msg['video']['sdkfileid'] ?? '');
$md5sum = (string) ($msg['video']['md5sum'] ?? '');
$fileSize = (int) ($msg['video']['filesize'] ?? 0);
$playLength = (int) ($msg['video']['play_length'] ?? 0);
$content = '[视频]';
break;
case 'emotion':
$mediaId = (string) ($msg['emotion']['sdkfileid'] ?? '');
$md5sum = (string) ($msg['emotion']['md5sum'] ?? '');
$fileSize = (int) ($msg['emotion']['imagesize'] ?? 0);
$content = '[表情]';
break;
case 'file':
$mediaId = (string) ($msg['file']['sdkfileid'] ?? '');
$md5sum = (string) ($msg['file']['md5sum'] ?? '');
$fileName = (string) ($msg['file']['filename'] ?? '');
$fileExt = (string) ($msg['file']['fileext'] ?? '');
$fileSize = (int) ($msg['file']['filesize'] ?? 0);
$content = '[文件] ' . $fileName;
break;
case 'link':
$content = (string) ($msg['link']['title'] ?? '[链接]');
break;
case 'weapp':
$content = '[小程序] ' . (string) ($msg['weapp']['title'] ?? '');
break;
case 'chatrecord':
$content = '[聊天记录]';
break;
case 'location':
$content = '[位置] ' . (string) ($msg['location']['address'] ?? '');
break;
case 'card':
$content = '[名片]';
break;
case 'revoke':
$action = 'recall';
$content = '[撤回] ' . (string) ($msg['revoke']['pre_msgid'] ?? '');
break;
case 'mixed':
$content = self::summarizeMixed($msg['mixed'] ?? null);
break;
case 'meeting':
$content = '[会议] ' . (string) ($msg['meeting']['topic'] ?? '');
break;
case 'docmsg':
$content = '[文档] ' . (string) ($msg['docmsg']['title'] ?? '');
break;
default:
$content = '[' . $msgtype . ']';
}
return [
'msgid' => $msgid,
'seq' => (int) ($msg['__seq'] ?? 0),
'action' => $action,
'from_user' => $from,
'from_type' => 0, // 外层 session upsert 时再判定
'to_list' => $tolist,
'roomid' => $roomid,
'msgtype' => $msgtype,
'content' => mb_substr($content, 0, 16000),
'media_id' => $mediaId,
'md5sum' => $md5sum,
'file_name' => mb_substr($fileName, 0, 500),
'file_size' => $fileSize,
'file_ext' => $fileExt,
'play_length' => $playLength,
'raw' => json_encode($msg, JSON_UNESCAPED_UNICODE),
'send_time' => $sendTime,
];
}
/**
* @param mixed $mixed
*/
private static function summarizeMixed($mixed): string
{
if (!is_array($mixed) || !isset($mixed['item']) || !is_array($mixed['item'])) {
return '[混合消息]';
}
$parts = [];
foreach ($mixed['item'] as $el) {
if (!is_array($el)) {
continue;
}
$t = (string) ($el['type'] ?? '');
if ($t === 'text') {
$parts[] = (string) ($el['content'] ?? '');
} else {
$parts[] = '[' . $t . ']';
}
if (count($parts) >= 3) {
break;
}
}
return implode(' ', $parts);
}
private static function upsertSession(array $norm, int $now): void
{
// 外部联系人 id 的企微前缀通常是 "wm" 或 "wo";员工 userid 无此前缀。
// 单聊时 tolist 有 1 个对端;群聊走 roomid。
$tolist = $norm['to_list'] ?? [];
if ($norm['roomid'] !== '') {
$sessionType = 2;
$staff = self::isStaffUserid($norm['from_user']) ? $norm['from_user'] : '';
if ($staff === '') {
foreach ($tolist as $to) {
if (self::isStaffUserid($to)) {
$staff = (string) $to;
break;
}
}
}
$external = '';
} else {
$sessionType = 1;
if (self::isStaffUserid($norm['from_user'])) {
$staff = $norm['from_user'];
$external = (string) ($tolist[0] ?? '');
} else {
$external = $norm['from_user'];
$staff = (string) ($tolist[0] ?? '');
}
}
if ($staff === '' && $norm['roomid'] === '') {
// 兜底:两端都辨不出,跳过 session 更新
return;
}
$where = [
'staff_userid' => $staff,
'external_userid' => $external,
'roomid' => $norm['roomid'],
];
$summary = mb_substr((string) $norm['content'], 0, 500);
$data = [
'session_type' => $sessionType,
'last_msg_id' => $norm['msgid'],
'last_msg_seq' => $norm['seq'],
'last_msg_time' => $norm['send_time'],
'last_msg_type' => $norm['msgtype'],
'last_msg_summary' => $summary,
'update_time' => $now,
];
$existing = QywxMsgSession::where($where)->findOrEmpty();
if ($existing->isEmpty()) {
QywxMsgSession::create(array_merge($where, $data, [
'unread_staff' => self::isStaffUserid($norm['from_user']) ? 0 : 1,
'create_time' => $now,
]));
} else {
// 仅当本条更新,才覆盖最近消息字段
if ((int) $existing['last_msg_time'] <= (int) $norm['send_time']) {
if (!self::isStaffUserid($norm['from_user'])) {
$data['unread_staff'] = (int) $existing['unread_staff'] + 1;
}
QywxMsgSession::where('id', $existing['id'])->update($data);
}
}
}
/**
* 粗判 userid 是员工还是外部联系人:企微外部联系人 id 以 "wm"/"wo" 开头且长度较长;
* 员工 userid 是管理员配置的,通常是字母/数字无这两个前缀。
*/
private static function isStaffUserid(string $id): bool
{
if ($id === '') {
return false;
}
$lower = strtolower($id);
if (strpos($lower, 'wm') === 0 || strpos($lower, 'wo') === 0) {
// 仍需长度判断:员工 userid 以 wm 开头的极少见;外部联系人通常 20+ 字符
return strlen($id) < 20;
}
return true;
}
private static function buildMediaRelPath(string $msgid, string $sdkFileid): string
{
$hash = substr(md5($msgid . ':' . $sdkFileid), 0, 16);
return 'qywx_archive/' . date('Ymd') . '/' . $hash;
}
private static function absoluteMediaPath(string $relPath): string
{
$base = rtrim((string) config('filesystem.disks.public.root', public_path() . 'storage'), DIRECTORY_SEPARATOR);
return $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relPath);
}
private static function getCursorSeq(string $cursorKey): int
{
$row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty();
return $row->isEmpty() ? 0 : (int) $row['seq'];
}
private static function touchCursor(string $cursorKey, int $seq, int $pullCount, string $error): void
{
$now = time();
$row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty();
if ($row->isEmpty()) {
QywxMsgArchiveCursor::create([
'cursor_key' => $cursorKey,
'seq' => $seq,
'last_pull_time' => $now,
'last_pull_count' => $pullCount,
'last_error' => mb_substr($error, 0, 500),
'create_time' => $now,
'update_time' => $now,
]);
} else {
QywxMsgArchiveCursor::where('id', $row['id'])->update([
'seq' => $seq,
'last_pull_time' => $now,
'last_pull_count' => $pullCount,
'last_error' => mb_substr($error, 0, 500),
'update_time' => $now,
]);
}
}
}
@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
namespace app\common\service\wechat;
use FFI;
use think\facade\Log;
/**
* 仅为 IDE/静态分析"看见" FFI::cdef 绑定的 C 函数,不会在运行时使用;
* 运行时 $this->ffi 是真正的 FFI 实例,方法是 FFI::cdef 动态注入。
*
* @method \FFI\CData NewSdk()
* @method int Init(\FFI\CData $sdk, string $corpid, string $secret)
* @method void DestroySdk(\FFI\CData $sdk)
* @method int GetChatData(\FFI\CData $sdk, int $seq, int $limit, string $proxy, string $passwd, int $timeout, \FFI\CData $out)
* @method int DecryptData(string $encryptKey, string $encryptMsg, \FFI\CData $out)
* @method int GetMediaData(\FFI\CData $sdk, string $indexbuf, string $sdkFileid, string $proxy, string $passwd, int $timeout, \FFI\CData $out)
* @method \FFI\CData NewSlice()
* @method void FreeSlice(\FFI\CData $slice)
* @method \FFI\CData GetContentFromSlice(\FFI\CData $slice)
* @method int GetSliceLen(\FFI\CData $slice)
* @method \FFI\CData NewMediaData()
* @method void FreeMediaData(\FFI\CData $md)
* @method \FFI\CData GetOutIndexBuf(\FFI\CData $md)
* @method \FFI\CData GetData(\FFI\CData $md)
* @method int GetIndexLen(\FFI\CData $md)
* @method int GetDataLen(\FFI\CData $md)
* @method int IsMediaDataFinish(\FFI\CData $md)
*/
interface WeComFinanceSdkBinding
{
}
/**
* 企业微信「会话内容存档」官方 C SDK 的 PHP FFI 封装
*
* @see https://developer.work.weixin.qq.com/document/path/91774 会话存档开发接入指南
*
* 官方动态库(需自行下载后放到服务器):
* Linux: libWeWorkFinanceSdk_C.so
* Windows: WeWorkFinanceSdk.dll
*
* 下载地址:https://developer.work.weixin.qq.com/document/path/91883
*
* 关键点:
* 1. 调用 GetChatData 获取一批 encrypt_random_key + encrypt_chat_msg
* 2. encrypt_random_key 是 RSA 公钥加密的,需用企业后台上传公钥对应的私钥解出 AES 密钥
* 3. AES 密钥 + encrypt_chat_msg 调 DecryptData 得到最终明文 JSON
* 4. 消息 msgtype 为 image/voice/video/file/emotion/chatrecord(含 voip_doc_share) 时需额外调 GetMediaData
* 可能分片返回(is_finish=0 时带 out_index_buf 继续下一片)
*
* 本类对「SDK 未安装 / 未启用」场景做了 noop 兼容:构造时若 `msgaudit_enabled=false`
* 或动态库路径为空,则 isAvailable() 返回 false,所有操作返回安全空结构,不抛异常。
* 上层(Archive 拉取命令)按 isAvailable() 分支。
*/
class WeComFinanceSdkClient
{
/** @var FFI&WeComFinanceSdkBinding|null */
private $ffi = null;
/** @var \FFI\CData|null NewSdk() 的返回值 */
private $sdk = null;
private bool $inited = false;
private bool $available = false;
private string $corpId = '';
private string $secret = '';
private string $libPath = '';
private string $proxy = '';
private string $proxyPasswd = '';
private int $timeout = 60;
private int $limit = 500;
/** @var array<int, string> 私钥 PEM,按 public_key_ver 索引(允许配置多版本轮换) */
private array $privateKeys = [];
private string $lastError = '';
public function __construct()
{
$enabled = (bool) config('pay.wechat_work.msgaudit_enabled', false);
$this->corpId = (string) config('pay.wechat_work.corp_id', '');
$this->secret = (string) config('pay.wechat_work.msgaudit_secret', '');
$this->libPath = (string) config('pay.wechat_work.msgaudit_sdk_lib_path', '');
$this->proxy = (string) config('pay.wechat_work.msgaudit_proxy', '');
$this->proxyPasswd = (string) config('pay.wechat_work.msgaudit_proxy_passwd', '');
$this->timeout = (int) config('pay.wechat_work.msgaudit_timeout', 60);
$this->limit = (int) config('pay.wechat_work.msgaudit_limit', 500);
$this->privateKeys = $this->loadPrivateKeys();
if (!$enabled) {
$this->lastError = 'msgaudit not enabled';
return;
}
if ($this->corpId === '' || $this->secret === '' || $this->libPath === '') {
$this->lastError = 'msgaudit config incomplete (corp_id / msgaudit_secret / msgaudit_sdk_lib_path)';
return;
}
if (!extension_loaded('ffi')) {
$this->lastError = 'php ffi extension not loaded';
return;
}
if (!is_file($this->libPath)) {
$this->lastError = 'msgaudit sdk lib not found: ' . $this->libPath;
return;
}
if (empty($this->privateKeys)) {
$this->lastError = 'msgaudit private key not configured';
return;
}
try {
$this->ffi = FFI::cdef($this->cdef(), $this->libPath);
$this->sdk = $this->ffi->NewSdk();
$ret = $this->ffi->Init($this->sdk, $this->corpId, $this->secret);
if ($ret !== 0) {
$this->lastError = 'SDK Init failed, ret=' . $ret;
$this->ffi->DestroySdk($this->sdk);
$this->sdk = null;
return;
}
$this->inited = true;
$this->available = true;
} catch (\Throwable $e) {
$this->lastError = 'FFI error: ' . $e->getMessage();
$this->sdk = null;
}
}
public function __destruct()
{
try {
if ($this->sdk !== null && $this->ffi !== null) {
$this->ffi->DestroySdk($this->sdk);
}
} catch (\Throwable $e) {
// 忽略析构异常
}
}
public function isAvailable(): bool
{
return $this->available;
}
public function getLastError(): string
{
return $this->lastError;
}
public function getTimeout(): int
{
return $this->timeout;
}
public function getLimit(): int
{
return $this->limit;
}
/**
* 拉取一批消息;返回解密后的明文数组(每条含 msgid、action、msgtype、content-like 字段等)
*
* @param int $seq 上次拉取到的最大 seq,首次传 0
* @param int $limit 单次条数,官方上限 1000
*
* @return array{
* ok: bool,
* error: string,
* messages: array<int, array<string, mixed>>,
* next_seq: int,
* }
*/
public function pullChatData(int $seq = 0, ?int $limit = null): array
{
if (!$this->available) {
return [
'ok' => false,
'error' => $this->lastError !== '' ? $this->lastError : 'sdk unavailable',
'messages' => [],
'next_seq' => $seq,
];
}
$limit = $limit === null ? $this->limit : $limit;
$limit = (int) min(1000, max(1, $limit));
$slice = null;
try {
$slice = $this->ffi->NewSlice();
$ret = $this->ffi->GetChatData(
$this->sdk,
$seq,
$limit,
$this->proxy,
$this->proxyPasswd,
$this->timeout,
$slice
);
if ($ret !== 0) {
return [
'ok' => false,
'error' => 'GetChatData failed, ret=' . $ret,
'messages' => [],
'next_seq' => $seq,
];
}
$json = $this->sliceToString($slice);
$decoded = json_decode($json, true);
if (!is_array($decoded) || !isset($decoded['chatdata']) || !is_array($decoded['chatdata'])) {
return [
'ok' => true,
'error' => '',
'messages' => [],
'next_seq' => $seq,
];
}
$messages = [];
$nextSeq = $seq;
foreach ($decoded['chatdata'] as $entry) {
if (!is_array($entry)) {
continue;
}
$curSeq = isset($entry['seq']) ? (int) $entry['seq'] : 0;
if ($curSeq > $nextSeq) {
$nextSeq = $curSeq;
}
$plain = $this->decryptChatItem($entry);
if ($plain === null) {
continue;
}
$plain['__seq'] = $curSeq;
$plain['__msgid_sdk'] = (string) ($entry['msgid'] ?? ($plain['msgid'] ?? ''));
$plain['__publickey_ver'] = (int) ($entry['publickey_ver'] ?? 0);
$messages[] = $plain;
}
return [
'ok' => true,
'error' => '',
'messages' => $messages,
'next_seq' => $nextSeq,
];
} catch (\Throwable $e) {
return [
'ok' => false,
'error' => 'exception: ' . $e->getMessage(),
'messages' => [],
'next_seq' => $seq,
];
} finally {
if ($slice !== null) {
try {
$this->ffi->FreeSlice($slice);
} catch (\Throwable $e) {
// ignore
}
}
}
}
/**
* 下载媒体(image/voice/video/file/emotion)到本地文件
*
* @param string $sdkFileid 消息明文里的 sdkfileid
* @param string $destPath 目标绝对路径
*
* @return array{ok: bool, error: string, size: int}
*/
public function downloadMedia(string $sdkFileid, string $destPath): array
{
if (!$this->available) {
return ['ok' => false, 'error' => $this->lastError !== '' ? $this->lastError : 'sdk unavailable', 'size' => 0];
}
if ($sdkFileid === '') {
return ['ok' => false, 'error' => 'empty sdkfileid', 'size' => 0];
}
$dir = dirname($destPath);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => 'mkdir failed: ' . $dir, 'size' => 0];
}
$fp = @fopen($destPath, 'wb');
if ($fp === false) {
return ['ok' => false, 'error' => 'fopen failed: ' . $destPath, 'size' => 0];
}
$indexBuf = '';
$totalSize = 0;
$chunkCount = 0;
$maxChunks = 10000; // 安全上限,防止 SDK 异常时死循环
try {
while ($chunkCount++ < $maxChunks) {
$mediaData = $this->ffi->NewMediaData();
try {
$ret = $this->ffi->GetMediaData(
$this->sdk,
$indexBuf,
$sdkFileid,
$this->proxy,
$this->proxyPasswd,
$this->timeout,
$mediaData
);
if ($ret !== 0) {
fclose($fp);
@unlink($destPath);
return ['ok' => false, 'error' => 'GetMediaData failed, ret=' . $ret . ' chunk=' . $chunkCount, 'size' => 0];
}
$dataLen = (int) $this->ffi->GetDataLen($mediaData);
if ($dataLen > 0) {
$dataPtr = $this->ffi->GetData($mediaData);
$chunk = FFI::string($dataPtr, $dataLen);
fwrite($fp, $chunk);
$totalSize += strlen($chunk);
}
$isFinish = (int) $this->ffi->IsMediaDataFinish($mediaData);
if ($isFinish === 1) {
break;
}
$indexLen = (int) $this->ffi->GetIndexLen($mediaData);
if ($indexLen > 0) {
$indexPtr = $this->ffi->GetOutIndexBuf($mediaData);
$indexBuf = FFI::string($indexPtr, $indexLen);
} else {
// 无 index 又 not finish,保护性退出
break;
}
} finally {
$this->ffi->FreeMediaData($mediaData);
}
}
fclose($fp);
return ['ok' => true, 'error' => '', 'size' => $totalSize];
} catch (\Throwable $e) {
if (is_resource($fp)) {
fclose($fp);
}
@unlink($destPath);
return ['ok' => false, 'error' => 'exception: ' . $e->getMessage(), 'size' => 0];
}
}
/**
* 单条消息解密:encrypt_random_keyRSA-> AES key -> DecryptData 明文
*
* @return array<string, mixed>|null
*/
private function decryptChatItem(array $entry): ?array
{
$encryptRandomKeyB64 = (string) ($entry['encrypt_random_key'] ?? '');
$encryptChatMsg = (string) ($entry['encrypt_chat_msg'] ?? '');
$publicKeyVer = (int) ($entry['publickey_ver'] ?? 0);
if ($encryptRandomKeyB64 === '' || $encryptChatMsg === '') {
return null;
}
$privateKey = $this->privateKeys[$publicKeyVer] ?? null;
if ($privateKey === null) {
// 尝试唯一配置的私钥兜底
$privateKey = reset($this->privateKeys) ?: null;
}
if ($privateKey === null) {
Log::warning(sprintf('会话存档-无匹配私钥 publickey_ver=%d', $publicKeyVer));
return null;
}
$encryptRandomKey = base64_decode($encryptRandomKeyB64, true);
if ($encryptRandomKey === false) {
return null;
}
$aesKey = null;
// 企微会话存档默认 RSA-PKCS1 填充;部分版本可能使用 OAEP,兼容尝试
if (openssl_private_decrypt($encryptRandomKey, $decrypted, $privateKey, OPENSSL_PKCS1_PADDING)) {
$aesKey = $decrypted;
} elseif (openssl_private_decrypt($encryptRandomKey, $decrypted, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) {
$aesKey = $decrypted;
} else {
Log::warning(sprintf(
'会话存档-RSA 解密失败 publickey_ver=%d err=%s',
$publicKeyVer,
(string) openssl_error_string()
));
return null;
}
$slice = null;
try {
$slice = $this->ffi->NewSlice();
$ret = $this->ffi->DecryptData($aesKey, $encryptChatMsg, $slice);
if ($ret !== 0) {
Log::warning('会话存档-DecryptData 失败 ret=' . $ret);
return null;
}
$plain = $this->sliceToString($slice);
$json = json_decode($plain, true);
if (!is_array($json)) {
return null;
}
return $json;
} finally {
if ($slice !== null) {
try {
$this->ffi->FreeSlice($slice);
} catch (\Throwable $e) {
// ignore
}
}
}
}
private function sliceToString(\FFI\CData $slice): string
{
$len = (int) $this->ffi->GetSliceLen($slice);
if ($len <= 0) {
return '';
}
$buf = $this->ffi->GetContentFromSlice($slice);
return FFI::string($buf, $len);
}
/**
* @return array<int, string> publickey_ver => pem
*/
private function loadPrivateKeys(): array
{
$ver = (int) config('pay.wechat_work.msgaudit_public_key_ver', 0);
$pemFromPath = '';
$path = (string) config('pay.wechat_work.msgaudit_private_key_path', '');
if ($path !== '' && is_file($path) && is_readable($path)) {
$pemFromPath = (string) file_get_contents($path);
}
$pemInline = (string) config('pay.wechat_work.msgaudit_private_key', '');
$pem = $pemFromPath !== '' ? $pemFromPath : $pemInline;
if ($pem === '') {
return [];
}
// 允许配置里是 \n 转义;标准化换行
$pem = str_replace(["\r\n", "\\n"], ["\n", "\n"], $pem);
if (strpos($pem, '-----BEGIN') === false) {
// 容忍纯 base64:按 64 列切并补头尾
$pem = "-----BEGIN RSA PRIVATE KEY-----\n" . chunk_split(trim($pem), 64, "\n") . "-----END RSA PRIVATE KEY-----\n";
}
// 支持未来扩展多版本;目前只有一把,默认索引为配置的 ver(>=1),若为 0 则归到 key=0
return [$ver => $pem];
}
/**
* 官方 C SDK 头文件的 FFI cdef;与 libWeWorkFinanceSdk_C 头文件完全对应。
*
* 官方定义(简化):
* struct Slice_t { char* buf; int len; };
* struct MediaData_t { char* outindexbuf; char* data; int data_len; int is_finish; };
*/
private function cdef(): string
{
return <<<'CDEF'
typedef struct Slice {
char *buf;
int len;
} Slice_t;
typedef struct MediaData {
char *outindexbuf;
int outindexbuf_len;
char *data;
int data_len;
int is_finish;
} MediaData_t;
typedef void WeWorkFinanceSdk_t;
WeWorkFinanceSdk_t* NewSdk();
int Init(WeWorkFinanceSdk_t* sdk, const char* corpid, const char* secret);
void DestroySdk(WeWorkFinanceSdk_t* sdk);
int GetChatData(WeWorkFinanceSdk_t* sdk, unsigned long long seq, unsigned int limit,
const char* proxy, const char* passwd, int timeout, Slice_t* chatData);
int DecryptData(const char* encrypt_key, const char* encrypt_msg, Slice_t* msg);
int GetMediaData(WeWorkFinanceSdk_t* sdk, const char* indexbuf, const char* sdkFileid,
const char* proxy, const char* passwd, int timeout, MediaData_t* mediaData);
Slice_t* NewSlice();
void FreeSlice(Slice_t* slice);
char* GetContentFromSlice(Slice_t* slice);
int GetSliceLen(Slice_t* slice);
MediaData_t* NewMediaData();
void FreeMediaData(MediaData_t* mediaData);
char* GetOutIndexBuf(MediaData_t* mediaData);
char* GetData(MediaData_t* mediaData);
int GetIndexLen(MediaData_t* mediaData);
int GetDataLen(MediaData_t* mediaData);
int IsMediaDataFinish(MediaData_t* mediaData);
CDEF;
}
}
@@ -15,12 +15,64 @@ class WechatWorkService
{
private $corpId;
private $secret;
/** 实际选用的 secret 语义标签:customer_contact / external_pay / unknown,便于排错 */
private string $secretLabel = 'unknown';
private $accessToken;
public function __construct()
/**
* @param string|null $secretType 'customer_contact'(客户联系 API)或 'external_pay'(对外收款 API);
* 缺省按调用方法的语义自动选择,未指定时优先使用客户联系 Secret,回退到对外收款。
*/
public function __construct(?string $secretType = null)
{
$this->corpId = config('pay.wechat_work.corp_id');
$this->secret = config('pay.wechat_work.external_pay_secret');
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
if ($secretType === 'external_pay') {
$this->secret = $payContactSecret;
$this->secretLabel = 'external_pay';
} elseif ($secretType === 'customer_contact') {
if ($customerSecret !== '') {
$this->secret = $customerSecret;
$this->secretLabel = 'customer_contact';
} else {
$this->secret = $payContactSecret;
$this->secretLabel = 'external_pay(fallback)';
}
} else {
// 历史调用:本类绝大多数方法走的是「客户联系」API,因此默认优先取客户联系 Secret,
// 未配置时回退到对外收款 Secret,保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
if ($customerSecret !== '') {
$this->secret = $customerSecret;
$this->secretLabel = 'customer_contact';
} else {
$this->secret = $payContactSecret;
$this->secretLabel = 'external_pay(fallback)';
}
}
}
/** 当前选用的 secret 语义标签 */
public function getSecretLabel(): string
{
return $this->secretLabel;
}
/** 对 secret 做脱敏展示,用于错误日志定位 */
private function maskSecret(): string
{
$s = (string) $this->secret;
if ($s === '') {
return '(empty)';
}
$len = strlen($s);
if ($len <= 8) {
return str_repeat('*', $len);
}
return substr($s, 0, 4) . str_repeat('*', $len - 8) . substr($s, -4) . '(len=' . $len . ')';
}
/**
@@ -33,8 +85,9 @@ class WechatWorkService
return $this->accessToken;
}
// 缓存获取
$cacheKey = 'qywx_access_token';
// 缓存 key 必须按 corp+secret 分桶,否则多应用(customer_contact / external_pay)之间会相互覆盖,
// 读到别家应用的 token 去调本家接口 → 偶发 40001 / 40014 / 60011。
$cacheKey = 'qywx_access_token:' . md5($this->corpId . '|' . $this->secret);
$token = Cache::get($cacheKey);
if ($token) {
$this->accessToken = $token;
@@ -52,15 +105,32 @@ class WechatWorkService
if (isset($response['access_token'])) {
$token = $response['access_token'];
$expiresIn = $response['expires_in'] ?? 7200;
// 缓存token,提前5分钟过期
Cache::set($cacheKey, $token, $expiresIn - 300);
$this->accessToken = $token;
return $token;
}
throw new \Exception('获取access_token失败: ' . ($response['errmsg'] ?? '未知错误'));
$errcode = $response['errcode'] ?? '?';
$errmsg = $response['errmsg'] ?? '未知错误';
Log::error('企业微信 gettoken 失败', [
'errcode' => $errcode,
'errmsg' => $errmsg,
'secret_label' => $this->secretLabel,
'secret_masked' => $this->maskSecret(),
'corp_id' => $this->corpId,
]);
throw new \Exception(sprintf(
'获取access_token失败[%s]: %s (secret_label=%s, secret=%s, corp_id=%s)',
(string) $errcode,
(string) $errmsg,
$this->secretLabel,
$this->maskSecret(),
(string) $this->corpId
));
}
/**
@@ -149,9 +219,15 @@ class WechatWorkService
/**
* @notes 获取客户详情
* @see https://developer.work.weixin.qq.com/document/path/92114
*
* @param string $externalUserId
* @param array<string, mixed>|null $errorOut 引用出参:当返回空数组时携带 errcode/errmsg/raw,便于上游分流处理
* @return array<string, mixed>
*/
public function getExternalContactDetail(string $externalUserId): array
public function getExternalContactDetail(string $externalUserId, ?array &$errorOut = null): array
{
$errorOut = null;
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get';
$params = [
@@ -160,15 +236,214 @@ class WechatWorkService
];
$response = $this->httpGet($url, $params);
if (isset($response['external_contact'])) {
return $response;
}
Log::error('获取客户详情失败: ' . json_encode($response));
$errcode = isset($response['errcode']) ? (int) $response['errcode'] : null;
$errmsg = (string) ($response['errmsg'] ?? '');
$errorOut = [
'errcode' => $errcode,
'errmsg' => $errmsg,
'raw' => $response,
];
// 84061: 客户尚未通过验证 / 41068: 不合法的成员/外部联系人 / 60011/60111: 没有权限访问
// 这几类是「业务上预期会发生」的,降级为 warning,避免污染 error 日志。
// 关键字段直接拼到消息文本里,便于在 ThinkPHP 默认 file 日志格式下 grep 统计 errcode 分布。
$line = sprintf(
'企业微信-获取客户详情失败 errcode=%s errmsg=%s ext=%s',
$errcode === null ? '?' : (string) $errcode,
$errmsg !== '' ? $errmsg : '-',
$externalUserId
);
if (in_array($errcode, [84061, 41068, 60011, 60111], true)) {
Log::warning($line);
} else {
Log::error($line);
}
return [];
}
/**
* 企业群发:员工代发消息给已添加客户 / 客户群
*
* @see https://developer.work.weixin.qq.com/document/path/92135 创建企业群发
*
* 注意:这里只是「下发提醒」,员工手机端会在企微 App 看到"请发送"的提示,点击后才真正发给客户;
* 不点击则消息不会触达。发送结果需通过 getGroupMsgSendResult 查询。
*
* @param array<string, mixed> $payload 标准字段:
* chat_type string 'single' 或 'group',必填
* external_userid string[] 客户 external_userid 列表(single 必填,最多 10000group 不传)
* chat_type_external_userid_list ... 预留
* sender string 员工 userid(不传则使用创建者自己;推荐显式传)
* allow_select bool 为 true 时企微会让员工在企微 App 里勾选客户;默认 false
* text ['content' => string]
* attachments array[] 单条内可含 image / video / link / miniprogram / file(最多 9 个)
*
* @return array<string, mixed> 原样返回 errcode / errmsg / msgid / fail_list
*/
public function addMsgTemplate(array $payload): array
{
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_msg_template?access_token=' . urlencode($accessToken);
$response = $this->httpPostJson($url, $payload);
$errcode = isset($response['errcode']) ? (int) $response['errcode'] : null;
if ($errcode !== 0) {
$errmsg = (string) ($response['errmsg'] ?? '');
Log::warning(sprintf(
'企业微信-企业群发失败 errcode=%s errmsg=%s sender=%s chat_type=%s',
$errcode === null ? '?' : (string) $errcode,
$errmsg !== '' ? $errmsg : '-',
(string) ($payload['sender'] ?? '-'),
(string) ($payload['chat_type'] ?? '-')
));
}
return $response;
}
/**
* 查询企业群发结果
*
* @see https://developer.work.weixin.qq.com/document/path/93338
* 群发任务创建后,消息会进入发送池,员工手机端需点击确认;此接口返回员工确认/送达/读状态。
*
* @return array<string, mixed>
*/
public function getGroupMsgSendResult(string $msgid, string $userid = '', string $cursor = '', int $limit = 500): array
{
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get_group_msg_send_result?access_token=' . urlencode($accessToken);
$body = [
'msgid' => $msgid,
'limit' => min(1000, max(1, $limit)),
'cursor' => $cursor,
];
if ($userid !== '') {
$body['userid'] = $userid;
}
return $this->httpPostJson($url, $body);
}
/**
* 上传临时素材(企微 media_id,3 天有效)。用于企业群发的 image/video/file 类型 attachment。
*
* @see https://developer.work.weixin.qq.com/document/path/90253
*
* @param string $type image|voice|video|file
* @param string $path 本地可读文件绝对路径
* @param string $filename 上传字段里的 filename,默认取 basename
* @return array<string, mixed> 标准响应(包含 media_id
*/
public function uploadMedia(string $type, string $path, string $filename = ''): array
{
if (!is_file($path) || !is_readable($path)) {
Log::error(sprintf('企业微信-上传临时素材失败:文件不存在或不可读 path=%s', $path));
return ['errcode' => -1, 'errmsg' => 'file not found: ' . $path];
}
$type = in_array($type, ['image', 'voice', 'video', 'file'], true) ? $type : 'file';
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=' . urlencode($accessToken) . '&type=' . $type;
if ($filename === '') {
$filename = basename($path);
}
$cfile = new \CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200) {
Log::error(sprintf('企业微信-上传临时素材 HTTP=%d type=%s err=%s', $httpCode, $type, $curlErr));
return ['errcode' => -1, 'errmsg' => 'http ' . $httpCode . ' ' . $curlErr];
}
$result = json_decode((string) $response, true);
if (!is_array($result)) {
Log::error('企业微信-上传临时素材 响应解析失败:' . (string) $response);
return ['errcode' => -1, 'errmsg' => 'bad json'];
}
return $result;
}
/**
* 上传图文/图片的永久图片素材,获取企微 CDN 图片 URL(用于 link/image 附件的 pic_url
*
* @see https://developer.work.weixin.qq.com/document/path/90256
*/
public function uploadImageToCdn(string $path, string $filename = ''): array
{
if (!is_file($path) || !is_readable($path)) {
return ['errcode' => -1, 'errmsg' => 'file not found'];
}
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token=' . urlencode($accessToken);
if ($filename === '') {
$filename = basename($path);
}
$cfile = new \CURLFile($path, mime_content_type($path) ?: 'image/jpeg', $filename);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode((string) $response, true);
return is_array($result) ? $result : ['errcode' => -1, 'errmsg' => 'bad json'];
}
/**
* 获取企业微信部门下所有成员(含字段 userid/name/mobile/department/...),供 admin 选择发送员工
*
* @see https://developer.work.weixin.qq.com/document/path/90201
*/
public function getDepartmentUserSimpleList(int $departmentId = 1, bool $fetchChild = true): array
{
$accessToken = $this->getAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/user/simplelist';
$params = [
'access_token' => $accessToken,
'department_id' => $departmentId,
'fetch_child' => $fetchChild ? 1 : 0,
];
$response = $this->httpGet($url, $params);
return isset($response['userlist']) && is_array($response['userlist']) ? $response['userlist'] : [];
}
/**
* @notes 检查应用权限(用于诊断)
* @return array 返回应用的权限信息