Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ ANGUI_SESSION_TTL_HOURS=8
ANGUI_INTAKE_ANSWER_HARD_MAX=2000
# Optional server-only AMap Web Service key. Never expose this key to browsers, logs, or commits.
# AMAP_WEBSERVICE_KEY=
# Server-only signing secret for the five-minute selected-POI route capability.
# Set the same value on every production instance; never expose it to browsers, logs, or commits.
# ANGUI_POI_SELECTION_TOKEN_SECRET=
AMAP_WEBSERVICE_BASE_URL=https://restapi.amap.com
AMAP_TIMEOUT_MS=2500
# Empty by default: the gateway returns the deterministic rule-based path.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dotenvy = "0.15.7"
env_logger = "0.11.11"
futures-util = "0.3.32"
hex = "0.4.3"
hmac = "0.12.1"
http = "0.2.12"
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png"] }
libheif-rs = { version = "2.7.0", default-features = false, features = ["v1_19", "embedded-libheif"], optional = true }
Expand Down
5 changes: 3 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
| `GET` | `/api/cases/{case_id}/public-progress` | `200` | 家属查看仅含已确认信息和本人待补充事项的公开进展 |
| `POST` | `/api/cases/{case_id}/clue-drafts` | `201` | 将受控文本持久化为不可直接确认的线索草稿 |
| `PATCH` | `/api/cases/{case_id}/clue-drafts/{draft_id}/review` | `200` | 指挥人工接受或拒绝线索字段候选 |
| `GET` | `/api/cases/{case_id}/pois` | `200` | 按服务端授权中心查询有上限的周边资源,失败时明确降级 |
| `GET` | `/api/cases/{case_id}/pois` | `200` | 按案件授权中心或用户主动授权的临时当前位置查询有上限的周边资源,失败时明确降级 |
| `GET` | `/api/cases/{case_id}/pois/route` | `200` | 从用户主动授权的临时当前位置到选中 POI 的步行距离和预计时长 |
| `POST` | `/api/cases/{case_id}/summary-drafts` | `201` | 指挥创建带来源范围和版本的内部摘要草稿 |
| `PATCH` | `/api/cases/{case_id}/summary-drafts/{draft_id}/review` | `200` | 指挥提交、审核发布、驳回或撤回摘要草稿 |
| `POST` | `/api/cases/{case_id}/archive-drafts` | `201` | 指挥为已结束案件创建受控内部归档草稿 |
Expand Down Expand Up @@ -244,7 +245,7 @@ Example:
## 志愿者任务协作

志愿者只要已作为案件 `volunteer` 成员加入案件,即可通过 `GET /api/cases`、`GET /api/cases/{case_id}`、`GET /api/cases/{case_id}/summary` 与 `GET /api/cases/{case_id}/tasks` 进入协作工作区;不再以“是否已有个人任务”为案件可见性的前提。工作区包含经该角色授权的案件摘要、线索、健康备注与家属联系邮箱;后两者仅在请求者已被显式授予该案件 `volunteer` 角色时一并返回。它不包含其他志愿者的精确位置。
志愿者可看到已确认线索和自己提交但仍待人工审核的线索;其他成员的待审核线索不会暴露。经人工审核为 `confirmed` 且可见性为 `public` 或 `confirmed` 的关键地点(包括家属提交的地点)会返回给志愿者;`internal` 地点始终不可见。志愿者调用 `GET /api/cases/{case_id}/pois` 时,服务端仅从该角色可见的任务坐标或已确认的公开地点选择中心,客户端不能提供或覆盖中心坐标。
志愿者可看到已确认线索和自己提交但仍待人工审核的线索;其他成员的待审核线索不会暴露。经人工审核为 `confirmed` 且可见性为 `public` 或 `confirmed` 的关键地点(包括家属提交的地点)会返回给志愿者;`internal` 地点始终不可见。志愿者调用 `GET /api/cases/{case_id}/pois` 时,默认由服务端从该角色可见的任务坐标或已确认的公开地点选择中心。用户也可在浏览器明确同意后,同时提交 `browser_longitude` 与 `browser_latitude`(WGS-84)作为仅本次请求的中心;服务端转换为高德坐标后检索,绝不持久化、审计或返回该原始浏览器坐标。POI 返回直线距离。`GET /api/cases/{case_id}/pois/route` 仅使用这次临时浏览器位置和已选择 POI 的高德坐标给出步行距离、时长,路线服务失败时只返回明确标记的直线距离回退。

任务不再是单人独占:`POST /api/cases/{case_id}/tasks` 可以使用 `volunteer_user_ids` 指定一个或多个已授权的案件志愿者(旧 `volunteer_user_id` 保持兼容)。每位受领者都是同一任务协作空间的成员;成员可以独立推进允许的任务状态、提交反馈和位置报告,任一成员完成任务会更新该共享任务状态。指挥仍可取消未结束任务,但不会因取消而需要成为任务成员。如果两个志愿者字段都未提供(或 `volunteer_user_ids` 为空),则会创建 `pending_claim` 的开放/常驻任务;案件志愿者可申请加入,指挥批准首位申请者后任务转为 `assigned`。

Expand Down
63 changes: 59 additions & 4 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1359,8 +1359,8 @@ paths:
get:
tags: [Cases]
operationId: listCasePois
summary: Find bounded nearby resources from a server-authorized center
description: The caller can choose only an allow-listed category; it cannot submit coordinates. The server performs one bounded nearby search (3 km, one page, at most 10 items) around an authorized task coordinate or, when none exists, a confirmed place; volunteers may use only confirmed public places as that fallback center. AMap transport, quota, or business failures return a fixed non-coordinate fallback and never disclose the provider key or source coordinate.
summary: Find bounded nearby resources from an authorized or explicitly consented center
description: The caller can choose only an allow-listed category. With no browser coordinates, the server performs one bounded nearby search (3 km, one page, at most 10 items) around an authorized task coordinate or, when none exists, a case-confirmed place. Volunteers may use only the case-confirmed public or confirmed-visibility places they are authorized to view; internal places remain commander-only. Callers outside the case cannot use a place as a search center. A caller may explicitly provide both browser coordinates in WGS-84 for this request only. The server converts them to AMap coordinates before the upstream request and must not persist them. Each coordinate-bearing returned POI includes a short-lived, case- and user-bound selection token for route planning. AMap transport, quota, conversion, or business failures return an explicit error or a fixed non-coordinate fallback and never disclose the provider key or source coordinate.
security: [{ bearerAuth: [] }]
x-account-types: [member]
x-case-roles: [commander, volunteer]
Expand All @@ -1369,6 +1369,14 @@ paths:
- name: category
in: query
schema: { type: string, enum: [hospital, police, transit, market, community_service], default: hospital }
- name: browser_longitude
in: query
description: Optional WGS-84 browser location. Must be supplied with browser_latitude and is never persisted.
schema: { type: number, minimum: -180, maximum: 180 }
- name: browser_latitude
in: query
description: Optional WGS-84 browser location. Must be supplied with browser_longitude and is never persisted.
schema: { type: number, minimum: -90, maximum: 90 }
responses:
"200": { description: Nearby POIs or explicit degraded fallback, content: { application/json: { schema: { $ref: "#/components/schemas/CasePois" } } } }
"400": { $ref: "#/components/responses/ValidationError" }
Expand All @@ -1377,6 +1385,39 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }

/api/cases/{case_id}/pois/route:
parameters: [{ $ref: "#/components/parameters/CaseId" }]
get:
tags: [Cases]
operationId: getCasePoiRoute
summary: Estimate a walking route from an explicitly consented browser location to a selected POI
description: Commander and volunteer only. Browser coordinates are WGS-84 and are used only for this request. A short-lived selection token from listCasePois identifies a selected AMap POI and is bound to the caller and case, preventing arbitrary destination routing. The server converts the browser coordinate before requesting an AMap walking estimate and returns a straight-line fallback when route estimation is unavailable. Neither coordinate is persisted or written to audit metadata.
security: [{ bearerAuth: [] }]
x-account-types: [member]
x-case-roles: [commander, volunteer]
x-data-classification: sensitive
parameters:
- name: browser_longitude
in: query
required: true
schema: { type: number, minimum: -180, maximum: 180 }
- name: browser_latitude
in: query
required: true
schema: { type: number, minimum: -90, maximum: 90 }
- name: selection_token
in: query
required: true
description: Short-lived token returned with the selected POI by listCasePois.
schema: { type: string, minLength: 1, maxLength: 2048 }
responses:
"200": { description: Walking route estimate or straight-line fallback, content: { application/json: { schema: { $ref: "#/components/schemas/CasePoiRoute" } } } }
"400": { $ref: "#/components/responses/ValidationError" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }

/api/cases/{case_id}/summary-drafts:
parameters: [{ $ref: "#/components/parameters/CaseId" }]
get:
Expand Down Expand Up @@ -4317,25 +4358,39 @@ components:
CasePoi:
type: object
additionalProperties: false
required: [id, name, category, address, longitude, latitude]
required: [id, name, category, address, longitude, latitude, distance_meters, selection_token]
properties:
id: { type: string }
name: { type: string }
category: { type: string, enum: [hospital, police, transit, market, community_service] }
address: { type: [string, "null"] }
longitude: { type: [number, "null"] }
latitude: { type: [number, "null"] }
distance_meters: { type: [integer, "null"], minimum: 0 }
selection_token: { type: [string, "null"], maxLength: 2048 }

CasePois:
type: object
additionalProperties: false
required: [items, source, degradation_status, fallback_message]
required: [items, center_source, source, degradation_status, fallback_message]
properties:
items: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/CasePoi" } }
center_source: { type: string, enum: [authorized_case_location, browser_location] }
source: { type: string, enum: [amap_webservice, fixed_demo_fallback] }
degradation_status: { type: string, enum: [available, degraded] }
fallback_message: { type: [string, "null"] }

CasePoiRoute:
type: object
additionalProperties: false
required: [straight_line_meters, walking_distance_meters, walking_duration_seconds, source, degradation_status]
properties:
straight_line_meters: { type: integer, minimum: 0 }
walking_distance_meters: { type: [integer, "null"], minimum: 0 }
walking_duration_seconds: { type: [integer, "null"], minimum: 0 }
source: { type: string, enum: [amap_webservice, straight_line_fallback] }
degradation_status: { type: string, enum: [available, degraded] }

CreateSummaryDraftRequest:
type: object
additionalProperties: false
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/api/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,23 @@ export interface CasePoi {
address: string | null;
longitude: number | null;
latitude: number | null;
distance_meters: number | null;
selection_token: string | null;
}
export interface CasePois {
items: CasePoi[];
center_source: "browser_location" | "authorized_case_location";
source: string;
degradation_status: string;
fallback_message: string | null;
}
export interface CasePoiRoute {
straight_line_meters: number;
walking_distance_meters: number | null;
walking_duration_seconds: number | null;
source: string;
degradation_status: "available" | "degraded";
}
export type CaseMapObjectType = "last_seen" | "place" | "clue" | "task";
export type MapLocationPrecision = "exact" | "approximate" | "unknown";
export interface CaseMapItem {
Expand Down Expand Up @@ -836,9 +846,34 @@ export function listCasePois(
token: string,
caseId: string,
category = "hospital",
browserLocation?: { longitude: number; latitude: number },
): Promise<CasePois> {
const params = new URLSearchParams({ category });
if (browserLocation) {
params.set("browser_longitude", String(browserLocation.longitude));
params.set("browser_latitude", String(browserLocation.latitude));
}
return apiRequest<CasePois>(
`/cases/${caseId}/pois?category=${encodeURIComponent(category)}`,
`/cases/${caseId}/pois?${params.toString()}`,
{},
token,
);
}

export function getCasePoiRoute(
token: string,
caseId: string,
input: {
browser_longitude: number;
browser_latitude: number;
selection_token: string;
},
): Promise<CasePoiRoute> {
const params = new URLSearchParams(
Object.entries(input).map(([key, value]) => [key, String(value)]),
);
return apiRequest<CasePoiRoute>(
`/cases/${caseId}/pois/route?${params.toString()}`,
{},
token,
);
Expand Down
Loading
Loading