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'] ?? []); 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); } private static function field(array $field): array { $config = $field['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('其他选项必须属于本题选项'); } $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; } 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'] = ''; 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'] ?? []); $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)); return $type === 'checkbox' ? $keys : $keys[0]; } 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 (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['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; } }