255) { throw new InvalidArgumentException('请填写标题,且不超过255字'); } if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $q['field'] ?? '')) { throw new InvalidArgumentException('字段标识须以字母开头,仅包含字母、数字和下划线'); } $q['config'] = self::arrayValue($q['config'] ?? []); $userField = $q['config']['user_field'] ?? ''; if ($userField !== '' && (!in_array($userField, ['name', 'mobile', 'no', 'sex', 'birthday', 'email', 'company_name', 'company_position', 'idcard', 'plate'], true) || !in_array($q['edit_input'], ['text', 'textarea', 'date'], true) || ($q['edit_input'] === 'date' && $userField !== 'birthday'))) { throw new InvalidArgumentException('学员字段仅支持个人信息字段,日期题只能关联出生日期'); } if ($q['edit_input'] === 'section') { $q['rule'] = ''; $q['need_fill'] = false; $q['allow_input'] = false; $q['select_item'] = []; $q['dimensions'] = []; $q['config'] = []; return $q; } if ($q['edit_input'] === 'multi_dimension') { $q['dimensions'] = self::arrayValue($q['dimensions'] ?? []); if (!$q['dimensions']) { throw new InvalidArgumentException('请至少添加一个维度'); } $fields = []; foreach ($q['dimensions'] as &$dim) { $dim['edit_input'] = $dim['edit_input'] ?? 'radio'; if (!in_array($dim['edit_input'], ['radio', 'number'], true)) { throw new InvalidArgumentException('维度仅支持单选或数字输入'); } if (self::blank($dim['name'] ?? null) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $dim['field'] ?? '')) { throw new InvalidArgumentException('请填写维度名称和合法的字段标识'); } if (in_array($dim['field'], $fields, true)) { throw new InvalidArgumentException('维度字段标识不能重复'); } $fields[] = $dim['field']; $dim['need_fill'] = self::required($q); $dim['rule'] = $dim['need_fill'] ? 'required' : ''; $dim['config'] = self::arrayValue($dim['config'] ?? []); $dim = self::field($dim); } unset($dim); $q['select_item'] = []; return $q; } return self::field($q); } public const FILE_EXTENSIONS = ['zip', 'rar', 'ppt', 'pptx', 'xls', 'xlsx', 'doc', 'docx', 'png', 'gif', 'jpg', 'jpeg', 'pdf', 'mp4']; public static function fileExtensions(array $config): array { $extensions = $config['allowed_extensions'] ?? self::FILE_EXTENSIONS; if (!is_array($extensions) || !$extensions) throw new InvalidArgumentException('请至少选择一种允许上传的文件格式'); foreach ($extensions as $extension) { if (!is_string($extension) || !in_array(strtolower($extension), self::FILE_EXTENSIONS, true)) throw new InvalidArgumentException('不支持的上传文件格式'); } return array_values(array_unique(array_map('strtolower', $extensions))); } private static function field(array $field): array { $config = $field['config'] ?? []; if ($field['edit_input'] === 'file') { $limit = $config['max_files'] ?? 1; if (filter_var($limit, FILTER_VALIDATE_INT) === false || $limit < 1 || $limit > 10) throw new InvalidArgumentException('上传数量须为1至10'); $field['config']['max_files'] = (int) $limit; $field['config']['allowed_extensions'] = self::fileExtensions($config); } if (in_array($field['edit_input'], ['radio', 'checkbox'], true)) { $options = self::arrayValue($field['select_item'] ?? []); if (!$options) { throw new InvalidArgumentException('选择题至少需要一个选项'); } $keys = []; $labels = []; foreach ($options as &$option) { if (self::blank($option['key'] ?? null) || self::blank($option['value'] ?? null)) { throw new InvalidArgumentException('请填写选项标识和文字'); } $option['key'] = (string) $option['key']; $option['value'] = (string) $option['value']; if (in_array($option['key'], $keys, true) || in_array($option['value'], $labels, true)) { throw new InvalidArgumentException('同一题的选项标识和文字不能重复'); } $keys[] = $option['key']; $labels[] = $option['value']; if (!empty($config['is_score'])) { if ($field['edit_input'] !== 'radio' || !isset($option['score']) || !is_numeric($option['score']) || !is_finite((float) $option['score'])) { throw new InvalidArgumentException('评分仅支持单选,每个选项须填写有效分值'); } $option['score'] = (float) $option['score']; } else { unset($option['score']); } } unset($option); if (!self::blank($config['other_key'] ?? null) && !in_array((string) $config['other_key'], $keys, true)) { throw new InvalidArgumentException('其他选项必须属于本题选项'); } if ($field['edit_input'] === 'checkbox' && isset($config['max_choices'])) { $max = $config['max_choices']; if (filter_var($max, FILTER_VALIDATE_INT) === false || $max < 0 || $max > count($options)) throw new InvalidArgumentException('最多选择项数须为0至选项数量之间的整数'); } $notes = $config['option_notes'] ?? []; if (!is_array($notes)) throw new InvalidArgumentException('选项附加输入框配置不正确'); foreach ($notes as $key => $note) { if (!in_array((string) $key, $keys, true) || !is_array($note)) throw new InvalidArgumentException('附加输入框必须属于本题选项'); if ((string) $key === (string) ($config['other_key'] ?? '')) throw new InvalidArgumentException('同一选项不能重复设置补充输入框'); if (!is_string($note['placeholder'] ?? '') || mb_strlen($note['placeholder'] ?? '') > 255) throw new InvalidArgumentException('输入框提示文字不能超过255字'); $field['config']['option_notes'][$key]['required'] = !empty($note['required']); } if (!is_string($config['other_placeholder'] ?? '') || mb_strlen($config['other_placeholder'] ?? '') > 255) throw new InvalidArgumentException('补充输入框提示文字不能超过255字'); $field['select_item'] = $options; } if ($field['edit_input'] === 'number') { foreach (['min', 'max'] as $key) { if (isset($config[$key]) && $config[$key] !== '' && (!is_numeric($config[$key]) || !is_finite((float) $config[$key]))) { throw new InvalidArgumentException('数字范围必须为有效数字'); } } if (isset($config['min'], $config['max']) && $config['min'] !== '' && $config['max'] !== '' && $config['min'] > $config['max']) { throw new InvalidArgumentException('最小值不能大于最大值'); } } return $field; } public static function hash(array $questions): string { $fields = ['id', 'name', 'field', 'edit_input', 'sort', 'help', 'rule', 'need_fill', 'allow_input', 'select_item', 'dimensions', 'config', 'course_content_id']; return hash('sha256', json_encode(array_map(function ($q) use ($fields) { return array_intersect_key($q, array_flip($fields)); }, $questions), JSON_UNESCAPED_UNICODE)); } /** Legacy data is a list of question snapshots or an id -> value map. */ public static function answerMap($data): array { $map = []; foreach (self::arrayValue($data) as $key => $raw) { if (is_array($raw) && isset($raw['id'])) { $map[(string) $raw['id']] = $raw; } else { $map[(string) $key] = ['value' => $raw]; } } return $map; } // Input must be in the same sort/id order used for questionnaire rendering. public static function validateConditions(array $questions): void { $preceding = []; foreach ($questions as $q) { $condition = $q['config']['visible_when'] ?? null; if ($condition !== null) { if (!is_array($condition) || !is_string($condition['field'] ?? null) || !is_scalar($condition['value'] ?? null)) throw new InvalidArgumentException('显示条件格式不正确'); $parent = $preceding[$condition['field']] ?? null; if ($q['edit_input'] === 'section' || !$parent || !in_array($parent['edit_input'], ['radio', 'checkbox'], true)) throw new InvalidArgumentException($q['name'].':显示条件只能引用本题之前的单选或多选题,请先调整条件'); $keys = array_map(fn ($o) => (string) $o['key'], self::arrayValue($parent['select_item'] ?? [])); if (!in_array((string) $condition['value'], $keys, true)) throw new InvalidArgumentException($q['name'].':显示条件引用的选项不存在,请先调整条件'); } $preceding[$q['field']] = $q; } } public static function visible(array $q, array $questions, array $map, bool $keyed = true, array $visited = []): bool { $condition = $q['config']['visible_when'] ?? null; if (!$condition) return true; $field = $q['field'] ?? (string) $q['id']; if (in_array($field, $visited, true)) return false; $visited[] = $field; foreach ($questions as $parent) { if (($parent['field'] ?? '') !== ($condition['field'] ?? '')) continue; if (!self::visible($parent, $questions, $map, $keyed, $visited)) return false; $value = $map[(string) $parent['id']]['value'] ?? null; $keys = self::selectedKeys($parent, $value, $keyed); return in_array((string) ($condition['value'] ?? ''), $keys, true); } return false; } public static function normalize(array $questions, $data, bool $keyed = false): array { $map = self::answerMap($data); $result = []; foreach ($questions as $q) { $raw = $map[(string) $q['id']] ?? []; $q['other_text'] = ''; $q['option_texts'] = []; if (!self::visible($q, $questions, $map, $keyed)) { $q['value'] = null; $q['hidden'] = true; $result[] = $q; continue; } $q['hidden'] = false; if ($q['edit_input'] === 'section') { $q['value'] = null; } elseif ($q['edit_input'] === 'multi_dimension') { $values = self::arrayValue($raw['value'] ?? []); $q['value'] = []; foreach (self::arrayValue($q['dimensions'] ?? []) as $dim) { $dim['edit_input'] = $dim['edit_input'] ?? 'radio'; $dim['need_fill'] = self::required($q); $dim['rule'] = $dim['need_fill'] ? 'required' : ''; $q['value'][$dim['field']] = self::answer($dim, $values[$dim['field']] ?? null, $keyed, $q['name'] . ' / ' . $dim['name']); } } else { $q['value'] = self::answer($q, $raw['value'] ?? null, $keyed, $q['name']); $config = self::arrayValue($q['config'] ?? []); foreach ($config['option_notes'] ?? [] as $key => $settings) { if (!in_array((string) $key, array_map('strval', (array) $q['value']), true)) continue; $text = $raw['option_texts'][$key] ?? ''; if (!is_scalar($text) || mb_strlen((string) $text) > 10000) throw new InvalidArgumentException($q['name'].':补充文字格式不正确或过长'); $text = trim((string) $text); if (!empty($settings['required']) && $text === '') throw new InvalidArgumentException($q['name'].':请填写补充说明'); $q['option_texts'][$key] = $text; } $otherKey = $config['other_key'] ?? null; $selectedOther = !self::blank($otherKey) && in_array((string) $otherKey, array_map('strval', (array) $q['value']), true); // Existing questionnaires allowed a separate free-text response without an "other" option. $legacyOther = !empty($q['allow_input']) && self::blank($otherKey); if ($selectedOther || $legacyOther) { $text = $raw['other_text'] ?? $raw['allow_input_text'] ?? ''; if (!is_scalar($text) || mb_strlen((string) $text) > 10000) { throw new InvalidArgumentException($q['name'] . ':补充文字格式不正确或过长'); } $q['other_text'] = trim((string) $text); if ($selectedOther && !empty($config['other_required']) && $q['other_text'] === '') { throw new InvalidArgumentException($q['name'] . ':请填写其他内容'); } } } // Preserve the server definition; never store a client-supplied label, score or dimension. $result[] = $q; } return $result; } private static function answer(array $field, $value, bool $keyed, string $label) { $type = $field['edit_input']; if (self::blank($value)) { if (self::required($field)) { throw new InvalidArgumentException($label . ':请填写此项'); } return $type === 'checkbox' ? [] : null; } $config = self::arrayValue($field['config'] ?? []); if (in_array($type, ['radio', 'checkbox'], true)) { $values = $type === 'checkbox' ? (is_array($value) ? $value : explode(',', (string) $value)) : [$value]; $keys = []; foreach ($values as $v) { $match = null; foreach (self::arrayValue($field['select_item'] ?? []) as $option) { if (is_scalar($v) && (string) $v === (string) ($keyed ? $option['key'] : $option['value'])) { $match = (string) $option['key']; break; } } if ($match === null) { throw new InvalidArgumentException($label . ':选项已变更,请刷新问卷'); } $keys[] = $match; } $keys = array_values(array_unique($keys)); if ($type === 'checkbox' && !empty($config['max_choices']) && count($keys) > (int) $config['max_choices']) throw new InvalidArgumentException($label.':最多选择'.$config['max_choices'].'项'); return $type === 'checkbox' ? $keys : $keys[0]; } if ($type === 'file') { if (!is_array($value) || count($value) > ($config['max_files'] ?? 1)) throw new InvalidArgumentException($label.':上传文件数量不正确'); $files = []; foreach ($value as $item) { $id = is_array($item) ? ($item['id'] ?? null) : $item; if (!is_scalar($id) || !ctype_digit((string) $id)) throw new InvalidArgumentException($label.':文件无效,请重新上传'); $file = \App\Models\Upload::whereKey($id)->where('creator_id', auth('mobile')->id())->first(); if (!$file || $file->size >= 50 * 1024 * 1024 || !in_array(strtolower($file->extension), ['zip','rar','ppt','pptx','xls','xlsx','doc','docx','png','gif','jpg','jpeg','pdf','mp4'], true)) throw new InvalidArgumentException($label.':文件无效或不属于当前用户,请重新上传'); if (!in_array(strtolower($file->extension), self::fileExtensions($config), true)) throw new InvalidArgumentException($label.':只允许上传 '.implode('、', self::fileExtensions($config)).' 格式'); $files[(string) $file->id] = ['id' => $file->id, 'original_name' => $file->original_name, 'url' => $file->url, 'extension' => $file->extension, 'size' => $file->size]; } return array_values($files); } if ($type === 'number') { if (!is_scalar($value) || !is_numeric($value) || !is_finite((float) $value)) { throw new InvalidArgumentException($label . ':请输入有效数字'); } $number = (float) $value; if (!empty($config['integer']) && floor($number) !== $number) { throw new InvalidArgumentException($label . ':请输入整数'); } foreach (['min', 'max'] as $bound) { if (isset($config[$bound]) && $config[$bound] !== '' && ($bound === 'min' ? $number < $config[$bound] : $number > $config[$bound])) { throw new InvalidArgumentException($label . ':数字超出允许范围'); } } return $number; } if (!is_scalar($value) || mb_strlen((string) $value) > 10000) { throw new InvalidArgumentException($label . ':答案格式不正确或超过10000字'); } $value = trim((string) $value); if (in_array($type, ['date', 'datetime'], true)) { $format = $type === 'date' ? 'Y-m-d' : 'Y-m-d H:i:s'; $date = \DateTime::createFromFormat('!' . $format, $value); if (!$date || $date->format($format) !== $value) { throw new InvalidArgumentException($label . ':日期格式不正确'); } } foreach (explode(',', $field['rule'] ?? '') as $rule) { if (($rule === 'integer' && !preg_match('/^-?\d+$/', $value)) || ($rule === 'numeric' && !is_numeric($value)) || ($rule === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) || ($rule === 'mobile' && !preg_match('/^1[3-9]\d{9}$/', $value)) || ($rule === 'idcard' && !preg_match('/^(\d{15}|\d{17}[\dXx])$/', $value))) { throw new InvalidArgumentException($label . ':格式不符合校验规则'); } } return $value; } public static function display(array $field, $value, bool $keyed): string { if (self::blank($value)) { return '未填写'; } $type = $field['edit_input'] ?? 'radio'; if ($type === 'file') return implode('、', array_column(is_array($value) ? $value : [], 'original_name')); if (in_array($type, ['radio', 'checkbox'], true)) { $values = is_array($value) ? $value : ($type === 'checkbox' ? explode(',', (string) $value) : [$value]); $labels = []; foreach ($values as $v) { $label = is_scalar($v) ? (string) $v : ''; foreach (self::arrayValue($field['select_item'] ?? []) as $option) { if ($label === (string) ($keyed ? $option['key'] : $option['value'])) { $label = (string) $option['value']; break; } } $labels[] = $label; } return implode('、', $labels); } return is_scalar($value) ? (string) $value : ''; } private static function selectedKeys(array $field, $value, bool $keyed): array { $type = $field['edit_input'] ?? 'radio'; if (!in_array($type, ['radio', 'checkbox'], true) || self::blank($value)) return []; $values = is_array($value) ? $value : ($type === 'checkbox' ? explode(',', (string) $value) : [$value]); $keys = []; foreach (self::arrayValue($field['select_item'] ?? []) as $option) { foreach ($values as $selected) { if (is_scalar($selected) && (string) $selected === (string) ($keyed ? $option['key'] : $option['value'])) { $keys[] = (string) $option['key']; break; } } } return $keys; } public static function replyQuestions(array $current, $data, $snapshot): array { $snapshot = self::arrayValue($snapshot); $map = self::answerMap($data); $questions = $snapshot['questions'] ?? $current; $keyed = ($snapshot['version'] ?? 0) >= 2; $result = []; foreach ($questions as $q) { $raw = $map[(string) $q['id']] ?? []; if (!$keyed && isset($raw['edit_input'], $raw['name'])) { $q = array_replace($q, $raw); // legacy submissions already carried their definition } $q['value'] = $raw['value'] ?? null; $q['hidden'] = !self::visible($q, $questions, $map, $keyed); $q['option_texts'] = self::arrayValue($raw['option_texts'] ?? []); $q['other_text'] = $raw['other_text'] ?? $raw['allow_input_text'] ?? ''; $q['display_value'] = self::display($q, $q['value'], $keyed); $q['selected_keys'] = self::selectedKeys($q, $q['value'], $keyed); if ($q['edit_input'] === 'multi_dimension') { $q['value'] = self::arrayValue($q['value']); $q['dimensions'] = self::arrayValue($q['dimensions'] ?? []); foreach ($q['dimensions'] as &$dim) { $dim['edit_input'] = $dim['edit_input'] ?? 'radio'; $dim['display_value'] = self::display($dim, $q['value'][$dim['field']] ?? null, $keyed); $dim['selected_keys'] = self::selectedKeys($dim, $q['value'][$dim['field']] ?? null, $keyed); } unset($dim); } $result[] = $q; } // Preserve deleted questions in older answers that carried full definitions. if (!$keyed) { $seen = array_column($result, 'id'); foreach ($map as $raw) { if (isset($raw['id'], $raw['name'], $raw['edit_input']) && !in_array($raw['id'], $seen)) { $result = array_merge($result, self::replyQuestions([$raw], [$raw], ['questions' => [$raw]])); } } } return $result; } }