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.

341 lines
16 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'];
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'] ?? []);
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;
}
}