更新
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<?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 {
|
||||
return Db::name('qywx_promotion_config')->getFields() !== [];
|
||||
} 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();
|
||||
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
|
||||
$data = ['config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), '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()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 不接受浏览器提供的 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user