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.

259 lines
7.5 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\Support;
use App\Models\Application;
use App\Models\Competition;
/**
* 报名表 schema 字段与 applications 表列对齐。
* 标量列名单从 Application::$fillable 自动派生;新增数据库列后只需加入 fillable 即可全链路生效。
*/
final class SignupApplicationFieldRegistry
{
public const COMMITMENT_KEY = 'commitment_accepted';
public const PROMISE_SIGNATURE_KEY = 'promise_signature';
/** @var list<string> */
public const FILE_FIELD_KEYS = ['plan', 'supporting'];
/** 无专属类型约束时,标量字段允许的 schema type */
private const GENERIC_SCALAR_TYPES = [
'text',
'textarea',
'email',
'tel',
'select',
'number',
'date',
];
/**
* 可选:为常用字段收窄允许的 schema type未列出的 fillable 列使用 GENERIC_SCALAR_TYPES。
*
* @var array<string, list<string>>
*/
private const SCALAR_TYPE_HINTS = [
'player_name' => ['text'],
'school' => ['text'],
'degree' => ['select'],
'contact_email' => ['email', 'text'],
'contact_mobile' => ['tel', 'text'],
'company_name' => ['text'],
'project_name' => ['text'],
'track' => ['select'],
'location_country' => ['select'],
'location_province' => ['select', 'text'],
'location_city' => ['select', 'text'],
'oversea_country' => ['text'],
'event_location' => ['select', 'text'],
'team_members' => ['text', 'textarea'],
'intro' => ['textarea', 'text'],
'recommend' => ['text', 'textarea'],
];
/**
* @return list<string>
*/
public static function scalarColumnKeys(): array
{
return Application::signupScalarFillableKeys();
}
/**
* @return list<string>
*/
public static function allSupportedFormKeys(): array
{
return array_values(array_unique([
...self::scalarColumnKeys(),
...self::FILE_FIELD_KEYS,
self::COMMITMENT_KEY,
]));
}
/**
* 供管理端报名表编辑器拉取:与后端 fillable 实时同步。
*
* @return array{
* scalar_field_keys: list<string>,
* file_field_keys: list<string>,
* commitment_field_key: string,
* scalar_type_hints: array<string, list<string>>,
* generic_scalar_types: list<string>,
* all_supported_form_keys: list<string>
* }
*/
public static function manifest(): array
{
$hints = [];
foreach (self::scalarColumnKeys() as $key) {
if (isset(self::SCALAR_TYPE_HINTS[$key])) {
$hints[$key] = self::SCALAR_TYPE_HINTS[$key];
}
}
return [
'scalar_field_keys' => self::scalarColumnKeys(),
'file_field_keys' => self::FILE_FIELD_KEYS,
'commitment_field_key' => self::COMMITMENT_KEY,
'scalar_type_hints' => $hints,
'generic_scalar_types' => self::GENERIC_SCALAR_TYPES,
'all_supported_form_keys' => self::allSupportedFormKeys(),
];
}
/**
* @param array<int, mixed>|null $schemaJson
* @return list<string>
*/
public static function keysFromSchemaJson(?array $schemaJson): array
{
if (! is_array($schemaJson)) {
return [];
}
$keys = [];
foreach ($schemaJson as $row) {
if (! is_array($row)) {
continue;
}
$key = trim((string) ($row['key'] ?? ''));
if ($key !== '') {
$keys[] = $key;
}
}
return array_values(array_unique($keys));
}
public static function schemaHasKey(Competition $competition, string $key): bool
{
$competition->loadMissing('formSchema');
$rows = $competition->formSchema?->schema_json;
return in_array($key, self::keysFromSchemaJson(is_array($rows) ? $rows : null), true);
}
/**
* @return list<string>
*/
public static function allowedTypesForScalarKey(string $key): array
{
return self::SCALAR_TYPE_HINTS[$key] ?? self::GENERIC_SCALAR_TYPES;
}
/**
* @param array<int, mixed> $schemaJson
*/
public static function validateSignupSchemaJson(array $schemaJson): ?string
{
$seen = [];
$scalarKeys = array_flip(self::scalarColumnKeys());
foreach ($schemaJson as $index => $row) {
if (! is_array($row)) {
return '报名表字段定义第 '.($index + 1).' 项格式无效';
}
$key = trim((string) ($row['key'] ?? ''));
$type = trim((string) ($row['type'] ?? ''));
$label = trim((string) ($row['label'] ?? ''));
if ($key === '') {
return '报名表存在未填写字段 key 的项(须与 applications 表列名一致)';
}
if ($label === '') {
return '字段「'.$key.'」缺少显示标签';
}
if (isset($seen[$key])) {
return '字段 key「'.$key.'」重复';
}
$seen[$key] = true;
if ($key === self::COMMITMENT_KEY) {
if ($type !== 'checkbox') {
return '参赛承诺书字段须为 checkbox 类型key='.self::COMMITMENT_KEY.'';
}
continue;
}
if (in_array($key, self::FILE_FIELD_KEYS, true)) {
if ($type !== 'file') {
return '附件字段「'.$key.'」须为 file 类型';
}
continue;
}
if (! isset($scalarKeys[$key])) {
if (! in_array($type, self::GENERIC_SCALAR_TYPES, true)) {
return '自定义字段「'.$key.'」类型须为:'.implode(' / ', self::GENERIC_SCALAR_TYPES);
}
continue;
}
$allowedTypes = self::allowedTypesForScalarKey($key);
if ($type === '' || ! in_array($type, $allowedTypes, true)) {
return '字段「'.$key.'」类型须为:'.implode(' / ', $allowedTypes);
}
}
return null;
}
/**
* @return array<string, mixed>
*/
public static function scalarAttributesFromApplication(Application $app): array
{
$out = [];
foreach (self::scalarColumnKeys() as $key) {
$out[$key] = $app->{$key};
}
return $out;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function filterFillableScalarData(array $data): array
{
$allowed = array_flip(self::scalarColumnKeys());
$out = [];
foreach ($data as $key => $value) {
if (isset($allowed[$key])) {
$out[$key] = $value;
}
}
return $out;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function filterAnswersJsonData(array $data): array
{
$columnKeys = array_flip(self::scalarColumnKeys());
$out = [];
foreach ($data as $key => $value) {
if (! is_string($key) || $key === '' || isset($columnKeys[$key])) {
continue;
}
if (in_array($key, self::FILE_FIELD_KEYS, true) || $key === self::COMMITMENT_KEY || $key === self::PROMISE_SIGNATURE_KEY) {
continue;
}
$out[$key] = $value;
}
return $out;
}
}