Files
zyt/server/app/common/service/qywx/QywxPromotionConfig.php
T
2026-09-01 11:26:47 +08:00

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);
}
}