feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioStream;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use DomainException;
final class FollowupAudioController extends BaseAdminController
{
public function capabilities()
{
return $this->handle(false, ['diagnosis_id'], fn (array $p): array =>
Logic::capabilities($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo));
}
public function uploadSession()
{
return $this->handle(true, ['diagnosis_id', 'file_name', 'total_bytes'], function (array $p): array {
Logic::requireEnabled(true);
return Upload::createSession($this->positive($p, 'diagnosis_id'), $this->textValue($p, 'file_name', 240),
$this->positive($p, 'total_bytes'), $this->adminId, $this->adminInfo);
});
}
public function uploadChunk()
{
return $this->handle(true, ['upload_id', 'index'], function (array $p): array {
Logic::requireEnabled(true);
$index = $p['index'] ?? null;
if (!(is_int($index) || is_string($index)) || !preg_match('/^\d{1,5}$/D', (string) $index)) {
throw new DomainException('录音分片序号无效');
}
$file = $this->request->file('file');
if (!$file instanceof \think\file\UploadedFile || !$file->isValid()) {
throw new DomainException('录音分片上传失败,请检查文件大小限制');
}
return Upload::putChunk($this->textValue($p, 'upload_id', 64), (int) $index,
$file->getPathname(), $this->adminId, $this->adminInfo);
});
}
public function uploadComplete()
{
return $this->handle(true, ['upload_id'], function (array $p): array {
Logic::requireEnabled(true);
return Upload::complete($this->textValue($p, 'upload_id', 64), $this->adminId, $this->adminInfo);
});
}
public function create()
{
return $this->handle(true, ['diagnosis_id', 'upload_id', 'recorded_at', 'model_key'], fn (array $p): array =>
Logic::create([
'diagnosis_id' => $this->positive($p, 'diagnosis_id'),
'upload_id' => $this->textValue($p, 'upload_id', 64),
'recorded_at' => $this->textValue($p, 'recorded_at', 19),
'model_key' => $this->textValue($p, 'model_key', 16),
], $this->adminId, $this->adminInfo));
}
public function lists()
{
return $this->handle(false, ['diagnosis_id'], fn (array $p): array =>
Logic::lists($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo));
}
public function detail()
{
return $this->handle(false, ['id'], fn (array $p): array =>
Logic::detail($this->positive($p, 'id'), $this->adminId, $this->adminInfo));
}
public function saveDraft()
{
return $this->handle(true, ['id', 'version', 'items'], fn (array $p): array =>
Logic::saveDraft($this->positive($p, 'id'), $this->positive($p, 'version'),
$this->items($p), $this->adminId, $this->adminInfo));
}
public function apply()
{
return $this->handle(true, ['id', 'version', 'items'], fn (array $p): array =>
Logic::apply($this->positive($p, 'id'), $this->positive($p, 'version'),
$this->items($p), $this->adminId, $this->adminInfo));
}
public function retry()
{
return $this->handle(true, ['id'], fn (array $p): array =>
Logic::retry($this->positive($p, 'id'), $this->adminId, $this->adminInfo));
}
public function audio()
{
return $this->handle(false, ['id'], function (array $p): FollowupAudioStream {
$task = Access::task($this->positive($p, 'id'), $this->adminId, $this->adminInfo);
$upload = Upload::session((string) $task['upload_id']);
return new FollowupAudioStream(Upload::path($upload), (string) $upload['extension']);
});
}
private function handle(bool $post, array $allowed, callable $handler)
{
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
return $this->fail('请求方式错误');
}
$params = $post ? $this->request->post() : $this->request->get();
if (array_diff(array_keys($params), $allowed) !== []) {
return $this->fail('请求包含不支持的字段');
}
try {
$this->adminInfo = Access::actor($this->adminId);
$result = $handler($params);
return $result instanceof \think\Response ? $result : $this->data($result);
} catch (DomainException $e) {
if (str_contains($e->getMessage(), 'FOLLOWUP_AUDIO_STALE_REVIEW')) {
return $this->fail('病历或日常记录已发生变化,请重新审阅差异后确认', [
'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW',
]);
}
return $this->fail($e->getMessage());
} catch (\Throwable $e) {
// Never expose raw SQL, audio paths, transcripts, provider responses or keys.
return $this->fail('回访录音服务暂不可用,请联系管理员检查部署');
}
}
private function positive(array $p, string $key): int
{
$raw = $p[$key] ?? null;
if (!(is_int($raw) || is_string($raw)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $raw)) {
throw new DomainException('记录标识或版本无效');
}
return (int) $raw;
}
private function textValue(array $p, string $key, int $max): string
{
$raw = $p[$key] ?? '';
if (!is_string($raw) || strlen($raw) > $max) {
throw new DomainException('文本参数无效');
}
return trim($raw);
}
private function items(array $p): array
{
$items = $p['items'] ?? null;
if (!is_array($items) || !array_is_list($items) || count($items) > 500
|| strlen(json_encode($items, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)) > 2097152) {
throw new DomainException('审阅项目无效或数量超限');
}
return $items;
}
}
@@ -73,6 +73,18 @@ class AuthMiddleware
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// This group must never use the legacy unregistered-route bypass.
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio'];
$action = substr($accessUri, strlen('tcm.followupaudio/'));
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)) {
return $next($request); // The service also checks the current patient scope and daily permissions.
}
return JsonService::fail('权限不足,无法访问或操作');
}
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
@@ -277,8 +277,8 @@ class DiagnosisLogic extends BaseLogic
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
// 处理既往史为数组
if (!empty($diagnosis['past_history'])) {
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
if (($diagnosis['past_history'] ?? null) !== null && $diagnosis['past_history'] !== '') {
$diagnosis['past_history'] = explode(',', (string) $diagnosis['past_history']);
} else {
$diagnosis['past_history'] = [];
}
@@ -291,8 +291,8 @@ class DiagnosisLogic extends BaseLogic
];
foreach ($multiSelectFields as $field) {
if (!empty($diagnosis[$field])) {
$diagnosis[$field] = explode(',', $diagnosis[$field]);
if (($diagnosis[$field] ?? null) !== null && $diagnosis[$field] !== '') {
$diagnosis[$field] = explode(',', (string) $diagnosis[$field]);
} else {
$diagnosis[$field] = [];
}
@@ -379,8 +379,8 @@ class DiagnosisLogic extends BaseLogic
}
// 处理既往史为数组
if (!empty($diagnosis['past_history'])) {
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
if (($diagnosis['past_history'] ?? null) !== null && $diagnosis['past_history'] !== '') {
$diagnosis['past_history'] = explode(',', (string) $diagnosis['past_history']);
} else {
$diagnosis['past_history'] = [];
}
@@ -393,8 +393,8 @@ class DiagnosisLogic extends BaseLogic
];
foreach ($multiSelectFields as $field) {
if (!empty($diagnosis[$field])) {
$diagnosis[$field] = explode(',', $diagnosis[$field]);
if (($diagnosis[$field] ?? null) !== null && $diagnosis[$field] !== '') {
$diagnosis[$field] = explode(',', (string) $diagnosis[$field]);
} else {
$diagnosis[$field] = [];
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use DomainException;
final class FollowupAudioLogic
{
public static function capabilities(int $diagnosisId, int $actor, array $info): array
{
$diagnosis = Access::diagnosis($diagnosisId, $actor, $info);
$enabled = Store::enabled();
$verified = Store::verified();
$daily = Access::canDaily($actor, $info);
return [
'enabled' => $enabled, 'audio_verified' => $verified,
'can_upload' => $enabled && $verified, 'can_apply' => $enabled && $verified,
'can_daily' => $daily, 'limits' => Upload::limits(),
'models' => array_values(array_filter(
[['value' => 'qwen', 'label' => '千问'], ['value' => 'openai', 'label' => 'OpenAI']],
static fn (array $model): bool => Store::verified($model['value'])
)),
// No migration/dictionary dependency is introduced when the feature is OFF.
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
];
}
public static function requireEnabled(bool $verified = false): void
{
if (!Store::enabled()) {
throw new DomainException('回访录音功能尚未启用');
}
if ($verified && !Store::verified()) {
throw new DomainException('Dify 音频能力尚未验证,暂不接受真实录音或写入');
}
}
public static function create(array $p, int $actor, array $info): array
{
self::requireEnabled(true);
Access::diagnosis($p['diagnosis_id'], $actor, $info);
$upload = Upload::owned($p['upload_id'], $actor, $info);
if ((int) $upload['diagnosis_id'] !== $p['diagnosis_id'] || $upload['status'] !== 'complete') {
throw new DomainException('录音与当前诊单不匹配或尚未上传完成');
}
Upload::path($upload);
$date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $p['recorded_at'], new \DateTimeZone('Asia/Shanghai'));
if (!$date || $date->format('Y-m-d H:i:s') !== $p['recorded_at'] || $date->getTimestamp() > time() + 300) {
throw new DomainException('请填写正确的实际通话时间(北京时间),不能晚于当前时间');
}
if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::verified($p['model_key'])) {
throw new DomainException('所选模型音频能力尚未验证');
}
return Store::create($upload, $p['recorded_at'], $p['model_key'], $actor, $info);
}
public static function lists(int $diagnosisId, int $actor, array $info): array
{
Access::diagnosis($diagnosisId, $actor, $info);
return ['items' => Store::enabled() ? Store::lists($diagnosisId) : []];
}
public static function detail(int $id, int $actor, array $info): array
{
Access::task($id, $actor, $info);
return self::protectDetail(Store::detail($id), $actor, $info);
}
public static function saveDraft(int $id, int $version, array $items, int $actor, array $info): array
{
self::requireEnabled();
$task = Access::task($id, $actor, $info);
self::checkDailyItems($task, $items, $actor, $info);
return self::protectDetail(Store::saveDraft($id, $version, $items, $actor), $actor, $info);
}
public static function apply(int $id, int $version, array $items, int $actor, array $info): array
{
self::requireEnabled(true);
$task = Access::task($id, $actor, $info);
self::checkDailyItems($task, $items, $actor, $info);
return Apply::apply($id, $version, $items, $actor, $info);
}
public static function retry(int $id, int $actor, array $info): array
{
self::requireEnabled(true);
Access::task($id, $actor, $info);
return Store::retry($id);
}
private static function checkDailyItems(array $task, array $items, int $actor, array $info): void
{
$diagnosis = Access::diagnosis((int) $task['diagnosis_id'], $actor, $info);
$plain = Access::allowed($actor, Access::actor($actor), 'tcm.diagnosis/phonePlain');
foreach ($items as $item) {
if (!is_array($item)) {
throw new DomainException('审阅内容无效');
}
if (!empty($item['selected']) && ($item['kind'] ?? '') === 'diagnosis' && !$plain) {
foreach (['phone', 'id_card'] as $key) {
if ($key === 'id_card' && trim((string) ($diagnosis[$key] ?? '')) === '') {
continue;
}
if (array_key_exists($key, (array) ($item['values'] ?? []))
&& (string) $item['values'][$key] !== (string) ($diagnosis[$key] ?? '')) {
throw new DomainException('无权通过录音修改已有手机号或身份证');
}
}
}
if (!empty($item['selected']) && ($item['kind'] ?? '') !== 'diagnosis') {
Access::diagnosis((int) $task['diagnosis_id'], $actor, $info, true);
// Continue to validate identity suggestions later in this same batch.
}
}
}
private static function catalogForActor(array $diagnosis, int $actor, array $info): array
{
$info = Access::actor($actor);
$catalog = Fields::catalog();
$basic = DiagnosisLogic::canEditPatientBasicInfo((int) $diagnosis['id'], $info);
$plain = Access::allowed($actor, $info, 'tcm.diagnosis/phonePlain');
foreach ($catalog['diagnosis'] as &$field) {
$key = $field['key'];
if ((!$basic && in_array($key, ['patient_name', 'id_card', 'phone', 'gender', 'age'], true))
|| (!$plain && ($key === 'phone' || ($key === 'id_card' && !empty($diagnosis['id_card']))))) {
$field['readonly'] = true;
}
}
unset($field);
return $catalog;
}
private static function protectDetail(array $detail, int $actor, array $info): array
{
$diagnosis = Access::diagnosis((int) $detail['diagnosis_id'], $actor, $info);
$detail['fields'] = self::catalogForActor($diagnosis, $actor, $info);
if (!Access::allowed($actor, Access::actor($actor), 'tcm.diagnosis/phonePlain')) {
foreach ($detail['items'] as &$item) {
foreach (['phone', 'id_card'] as $key) {
$value = (string) ($item['current_values'][$key] ?? '');
if ($value !== '') {
$item['current_values'][$key] = mb_substr($value, 0, 3) . '****' . mb_substr($value, -4);
}
}
}
unset($item);
}
return $detail;
}
}
@@ -4,6 +4,8 @@ namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\TrackingNote;
use app\common\model\tcm\Diagnosis;
use think\facade\Db;
/**
* 诊单跟踪备注 Logic
@@ -36,27 +38,12 @@ class TrackingNoteLogic extends BaseLogic
return false;
}
$today = date('Y-m-d');
$time = date('H:i');
$line = "[{$time}] {$newContent}";
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
->where('note_date', $today)
->whereNull('delete_time')
->find();
if ($existing) {
$prev = trim((string) ($existing->content ?? ''));
$existing->content = $prev !== '' ? ($prev . "\n" . $line) : $line;
$existing->save();
} else {
TrackingNote::create([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'note_date' => $today,
'content' => $line,
]);
}
// Legacy entry point deliberately ignores any client-supplied note_date.
// It shares the diagnosis lock with historical appends to avoid lost updates.
$today = (new \DateTimeImmutable('now', new \DateTimeZone('Asia/Shanghai')))->format('Y-m-d');
Db::transaction(static function () use ($diagnosisId, $today, $newContent, $adminId): void {
self::appendLocked($diagnosisId, $today, $newContent, $adminId);
});
return true;
} catch (\Exception $e) {
@@ -65,6 +52,75 @@ class TrackingNoteLogic extends BaseLogic
}
}
/**
* Explicit historical-date append for trusted application services.
*
* Caller MUST authorize diagnosis + daily-record scope and hold an active
* transaction. This method never starts/commits one: task, note and audit writes
* must roll back together. The diagnosis lock serializes the missing-row case
* with legacy appends; the note lock protects read-modify-write.
*
* @return int Actual tracking_note id (existing row or newly inserted row).
* @throws \DomainException Invalid inputs; database exceptions propagate.
*/
public static function appendForDate(int $diagnosisId, string $noteDate, string $content, int $actorId): int
{
if ($diagnosisId <= 0 || $actorId <= 0) {
throw new \DomainException('诊单或操作人缺失');
}
$content = trim($content);
if ($content === '') {
throw new \DomainException('备注内容不能为空');
}
return self::appendLocked($diagnosisId, self::normalizeNoteDate($noteDate), $content, $actorId);
}
private static function normalizeNoteDate(string $value): string
{
$value = trim($value);
$zone = new \DateTimeZone('Asia/Shanghai');
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, $zone);
if (!preg_match('/^[1-9]\d{3}-\d{2}-\d{2}$/D', $value) || !$date || $date->format('Y-m-d') !== $value) {
throw new \DomainException('跟踪备注日期无效,请使用 YYYY-MM-DD');
}
if ($value > (new \DateTimeImmutable('now', $zone))->format('Y-m-d')) {
throw new \DomainException('跟踪备注日期不能晚于今天');
}
return $value;
}
private static function appendLocked(int $diagnosisId, string $noteDate, string $content, int $actorId): int
{
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->lock(true)->find();
if (!$diagnosis) {
throw new \DomainException('诊单不存在');
}
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
->where('note_date', $noteDate)
->lock(true)
->find();
// The table's diagnosis/date unique key includes soft-deleted rows.
// Never resurrect a deleted note or silently replace its historic content.
if ($existing && $existing->delete_time !== null) {
throw new \DomainException('当日跟踪备注已删除,请先核实');
}
$time = (new \DateTimeImmutable('now', new \DateTimeZone('Asia/Shanghai')))->format('H:i');
$line = "[{$time}] {$content}";
if ($existing) {
$previous = trim((string) ($existing->content ?? ''));
$existing->content = $previous !== '' ? $previous . "\n" . $line : $line;
$existing->save();
return (int) $existing->id;
}
$record = TrackingNote::create([
'diagnosis_id' => $diagnosisId,
'admin_id' => $actorId,
'note_date' => $noteDate,
'content' => $line,
]);
return (int) $record->id;
}
/**
* 按 diagnosis_id 获取跟踪备注列表(note_date DESC)
*