master
lion 3 weeks ago
parent 98bd3303bb
commit a20be1201e

@ -237,19 +237,36 @@ class CourseContentEvaluationFormController extends BaseController
{
$survey = CourseContentEvaluation::findOrFail(request('course_content_evaluation_id'));
$questionId = (string) request('question_id');
$other = request('kind') === 'other';
$kind=request('kind','value');
$other=$kind==='other';
$optionKey=(string)request('option_key','');
if ($kind==='option' && $optionKey==='') return $this->fail([ResponseCode::ERROR_BUSINESS,'请选择需要查看的选项']);
$current=$survey->courseContentEvaluationAsks()->orderBy('sort')->orderBy('id')->get()->toArray();
$page = max(1, (int) request('page', 1));
$size = min(50, max(1, (int) request('page_size', 20)));
$total = 0;
$items = [];
foreach ($survey->courseContentEvaluationForms()->orderByDesc('id')->cursor() as $response) {
$raw = SurveySchema::answerMap($response->data)[$questionId] ?? [];
$value = $other ? ($raw['other_text'] ?? $raw['allow_input_text'] ?? null) : ($raw['value'] ?? null);
$files = !$other && ($raw['edit_input'] ?? '') === 'file' && is_array($value) ? $value : [];
if ($files) $value = SurveySchema::display($raw, $files, true);
$question=null;
foreach (SurveySchema::replyQuestions($current,$response->data,$response->snapshot) as $q) if ((string)$q['id']===$questionId) { $question=$q;break; }
if (!$question) {
$raw=SurveySchema::answerMap($response->data)[$questionId] ?? [];
if (!$raw) continue;
$question=$raw + ['edit_input'=>'text','other_text'=>$raw['allow_input_text'] ?? ''];
}
if (!empty($question['hidden'])) continue;
$files=[];
if ($other || $kind==='option') {
$value=null;
foreach (SurveySchema::supplements($question) as $note) if ($note['kind']===$kind && ($other || $note['option_key']===$optionKey)) { $value=$note['text'];break; }
} else {
$value=$question['value'] ?? null;
$files=$question['edit_input']==='file' && is_array($value) ? $value : [];
if ($files) $value=$question['display_value'];
}
if (!is_scalar($value) || SurveySchema::blank($value)) continue;
if ($total >= ($page - 1) * $size && count($items) < $size) {
$items[] = ['id' => $response->id, 'text' => (string) $value, 'files' => $files, 'created_at' => (string) $response->created_at];
$items[] = ['id' => $response->id, 'text' => (string) $value, 'respondent'=>$response->respondent, 'files' => $files, 'created_at' => (string) $response->created_at];
}
$total++;
}

@ -30,6 +30,9 @@ class SurveyExport
if ($zip->open($base.'.zip', ZipArchive::CREATE | ZipArchive::EXCL) !== true) throw new \RuntimeException('无法创建导出文件');
$opened = true;
$detail = $book->getActiveSheet()->setTitle('问题详细表');
$supplements = $book->createSheet()->setTitle('补充内容明细');
self::row($supplements,1,['答卷ID','答题人姓名','题目','选项','补充内容','提交时间']);
$sr=2;
$attachments = $book->createSheet()->setTitle('附件清单');
self::row($attachments, 1, ['答卷ID', '答题人姓名', '题目', '原文件名', '压缩包内路径', '状态']);
$dr = 3;
@ -43,10 +46,10 @@ class SurveyExport
foreach (SurveySchema::replyQuestions($current, $reply->data, $reply->snapshot) as $q) {
if ($q['edit_input'] === 'section') continue;
$hidden = !empty($q['hidden']);
$extra = $hidden ? '' : ($q['other_text'] ?? '');
foreach ($q['select_item'] ?? [] as $opt) {
$note = $q['option_texts'][$opt['key']] ?? '';
if (!$hidden && $note !== '') $extra .= ($extra ? "\n" : '').$opt['value'].':'.$note;
$extra='';
foreach (SurveySchema::supplements($q) as $note) {
$extra .= ($extra ? "\n" : '').($note['kind']==='option' ? $note['label'].':' : '').$note['text'];
self::row($supplements,$sr++,[$reply->id,$name,$q['name'],$note['label'],$note['text'],(string)$reply->created_at]);
}
foreach (self::questionFields($q) as [$key, $label, $field]) {
if (!isset($columns[$key])) $columns[$key] = ['column' => count($columns) + 3, 'label' => $label, 'title' => $q['name'], 'group' => (string) ($q['id'] ?? $q['field']), 'dimension' => $q['edit_input'] === 'multi_dimension' ? $field['name'] : null, 'type' => $field['edit_input'] ?? 'text'];

@ -434,6 +434,20 @@ class SurveySchema
return $keys;
}
public static function supplements(array $question): array
{
if (!empty($question['hidden'])) return [];
$result=[];
$other=$question['other_text'] ?? '';
if (is_scalar($other) && trim((string)$other)!=='') $result[]=['kind'=>'other','option_key'=>'','label'=>'题目补充','text'=>(string)$other];
$selected=array_map('strval',$question['selected_keys'] ?? []);
foreach ($question['select_item'] ?? [] as $option) {
$key=(string)$option['key'];$text=$question['option_texts'][$key] ?? '';
if (in_array($key,$selected,true) && is_scalar($text) && trim((string)$text)!=='') $result[]=['kind'=>'option','option_key'=>$key,'label'=>$option['value'],'text'=>(string)$text];
}
return $result;
}
public static function replyQuestions(array $current, $data, $snapshot): array
{
$snapshot = self::arrayValue($snapshot);

@ -24,10 +24,14 @@ class SurveyStatistics
$map = SurveySchema::answerMap($response['data'] ?? []);
$snapshot = SurveySchema::arrayValue($response['snapshot'] ?? []);
$keyed = ($snapshot['version'] ?? 0) >= 2;
$replyQuestions=[];
foreach (SurveySchema::replyQuestions($questions,$response['data'] ?? [],$snapshot) as $answered) $replyQuestions[(string)$answered['id']]=$answered;
foreach ($stats as $id => &$q) {
if ($q['edit_input'] === 'section') {
continue;
}
$answered=$replyQuestions[$id] ?? [];
if (!empty($answered['hidden'])) continue;
$raw = $map[$id] ?? [];
$value = $raw['value'] ?? null;
if ($q['edit_input'] === 'multi_dimension') {
@ -45,8 +49,12 @@ class SurveyStatistics
} else {
self::add($q['stats'], $q, $value, $keyed);
}
if (!SurveySchema::blank($raw['other_text'] ?? $raw['allow_input_text'] ?? null)) {
$q['stats']['other_count']++;
foreach (SurveySchema::supplements($answered) as $note) {
if ($note['kind']==='other') $q['stats']['other_count']++;
else foreach ($q['stats']['options'] as &$option) {
if ($option['key']===$note['option_key']) $option['note_count']++;
}
unset($option);
}
}
unset($q);
@ -82,7 +90,7 @@ class SurveyStatistics
{
return [
'valid_count' => 0, 'other_count' => 0, 'sum' => 0, 'score_sum' => 0, 'score_count' => 0, 'average' => null,
'options' => array_map(fn ($o) => ['key' => (string) $o['key'], 'label' => $o['value'], 'count' => 0, 'percentage' => 0], $field['select_item'] ?? []),
'options' => array_map(fn ($o) => ['key' => (string) $o['key'], 'label' => $o['value'], 'count' => 0, 'percentage' => 0, 'note_count' => 0], $field['select_item'] ?? []),
];
}

@ -0,0 +1,80 @@
<?php
// CLI only. Upload to the Laravel project root, beside artisan.
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
class ImportedSupplyCleanup
{
public static function run($execute, $backupDirectory)
{
$db = \Illuminate\Support\Facades\DB::connection();
foreach (['supply_demands'=>['id','is_proxy','import_key','deleted_at','updated_at'], 'dialogues'=>['id','supply_demand_id'], 'messages'=>['id','supply_demand_id','dialogue_id']] as $table=>$columns) {
if (!$db->getSchemaBuilder()->hasColumns($table,$columns)) throw new \RuntimeException('缺少必要表或字段:'.$table.',已停止');
}
if ($execute && $db->getDriverName() === 'mysql') {
foreach (['supply_demands','dialogues','messages'] as $table) {
$engine=$db->selectOne('SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',[$db->getDatabaseName(),$db->getTablePrefix().$table]);
if (!$engine || strcasecmp($engine->engine,'InnoDB') !== 0) throw new \RuntimeException('表不支持预期事务保护:'.$table.',已停止');
}
}
return $db->transaction(function () use ($db,$execute,$backupDirectory) {
// Raw queries deliberately include previously soft-deleted imported rows.
$demands=$db->table('supply_demands')->where('is_proxy',1)->whereNotNull('import_key')->where('import_key','!=','')->orderBy('id')->lockForUpdate()->get();
$ids=$demands->pluck('id')->all();
$dialogues=$db->table('dialogues')->whereIn('supply_demand_id',$ids)->orderBy('id')->lockForUpdate()->get();
$dialogueIds=$dialogues->pluck('id')->all();
$messages=$db->table('messages')->where(function($q)use($ids,$dialogueIds){$q->whereIn('supply_demand_id',$ids)->orWhereIn('dialogue_id',$dialogueIds);})->orderBy('id')->lockForUpdate()->get();
$report=['database'=>$db->getDatabaseName(),'host'=>$db->getConfig('host'),'mode'=>$execute?'备份并按条件清理':'只读检查', 'imported_demands'=>count($ids),'dialogues'=>$dialogues->count(),'messages'=>$messages->count(),'cleaned'=>0,'backup'=>null];
if (!$execute || !$ids) {
$report['result']=!$ids?'没有需要清理的旧导入记录':($messages->count()?'存在历史私信,不允许清理':'未发现历史私信,可使用 --execute 备份并清理');
return $report;
}
$snapshot=['format_version'=>1,'created_at'=>date(DATE_ATOM),'database'=>$db->getDatabaseName(),'host'=>$db->getConfig('host'),'scope'=>'is_proxy=1 AND import_key IS NOT NULL AND import_key <> empty','supply_demands'=>$demands->all(),'dialogues'=>$dialogues->all(),'messages'=>$messages->all()];
$report['backup']=self::backup($backupDirectory,$snapshot);
if ($messages->count()) {
$report['result']='发现历史私信:已备份,未清理任何业务数据,请先核对';
return $report;
}
$now=date('Y-m-d H:i:s');
$report['cleaned']=$db->table('supply_demands')->whereIn('id',$ids)->where('is_proxy',1)->whereNotNull('import_key')->update(['deleted_at'=>$db->raw('COALESCE(deleted_at, CURRENT_TIMESTAMP)'),'import_key'=>null,'updated_at'=>$now]);
if ($report['cleaned'] !== count($ids)) throw new \RuntimeException('清理数量不符,事务已回滚,请保留备份并核对');
$remaining=$db->table('supply_demands')->whereIn('id',$ids)->where(function($q){$q->whereNull('deleted_at')->orWhereNotNull('import_key');})->exists();
if ($remaining) throw new \RuntimeException('清理结果校验失败,事务已回滚');
$report['result']='已备份并清理旧导入记录及查重标记,可以自行导入新表';
return $report;
});
}
private static function backup($directory, array $snapshot)
{
if (!is_dir($directory) && !mkdir($directory,0700,true) && !is_dir($directory)) throw new \RuntimeException('无法创建备份目录,停止清理');
if (!chmod($directory,0700)) throw new \RuntimeException('无法保护备份目录权限,停止清理');
$file=rtrim($directory,'/').'/imported-supply-'.date('Ymd-His').'-'.bin2hex(random_bytes(4)).'.json';
$json=json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
$handle=fopen($file,'x');
if (!$handle) throw new \RuntimeException('无法创建备份文件,停止清理');
try {
if (!chmod($file,0600)) throw new \RuntimeException('无法保护备份文件权限,停止清理');
$offset=0;$size=strlen($json);
while ($offset<$size) { $written=fwrite($handle,substr($json,$offset)); if (!$written) throw new \RuntimeException('备份写入失败,停止清理'); $offset+=$written; }
if (!fflush($handle)) throw new \RuntimeException('备份刷新失败,停止清理');
if (function_exists('fsync') && !fsync($handle)) throw new \RuntimeException('备份落盘失败,停止清理');
} finally { fclose($handle); }
if (!hash_equals(hash('sha256',$json),hash_file('sha256',$file))) throw new \RuntimeException('备份校验失败,停止清理');
json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
return ['file'=>$file,'sha256'=>hash('sha256',$json)];
}
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
try {
if (!is_file(__DIR__.'/artisan') || !is_file(__DIR__.'/vendor/autoload.php')) throw new \RuntimeException('请把脚本放到包含 artisan 的 Laravel 项目根目录');
require __DIR__.'/vendor/autoload.php';
$app=require __DIR__.'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$args=array_slice($argv,1);
if ($args && $args !== ['--check'] && $args !== ['--execute']) throw new \RuntimeException('用法:php cleanup-imported-supply.php [--check|--execute]');
$report=ImportedSupplyCleanup::run($args === ['--execute'],storage_path('app/private/supply-demand-cleanup'));
// Printed only after the transaction has committed successfully.
echo json_encode($report,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
} catch (\Throwable $e) { fwrite(STDERR,'执行停止:'.$e->getMessage().PHP_EOL); exit(1); }
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,60 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
class ImportedSupplyCleanupTest extends TestCase
{
private $backupDirectory;
protected function setUp(): void {
parent::setUp();
config(['database.default'=>'sqlite','database.connections.sqlite.database'=>':memory:']);DB::purge('sqlite');
require_once base_path('cleanup-imported-supply.php');
$this->backupDirectory=sys_get_temp_dir().'/supply-cleanup-test-'.bin2hex(random_bytes(6));
Schema::create('supply_demands',function(Blueprint $t){$t->id();$t->integer('is_proxy');$t->string('import_key')->nullable();$t->string('content');$t->integer('user_id')->default(0);$t->integer('status')->default(1);$t->timestamps();$t->softDeletes();});
Schema::create('dialogues',function(Blueprint $t){$t->id();$t->integer('supply_demand_id');});
Schema::create('messages',function(Blueprint $t){$t->id();$t->integer('supply_demand_id');$t->integer('dialogue_id');$t->string('content');$t->softDeletes();});
foreach ([[1,1,'import-a',null],[2,1,'import-b','2026-10-20 00:00:00'],[3,1,null,null],[4,0,'self-published',null]] as [$id,$proxy,$key,$deleted]) DB::table('supply_demands')->insert(['id'=>$id,'is_proxy'=>$proxy,'import_key'=>$key,'content'=>'原始内容'.$id,'user_id'=>123,'deleted_at'=>$deleted]);
}
protected function tearDown(): void {
if(is_dir($this->backupDirectory)){foreach(glob($this->backupDirectory.'/*') as $f)unlink($f);rmdir($this->backupDirectory);}
DB::disconnect('sqlite');parent::tearDown();
}
public function test_check_is_read_only_and_execute_backs_up_before_clearing_only_imports() {
$before=DB::table('supply_demands')->orderBy('id')->get();
$check=\ImportedSupplyCleanup::run(false,$this->backupDirectory);
$this->assertSame(2,$check['imported_demands']);$this->assertSame(0,$check['cleaned']);$this->assertFalse(is_dir($this->backupDirectory));
$this->assertEquals($before,DB::table('supply_demands')->orderBy('id')->get());
$done=\ImportedSupplyCleanup::run(true,$this->backupDirectory);
$this->assertSame(2,$done['cleaned']);$this->assertSame(hash_file('sha256',$done['backup']['file']),$done['backup']['sha256']);
$backup=json_decode(file_get_contents($done['backup']['file']),true);
$this->assertSame('import-a',$backup['supply_demands'][0]['import_key']);$this->assertNull($backup['supply_demands'][0]['deleted_at']);
$this->assertSame(0600,fileperms($done['backup']['file']) & 0777);
$this->assertSame(0,DB::table('supply_demands')->whereIn('id',[1,2])->whereNotNull('import_key')->count());
$this->assertSame(2,DB::table('supply_demands')->whereIn('id',[1,2])->whereNotNull('deleted_at')->count());
$this->assertSame('2026-10-20 00:00:00',DB::table('supply_demands')->where('id',2)->value('deleted_at'));
foreach([3,4] as $id)$this->assertEquals($before->firstWhere('id',$id),DB::table('supply_demands')->where('id',$id)->first());
$this->assertSame(123,DB::table('supply_demands')->where('id',1)->value('user_id'));
$this->assertSame(0,\ImportedSupplyCleanup::run(true,$this->backupDirectory)['cleaned']);
}
public function test_any_private_message_including_deleted_or_dialogue_only_stops_cleanup_after_backup() {
DB::table('dialogues')->insert(['id'=>10,'supply_demand_id'=>1]);
DB::table('messages')->insert(['id'=>1,'supply_demand_id'=>0,'dialogue_id'=>10,'content'=>'历史消息','deleted_at'=>'2026-10-20 00:00:00']);
$before=DB::table('supply_demands')->get();
$report=\ImportedSupplyCleanup::run(true,$this->backupDirectory);
$this->assertSame(1,$report['messages']);$this->assertSame(0,$report['cleaned']);
$this->assertFileExists($report['backup']['file']);
$backup=json_decode(file_get_contents($report['backup']['file']),true);
$this->assertSame('历史消息',$backup['messages'][0]['content']);
$this->assertEquals($before,DB::table('supply_demands')->get());
$this->assertSame(1,DB::table('messages')->count());$this->assertSame(1,DB::table('dialogues')->count());
}
public function test_backup_failure_prevents_business_changes() {
$before=DB::table('supply_demands')->get();
try { \ImportedSupplyCleanup::run(true,'/dev/null/unwritable');$this->fail('Expected backup failure'); }
catch(\Throwable $e){$this->assertStringNotContainsString('Expected backup failure',$e->getMessage());}
$this->assertEquals($before,DB::table('supply_demands')->get());
}
}

@ -489,6 +489,38 @@ class SurveyWorkflowTest extends TestCase
$this->assertSame([$chapter, $id], DB::table('course_content_evaluation_asks')->orderBy('sort')->pluck('id')->all());
}
public function testOptionSupplementCountsListsRespondentsAndExportStayConsistent(): void
{
Schema::table('users',function(Blueprint $t){$t->string('name')->nullable();$t->string('company_name')->nullable();});
DB::table('users')->insert([['id'=>200,'name'=>'甲学员','company_name'=>'甲公司'],['id'=>201,'name'=>'乙学员','company_name'=>'乙公司']]);
$questions=[
['id'=>1,'name'=>'企业问诊会场','field'=>'venues','edit_input'=>'checkbox','select_item'=>[['key'=>'a','value'=>'财税'],['key'=>'b','value'=>'法律'],['key'=>'c','value'=>'知识产权']],'config'=>['visible_when'=>['field'=>'attend','values'=>['yes']]]],
['id'=>2,'name'=>'参加','field'=>'attend','edit_input'=>'radio','select_item'=>[['key'=>'yes','value'=>'是'],['key'=>'no','value'=>'否']]],
];
$snapshot=['version'=>2,'questions'=>$questions];
foreach ([
[200,'yes',['a','b'],['a'=>'税务咨询','b'=>'合同问题','c'=>'未选中旧内容'],'其他建议'],
[201,'yes',['a'],['a'=>' '],''],
[201,'no',['a'],['a'=>'隐藏题旧内容'],'隐藏题补充'],
] as [$user,$attend,$selected,$notes,$other]) \App\Models\CourseContentEvaluationForm::create(['course_content_evaluation_id'=>10,'user_id'=>$user,'snapshot'=>$snapshot,'data'=>[['id'=>1,'value'=>$selected,'option_texts'=>$notes,'other_text'=>$other],['id'=>2,'value'=>$attend]]]);
$this->getJson('/api/admin/course-content-evaluation-form/statistics?course_content_evaluation_id=10')->assertJsonPath('questions.0.stats.valid_count',2)->assertJsonPath('questions.0.stats.options.0.note_count',1)->assertJsonPath('questions.0.stats.options.1.note_count',1)->assertJsonPath('questions.0.stats.options.2.note_count',0)->assertJsonPath('questions.0.stats.other_count',1);
$url='/api/admin/course-content-evaluation-form/text-answers?course_content_evaluation_id=10&question_id=1';
$this->getJson($url.'&kind=option&option_key=a')->assertJsonPath('total',1)->assertJsonPath('data.0.text','税务咨询')->assertJsonPath('data.0.respondent.name','甲学员')->assertJsonPath('data.0.respondent.company_name','甲公司');
$this->getJson($url.'&kind=option&option_key=b')->assertJsonPath('total',1)->assertJsonPath('data.0.text','合同问题');
$this->getJson($url.'&kind=option&option_key=c')->assertJsonPath('total',0);
$this->getJson($url.'&kind=other')->assertJsonPath('total',1)->assertJsonPath('data.0.text','其他建议');
$this->getJson($url.'&kind=option')->assertJsonPath('errcode',10002);
$path=\App\Services\Survey\SurveyExport::build(\App\Models\CourseContentEvaluation::find(10));
$zip=new \ZipArchive();$zip->open($path);file_put_contents($path.'.xlsx',$zip->getFromName('问卷答卷明细.xlsx'));
$book=\PhpOffice\PhpSpreadsheet\IOFactory::load($path.'.xlsx');
try {
$detail=$book->getSheetByName('问题详细表');$this->assertStringContainsString('财税:税务咨询',$detail->getCell('C3')->getValue());$this->assertStringContainsString('法律:合同问题',$detail->getCell('C3')->getValue());
$notes=$book->getSheetByName('补充内容明细');$this->assertSame(4,$notes->getHighestRow());
$this->assertSame(['其他建议','税务咨询','合同问题'],array_column($notes->rangeToArray('A2:F4'),4));
$this->assertSame('甲学员',$notes->getCell('B2')->getValue());
} finally {$book->disconnectWorksheets();$zip->close();unlink($path.'.xlsx');unlink($path);}
}
public function testTextAnswersArePaginatedAndZeroRemainsVisible(): void
{
for ($i = 0; $i < 22; $i++) DB::table('course_content_evaluation_forms')->insert(['course_content_evaluation_id' => 10, 'data' => json_encode([['id' => 5, 'value' => $i === 0 ? '0' : '建议' . $i]])]);

Loading…
Cancel
Save