Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
564e48c0
Commit
564e48c0
authored
Jul 28, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: fix/migrate-concurrently-txn → main(解 migrate 卡死:多语句迁移不能写 CONCURRENTLY)
parents
6333137a
8dba340f
Pipeline
#3464
failed in 0 seconds
Changes
8
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
135 additions
and
27 deletions
+135
-27
apps/pac-service/prisma/migrations/20260727070000_add_patient_transactions_sync_log_id_idx/migration.sql
+7
-3
apps/pac-service/prisma/migrations/20260727110000_add_patient_transactions_host_subject_patient_idx/migration.sql
+3
-2
apps/pac-service/prisma/migrations/20260728020000_persona_features_tag_filter_indexes/migration.sql
+35
-18
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
+29
-0
apps/pac-service/tests/plan-event-log.spec.ts
+33
-0
apps/pac-web/src/components/plans/plans-api.ts
+4
-0
apps/pac-web/src/components/plans/use-plan-aggregate.ts
+5
-0
packages/types/src/enums/index.ts
+19
-4
No files found.
apps/pac-service/prisma/migrations/20260727070000_add_patient_transactions_sync_log_id_idx/migration.sql
View file @
564e48c0
...
@@ -20,9 +20,13 @@
...
@@ -20,9 +20,13 @@
-- 故用 CONCURRENTLY(不锁写)。
-- 故用 CONCURRENTLY(不锁写)。
--
--
-- ⚠️ 关于「Prisma migrate 有事务包裹所以跑不了 CONCURRENTLY」——
-- ⚠️ 关于「Prisma migrate 有事务包裹所以跑不了 CONCURRENTLY」——
-- 那是 20260701060607 里的说法,**在当前 Prisma 6.19 已不成立**:2026-07-27 本地实测
-- 本条实测能随 migrate deploy 跑通,但**别把它总结成「Prisma 新版已经没事务包裹了」**
-- `prisma migrate deploy` 直接把本文件跑通、索引建出、plan 生效。所以本条**可以**随
-- (本文件原来就是这么写的,错的归因,2026-07-28 已订正)。
-- migrate deploy 走,不需要 `migrate resolve` 那套。
-- 真正的分界线是**本文件里的语句条数**:Prisma 把整份 migration.sql 用一次 simple query
-- 发给 Postgres,而 Postgres 只对「一个查询串里多条语句」隐式开事务块。
-- 本文件只有 1 条语句 → 没有隐式事务 → CONCURRENTLY 跑得通;
-- 多条语句的迁移里写 CONCURRENTLY 必挂 25001,还会把 _prisma_migrations 堵死(P3018)。
-- 2026-07-28 的 20260728020000 就是这么炸的,那份文件头有完整复盘。
--
--
-- 但**推荐仍手动先建**,理由与事务无关,是停机时间:
-- 但**推荐仍手动先建**,理由与事务无关,是停机时间:
-- deploy 时 pac-service 通过 depends_on 等 pac-migrate 退出才启动,而 33/64GB 建索引要几分钟
-- deploy 时 pac-service 通过 depends_on 等 pac-migrate 退出才启动,而 33/64GB 建索引要几分钟
...
...
apps/pac-service/prisma/migrations/20260727110000_add_patient_transactions_host_subject_patient_idx/migration.sql
View file @
564e48c0
...
@@ -22,8 +22,9 @@
...
@@ -22,8 +22,9 @@
--
--
-- 【CONCURRENTLY 与上线方式】同 20260727070000(sync_log_id 那条):
-- 【CONCURRENTLY 与上线方式】同 20260727070000(sync_log_id 那条):
-- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表数分钟,摄入与 push 全挂,故用 CONCURRENTLY。
-- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表数分钟,摄入与 push 全挂,故用 CONCURRENTLY。
-- 当前 Prisma 6.19 可以直接 `migrate deploy` 跑通(20260701060607 里"有事务包裹跑不了"的
-- 本条可以直接 `migrate deploy` 跑通 —— 但原因是**本文件只有 1 条语句**(Postgres 只对
-- 说法已不成立,2026-07-27 实测)。但**推荐部署前手动先建**:deploy 时 pac-service 要等
-- 多语句查询串隐式开事务块),不是「Prisma 新版没有事务包裹了」。原注释那句错误归因
-- 2026-07-28 已订正,详见 20260727070000 与 20260728020000 的文件头。但**推荐部署前手动先建**:deploy 时 pac-service 要等
-- pac-migrate 退出才启动,几分钟索引构建 = 白等的停机。手动建好后本条 IF NOT EXISTS 空跑:
-- pac-migrate 退出才启动,几分钟索引构建 = 白等的停机。手动建好后本条 IF NOT EXISTS 空跑:
-- docker exec pac-postgres-1 psql -U pac -d pac -c \
-- docker exec pac-postgres-1 psql -U pac -d pac -c \
-- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" \
-- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" \
...
...
apps/pac-service/prisma/migrations/20260728020000_persona_features_tag_filter_indexes/migration.sql
View file @
564e48c0
...
@@ -45,10 +45,27 @@
...
@@ -45,10 +45,27 @@
-- 生产实测 14 条合计 **220MB**(表 5.4GB,占 4%)。写入侧同理:插一行特征至多多维护 1 条索引,
-- 生产实测 14 条合计 **220MB**(表 5.4GB,占 4%)。写入侧同理:插一行特征至多多维护 1 条索引,
-- 画像重算不会被拖垮。
-- 画像重算不会被拖垮。
--
--
-- 【CONCURRENTLY 与上线方式】同 20260727110000:普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表,
-- 【⚠️ 上线方式:大表环境必须部署前手工 CONCURRENTLY 先建】
-- 画像重算与摄入全挂,故用 CONCURRENTLY。**推荐部署前手动先建**(deploy 时 pac-service 要等
-- 本文件里写的是**普通** CREATE INDEX(会 ACCESS EXCLUSIVE 锁全表),这是被迫的,见下条。
-- pac-migrate 退出,14 条索引串行构建 = 白等的停机);手动建好后本文件全部 IF NOT EXISTS 空跑。
-- 所以对**已有数据的库**,部署前必须先手工建好,让本文件的 IF NOT EXISTS 空跑:
-- 生产实测单条 4-17s,14 条共 **88 秒**(不锁表,期间业务照常);测试服磁盘慢,单条约 42s。
-- docker exec pac-postgres-1 psql -U pac -d pac -c "CREATE INDEX CONCURRENTLY IF NOT EXISTS ..."
-- (托管 PG 无 postgres 容器,用 pac-service 容器里的 node + prisma $executeRawUnsafe 发同样的 DDL)
-- 生产实测单条 4-17s、14 条共 88 秒;测试服盘慢,单条约 42s、共 238s。全程不锁表,业务照常。
-- 从零建库的环境不用管:那时 persona_features 是空表,普通 CREATE INDEX 瞬间完成。
--
-- 【为什么不能在这里写 CONCURRENTLY —— 2026-07-28 踩爆过,别再改回去】
-- Prisma 把整份 migration.sql 用**一次 simple query** 发给 Postgres,而 Postgres 对
-- 「一个查询串里多条语句」会隐式开事务块;CONCURRENTLY 明确禁止在事务块内跑:
-- ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)
-- 分界线是**语句条数,不是 Prisma 版本**:
-- 20260701060607 / 20260727070000 / 20260727110000 各只有 1 条语句 → 无隐式事务 → 跑得通
-- 本文件 14 条 → 隐式事务 → 必挂
-- 20260727110000 里那句「Prisma 6.19 已能直接跑通 CONCURRENTLY」是**错的归因**(单条侥幸),
-- 我照抄进本文件,结果测试服 deploy 挂在 migrate 步,pac-service 起不来。
-- 更坑的是失败后 _prisma_migrations 留下一条 finished_at/rolled_back_at 全空、
-- applied_steps_count=0 的记录,Prisma 从此拒绝执行**任何**后续迁移(P3018)——
-- 整条部署流水线被堵死,跟部署什么内容无关。解法:
-- npx prisma migrate resolve --rolled-back 20260728020000_persona_features_tag_filter_indexes
--
--
-- 【上线后实测】生产 2026-07-28 已手动建完,同口径复测(count + findMany 并发,墙钟):
-- 【上线后实测】生产 2026-07-28 已手动建完,同口径复测(count + findMany 并发,墙钟):
-- contraindication:surgery 23,379ms → **84ms** (278×,命中 1,347 单)
-- contraindication:surgery 23,379ms → **84ms** (278×,命中 1,347 单)
...
@@ -62,33 +79,33 @@
...
@@ -62,33 +79,33 @@
-- 禁忌 4,210ms → **344ms**。那台盘慢,绝对值别拿去跟生产比,看倍数即可。
-- 禁忌 4,210ms → **344ms**。那台盘慢,绝对值别拿去跟生产比,看倍数即可。
-- ── 标量维度(equals):btree(表达式, persona_id),第二列盖住 EXISTS 的输出列 ──────────────
-- ── 标量维度(equals):btree(表达式, persona_id),第二列盖住 EXISTS 的输出列 ──────────────
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_rfm_segment_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_rfm_segment_idx"
ON
"persona_features"
(((
data
#>
'{segment}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'rfm'
;
ON
"persona_features"
(((
data
#>
'{segment}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'rfm'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_lifecycle_stage_stage_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_lifecycle_stage_stage_idx"
ON
"persona_features"
(((
data
#>
'{stage}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'lifecycle_stage'
;
ON
"persona_features"
(((
data
#>
'{stage}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'lifecycle_stage'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_urgency_level_level_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_urgency_level_level_idx"
ON
"persona_features"
(((
data
#>
'{level}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'urgency_level'
;
ON
"persona_features"
(((
data
#>
'{level}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'urgency_level'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_age_bracket_bracket_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_age_bracket_bracket_idx"
ON
"persona_features"
(((
data
#>
'{bracket}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'age_bracket'
;
ON
"persona_features"
(((
data
#>
'{bracket}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'age_bracket'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_gender_gender_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_gender_gender_idx"
ON
"persona_features"
(((
data
#>
'{gender}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'gender'
;
ON
"persona_features"
(((
data
#>
'{gender}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'gender'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_family_structure_structure_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_family_structure_structure_idx"
ON
"persona_features"
(((
data
#>
'{structure}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'family_structure'
;
ON
"persona_features"
(((
data
#>
'{structure}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'family_structure'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_referral_champion_type_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_referral_champion_type_idx"
ON
"persona_features"
(((
data
#>
'{type}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'referral_champion'
;
ON
"persona_features"
(((
data
#>
'{type}'
::
text
[])),
"persona_id"
)
WHERE
"key"
=
'referral_champion'
;
-- ── 数组维度(array_contains → @>):GIN jsonb_path_ops ────────────────────────────────────
-- ── 数组维度(array_contains → @>):GIN jsonb_path_ops ────────────────────────────────────
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_contraindication_domains_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_contraindication_domains_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{domains}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'contraindication'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{domains}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'contraindication'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_entitlement_status_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_entitlement_status_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'entitlement_status'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'entitlement_status'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_treatment_history_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_treatment_history_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'treatment_history'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'treatment_history'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_treatment_sensitivity_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_treatment_sensitivity_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'treatment_sensitivity'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'treatment_sensitivity'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_special_attention_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_special_attention_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'special_attention'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'special_attention'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_time_preference_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_time_preference_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'time_preference'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'time_preference'
;
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
"persona_features_potential_treatment_types_idx"
CREATE
INDEX
IF
NOT
EXISTS
"persona_features_potential_treatment_types_idx"
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'potential_treatment'
;
ON
"persona_features"
USING
gin
(((
data
#>
'{types}'
::
text
[]))
jsonb_path_ops
)
WHERE
"key"
=
'potential_treatment'
;
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
View file @
564e48c0
...
@@ -251,6 +251,35 @@ export class PlansAggregateController {
...
@@ -251,6 +251,35 @@ export class PlansAggregateController {
return
{
ok
:
true
as
const
,
invocationId
:
script
.
agentInvocationId
,
feedback
};
return
{
ok
:
true
as
const
,
invocationId
:
script
.
agentInvocationId
,
feedback
};
}
}
@
Post
(
':id/view'
)
@
RequirePermission
(
Permission
.
PLAN_VIEW_OWN
)
@
ApiOperation
({
summary
:
'上报 plan 详情页浏览(PV/UV 埋点)—— 每进入一次记一条'
,
})
async
reportPlanView
(
@
TenantScope
()
scope
:
TenantScopeContext
,
@
CurrentUser
()
user
:
AuthenticatedUser
,
@
Param
(
'id'
)
planId
:
string
,
)
{
// ⚠️ **不加认领闸**(区别于 recall-feedback / 执行结果):浏览发生在认领**之前**,
// 池子里任何一条都能点开看。加了闸这个埋点就只剩已认领的,PV/UV 直接废掉。
const
plan
=
await
this
.
prisma
.
followupPlan
.
findFirst
({
where
:
{
id
:
planId
,
hostId
:
scope
.
hostId
,
tenantId
:
scope
.
tenantId
},
select
:
{
id
:
true
,
hostId
:
true
,
tenantId
:
true
,
patientId
:
true
},
});
// 静默返回 not_found:埋点是**尽力而为**的旁路,不能因为它失败而影响页面
if
(
!
plan
)
return
{
ok
:
false
as
const
,
reason
:
'not_found'
};
await
recordPlanEvent
(
this
.
prisma
,
{
hostId
:
plan
.
hostId
,
tenantId
:
plan
.
tenantId
,
planId
:
plan
.
id
,
patientId
:
plan
.
patientId
,
event
:
PlanEventType
.
VIEW
,
actorUserId
:
user
.
sub
,
});
return
{
ok
:
true
as
const
};
}
@
Post
(
':id/recall-feedback'
)
@
Post
(
':id/recall-feedback'
)
@
RequirePermission
(
Permission
.
PLAN_VIEW_OWN
)
@
RequirePermission
(
Permission
.
PLAN_VIEW_OWN
)
@
ApiOperation
({
@
ApiOperation
({
...
...
apps/pac-service/tests/plan-event-log.spec.ts
View file @
564e48c0
...
@@ -203,3 +203,36 @@ describe('RecycleSchedulerService — 自动回收默认关闭', () => {
...
@@ -203,3 +203,36 @@ describe('RecycleSchedulerService — 自动回收默认关闭', () => {
.
toHaveBeenCalled
();
.
toHaveBeenCalled
();
});
});
});
});
// =============================================================
// 2026-07 加 view 事件(plan 详情页 PV/UV)—— 锁死"浏览 ≠ 处理"
// =============================================================
describe
(
'PlanEventType.VIEW — PV/UV 埋点'
,
()
=>
{
it
(
'⭐ 红线:view 绝不能进 HUMAN_TOUCH_EVENTS'
,
()
=>
{
// byHuman 是**口径开关**不是"是不是人做的"。给 view 写 true 会让
// 「客服处理过的患者」静默膨胀成"看一眼也算处理",且**没有任何编译错误**。
expect
(
PLAN_EVENT_META
.
view
.
byHuman
).
toBe
(
false
);
expect
(
HUMAN_TOUCH_EVENTS
).
not
.
toContain
(
PlanEventType
.
VIEW
);
});
it
(
'HUMAN_TOUCH_EVENTS 精确等于四个处理类事件(加了新事件要显式决策)'
,
()
=>
{
expect
([...
HUMAN_TOUCH_EVENTS
].
sort
()).
toEqual
(
[
PlanEventType
.
ASSIGN
,
PlanEventType
.
CLAIM
,
PlanEventType
.
FEEDBACK
,
PlanEventType
.
RELEASE
,
].
sort
(),
);
});
it
(
'view 不改变归属:holdsPatient=false,独立 group'
,
()
=>
{
expect
(
PLAN_EVENT_META
.
view
.
holdsPatient
).
toBe
(
false
);
expect
(
PLAN_EVENT_META
.
view
.
group
).
toBe
(
'view'
);
});
it
(
'view 已在枚举登记(元数据完整)'
,
()
=>
{
expect
(
PlanEventType
.
VIEW
).
toBe
(
'view'
);
expect
(
PLAN_EVENT_META
.
view
.
labelZh
.
length
).
toBeGreaterThan
(
0
);
});
});
apps/pac-web/src/components/plans/plans-api.ts
View file @
564e48c0
...
@@ -38,6 +38,10 @@ export const plansApi = {
...
@@ -38,6 +38,10 @@ export const plansApi = {
getAggregate
:
(
planId
:
string
)
=>
getAggregate
:
(
planId
:
string
)
=>
api
.
get
<
PlanDetailData
>
(
`/pac/v1/plans/
${
encodeURIComponent
(
planId
)}
/full`
),
api
.
get
<
PlanDetailData
>
(
`/pac/v1/plans/
${
encodeURIComponent
(
planId
)}
/full`
),
/** PV/UV 埋点:上报一次 plan 详情页浏览。**尽力而为**,失败不影响页面(调用方吞异常)*/
reportView
:
(
planId
:
string
)
=>
api
.
post
<
{
ok
:
boolean
}
>
(
`/pac/v1/plans/
${
encodeURIComponent
(
planId
)}
/view`
,
{}),
/** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/
/** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/
getRecallSummary
:
(
planId
:
string
)
=>
getRecallSummary
:
(
planId
:
string
)
=>
api
.
get
<
{
summary
:
string
|
null
;
status
:
'ready'
|
'empty'
;
source
?:
string
}
>
(
api
.
get
<
{
summary
:
string
|
null
;
status
:
'ready'
|
'empty'
;
source
?:
string
}
>
(
...
...
apps/pac-web/src/components/plans/use-plan-aggregate.ts
View file @
564e48c0
...
@@ -35,6 +35,11 @@ export function usePlanAggregate(planId: string) {
...
@@ -35,6 +35,11 @@ export function usePlanAggregate(planId: string) {
if
(
loadedFor
.
current
===
planId
)
return
;
if
(
loadedFor
.
current
===
planId
)
return
;
loadedFor
.
current
=
planId
;
loadedFor
.
current
=
planId
;
void
load
();
void
load
();
// ⭐ PV/UV 埋点:借这道守卫定义「进入一次详情页」——
// 三种入口全收(直接进/刷新 · 患者列表切换 · /plans 落地自动跳转),
// 而**手动 refresh 不重复计**(refresh 直接调 load(),走不到这里)。
// 尽力而为:失败只吞掉,埋点不能影响页面。
void
plansApi
.
reportView
(
planId
).
catch
(()
=>
{});
},
[
planId
,
load
]);
},
[
planId
,
load
]);
return
{
state
,
refresh
:
load
};
return
{
state
,
refresh
:
load
};
...
...
packages/types/src/enums/index.ts
View file @
564e48c0
...
@@ -755,26 +755,41 @@ export const PlanEventType = {
...
@@ -755,26 +755,41 @@ export const PlanEventType = {
AUTO_RELEASE
:
'auto_release'
,
AUTO_RELEASE
:
'auto_release'
,
/// 反馈:召回准不准 👍/👎(reason 列存 up/down)
/// 反馈:召回准不准 👍/👎(reason 列存 up/down)
FEEDBACK
:
'feedback'
,
FEEDBACK
:
'feedback'
,
/// 浏览:进入 plan 详情页一次(PV/UV 用)。**不是"处理"** —— 见下方 byHuman 说明。
/// 三种入口全收:直接进/刷新 · 患者列表切换 · /plans 落地自动跳转;
/// 同一 planId 每次进入记一次(前端 usePlanAggregate 去重守卫为界),手动刷新不重复计。
VIEW
:
'view'
,
}
as
const
;
}
as
const
;
export
type
PlanEventType
=
(
typeof
PlanEventType
)[
keyof
typeof
PlanEventType
];
export
type
PlanEventType
=
(
typeof
PlanEventType
)[
keyof
typeof
PlanEventType
];
/**
/**
* 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
* 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
*
*
* group ownership 归属变更 / feedback 质量反馈(后续可加 lifecycle 等)
* group ownership 归属变更 / feedback 质量反馈 / view 浏览埋点(后续可加 lifecycle 等)
* byHuman 是否人工动作。⭐ 统计「客服处理过哪些患者」只算 byHuman=true 的,
* byHuman ⭐ **口径开关,不是"是不是人做的"** —— 语义是「该事件算不算『客服处理过这个患者』」,
* auto_release 是系统行为,不能算成"客服处理过"。
* 即是否进 [[HUMAN_TOUCH_EVENTS]]。两类为 false,原因不同:
* · auto_release —— 系统超时回收,压根不是人的动作;
* · view —— 是人的动作,但**浏览 ≠ 处理**。若给它 true,「处理过的患者」
* 口径会静默膨胀成"看一眼也算",而 HUMAN_TOUCH_EVENTS 是 filter 派生的,
* 改错**不会有任何编译错误或类型报错**。下方 spec 有断言锁死。
* holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
* holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
*/
*/
export
const
PLAN_EVENT_META
:
Record
<
export
const
PLAN_EVENT_META
:
Record
<
PlanEventType
,
PlanEventType
,
{
labelZh
:
string
;
group
:
'ownership'
|
'feedback'
;
byHuman
:
boolean
;
holdsPatient
:
boolean
}
{
labelZh
:
string
;
group
:
'ownership'
|
'feedback'
|
'view'
;
byHuman
:
boolean
;
holdsPatient
:
boolean
;
}
>
=
{
>
=
{
claim
:
{
labelZh
:
'认领'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
true
},
claim
:
{
labelZh
:
'认领'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
true
},
assign
:
{
labelZh
:
'指派'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
true
},
assign
:
{
labelZh
:
'指派'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
true
},
release
:
{
labelZh
:
'返池'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
false
},
release
:
{
labelZh
:
'返池'
,
group
:
'ownership'
,
byHuman
:
true
,
holdsPatient
:
false
},
auto_release
:
{
labelZh
:
'超时自动回收'
,
group
:
'ownership'
,
byHuman
:
false
,
holdsPatient
:
false
},
auto_release
:
{
labelZh
:
'超时自动回收'
,
group
:
'ownership'
,
byHuman
:
false
,
holdsPatient
:
false
},
feedback
:
{
labelZh
:
'召回反馈'
,
group
:
'feedback'
,
byHuman
:
true
,
holdsPatient
:
true
},
feedback
:
{
labelZh
:
'召回反馈'
,
group
:
'feedback'
,
byHuman
:
true
,
holdsPatient
:
true
},
// ⚠️ byHuman: false —— 浏览是人做的,但**不算"处理过"**(见上方字段说明)。别改成 true。
view
:
{
labelZh
:
'查看详情'
,
group
:
'view'
,
byHuman
:
false
,
holdsPatient
:
false
},
};
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment