|
|
<?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;
|
|
|
}
|
|
|
}
|