feat(sign): 补齐医护端与患者端签署流程

This commit is contained in:
xy
2026-09-28 19:57:57 +08:00
parent a25716c861
commit 8024075439
22 changed files with 1082 additions and 163 deletions
+20 -27
View File
@@ -5,16 +5,20 @@ import type {
ApiResponse,
SignatureUploadInput,
SignatureUploadResponse,
SigningProof,
SigningContextResponse,
TokenConsumeRequest,
TokenConsumeResponse,
} from './types'
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
export function consumeSigningToken(
payload: TokenConsumeRequest,
idempotencyKey: string,
): Promise<TokenConsumeResponse> {
return request
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
headers: {
'Idempotency-Key': createIdempotencyKey(),
'Idempotency-Key': idempotencyKey,
},
})
.then(unwrapApiResponse)
@@ -23,48 +27,37 @@ export function consumeSigningToken(payload: TokenConsumeRequest): Promise<Token
/**
* 读取本次签署的完整上下文:患者信息 / 签署人 / 文书正文 / 医生已填结果。
*
* 契约见 `.workbuddy-ai/患者端H5真实接入方案-2026-09-28.md` 第三节。
* **后端就绪前这个调用会失败**,所以调用方必须能优雅降级 ——
* 拿不到正文就不能放行签署(见 `useSignFlow` 的 `documentError`)。
* Context 使用已消费凭证,不再把原始一次性 Token 放进请求 URL、日志或后续请求。
*/
export function fetchSigningContext(token: string): Promise<SigningContextResponse> {
export function fetchSigningContext(proof: SigningProof): Promise<SigningContextResponse> {
return request
.get<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', {
params: { token },
})
.post<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', proof)
.then(unwrapApiResponse)
}
function createIdempotencyKey(value?: string) {
if (value) {
return value
}
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `patient-h5-${crypto.randomUUID()}`
}
return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function uploadSigningSignature(
payload: SignatureUploadInput,
): Promise<SignatureUploadResponse> {
const formData = new FormData()
formData.append('file', payload.file, 'signature.png')
formData.append('metadata', payload.metadata)
formData.append('deliveryId', payload.deliveryId)
formData.append('uploadToken', payload.uploadToken)
formData.append(
'metadata',
new Blob([JSON.stringify(payload.metadata)], { type: 'application/json' }),
)
formData.append(
'signer',
new Blob([JSON.stringify(payload.signer)], { type: 'application/json' }),
)
return request
.post<ApiResponse<SignatureUploadResponse>>(
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
formData,
{
params: {
deliveryId: payload.deliveryId,
uploadToken: payload.uploadToken,
},
headers: {
'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey),
'Idempotency-Key': payload.idempotencyKey,
},
},
)
+57 -9
View File
@@ -24,6 +24,38 @@ export interface TokenConsumeRequest {
taskId?: string
}
export interface SignatureFieldPosition {
id: string
page: number
x: number
y: number
width: number
height: number
coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT'
}
/** Facts read from the actual PNG plus an optional assertion against a configured PDF field. */
export interface SignaturePngMetadata {
imageWidth: number
imageHeight: number
sizeBytes: number
sha256: string
contentType: 'image/png'
fieldId: string
page: number
x: number
y: number
width: number
height: number
coordinateSystem: SignatureFieldPosition['coordinateSystem']
}
export interface SigningProof {
taskId: string
deliveryId: string
uploadToken: string
}
export interface TokenConsumeResponse {
deliveryId: string
taskId: string
@@ -35,12 +67,7 @@ export interface TokenConsumeResponse {
/** 患者端看到的任务状态。不是 `WAITING_SIGN` 时不该放行签署 */
export type SigningTaskSignStatus =
| 'WAITING_SIGN'
| 'SIGNED'
| 'EXPIRED'
| 'VOIDED'
| 'FAILED'
| string
'WAITING_SIGN' | 'WAITING_CONFIRM' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'FAILED' | string
export interface SigningContextPatient {
name?: string | null
@@ -107,10 +134,24 @@ export interface SigningContextResponse {
staffAnswers?: Record<string, string> | null
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
fields?: SigningContextField[] | null
/** Configured PDF signature locations, separate from body form-field definitions. */
signatureFields?: SignatureFieldPosition[] | null
/** Recoverable summary of the real server pipeline and artifacts after a successful upload. */
uploadResult?: SignatureUploadResponse | null
/** Server-owned patient signature completion timestamp (separate from department confirmation). */
signedAt?: string | null
}
export type SignatureUploadStatus =
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
| 'CREATED'
| 'WAITING_SIGN'
| 'SIGNED'
| 'EXPIRED'
| 'VOIDED'
| 'GENERATING'
| 'WAITING_CONFIRM'
| 'FAILED'
| string
export interface SignatureUploadResponse {
taskId: string
@@ -132,6 +173,13 @@ export interface SignatureUploadInput {
deliveryId: string
uploadToken: string
file: Blob
metadata: string
idempotencyKey?: string
metadata: SignaturePngMetadata
signer: {
type: string
name: string
relation: string
consentAccepted: true
observedSignedAt: string
}
idempotencyKey: string
}