292 lines
13 KiB
PHP
292 lines
13 KiB
PHP
<?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);
|
|
}
|
|
}
|