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