You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

446 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services\Survey;
use InvalidArgumentException;
/** Shared rules for server-owned question definitions, typed answers and legacy replies. */
class SurveySchema
{
public static function arrayValue($value): array
{
if (is_string($value)) {
$value = json_decode($value, true);
}
return is_array($value) ? $value : [];
}
public static function blank($value): bool
{
return $value === null || $value === [] || (is_string($value) && trim($value) === '');
}
public static function required(array $field): bool
{
return !empty($field['need_fill']) || in_array('required', explode(',', $field['rule'] ?? ''), true);
}
public static function question(array $q): array
{
$types = ['section', 'radio', 'checkbox', 'text', 'textarea', 'number', 'date', 'datetime', 'multi_dimension', 'file'];
if (!in_array($q['edit_input'] ?? '', $types, true)) {
throw new InvalidArgumentException('不支持的题型');
}
if (self::blank($q['name'] ?? null) || mb_strlen($q['name']) > 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;
}
}