移动端、推送与 DNN 集成
提供适合受访者的移动端体验,并集成到现有 DNN 门户,同时保持独立的现代运行时。
受访者外部登录
问卷受访者与系统管理员/系统用户是两条独立身份链。受访者可使用本地 `survey_respondent` 帐号,或在启用后通过 Google、Facebook OpenID Connect 登录。外部身份只用于受访者问卷流程,不会自动取得系统表单、Designer 或管理 API 权限。
配置
在受保护配置源中设置 Google/Facebook 的 `ClientId`、`ClientSecret` 和启用开关;redirect URI 必须与外部应用后台登记的公开 HTTPS 地址完全一致。开发环境和生产环境应使用不同 OAuth 应用或分别登记 callback URL。密钥不得放入表单、Action Code 或前端扩展。
外部登录成功后,系统以 provider + provider subject 建立/读取受访者外部身份。匿名部署在时间范围内可访问;被分配清单的部署仍需要相应本地受访者身份。外部身份写入答卷的 `respondent_id`,但不伪造本地密码或系统用户记录。
可重复提交
对于允许重复提交的部署,外部身份和本地受访者一样会读取最后一次答卷并在再次提交时创建新记录。对于不允许重复提交的部署,系统加载既有答卷而不是创建第二条。登录退出应先清除 FormPlatform respondent 会话,再由浏览器/外部 IdP 的会话策略决定是否显示账号选择页面。
English edition: RESPONDENT_EXTERNAL_AUTHENTICATION.md.
---
受访者移动端 PWA 与 Web Push
目标与边界
受访者移动端采用 **Progressive Web App(PWA)**,而不是维护一套独立 Android/iOS 原生代码。它仍然使用现有 Vue 表单运行时、问卷路由、受访者登录、外部 OIDC 登录和部署授权。因此,同一个部署在桌面浏览器、手机浏览器及安装到主屏幕后的应用中,行为和权限完全一致。
移动端实现包含:
- `/respondent/dashboard` 的卡片式 `ItemRenderer`;默认按开始时间倒序,使用游标分页,每次加载 10 个部署;
- Web App Manifest 与 Service Worker,可从支持的浏览器安装到主屏幕;
- 标准 Browser Push API + VAPID 的设备订阅;
- 管理端/服务器端发送给一个受访者或一个部署清单的 Web Push API;
- 失效订阅(HTTP 404/410)自动删除,其他失败会记录在 `survey_push_subscription.last_error`。
PWA 不是离线答卷功能。Service Worker **不会缓存**已登录表单、回答数据或 API 响应;这样可以避免受访者注销、权限变化或多人共用手机后读取旧数据。
安装和使用
1. 网站必须使用 HTTPS。`localhost` 仅适合本机开发;真实手机必须访问一个受信任 HTTPS 域名。
2. 受访者访问并登录 `/respondent/dashboard`。
3. 在浏览器菜单选择“安装应用”/“添加到主屏幕”。Android Chrome/Edge 通常会显示安装提示;iPhone/iPad Safari 通过分享菜单的“添加到主屏幕”。
4. Dashboard 顶部点击“开启通知”,并在浏览器提示中允许通知。
按钮只会在浏览器具备 Service Worker、Push API、Notification API 且服务器已正确配置 VAPID 时显示。受访者可以随时点“关闭通知”;这会删除服务器订阅并取消浏览器订阅。
Dashboard 顶部会说明不能启用的具体原因:需要可信 HTTPS、当前浏览器不支持、iPhone/iPad 尚未从 Safari “添加到主屏幕”,或服务器尚未配置 VAPID。最后一种情况不是手机故障:在生产环境修改明文 secrets source 的 `PushNotifications` 段后,重新运行安装器以生成加密的运行时 secrets,再重启应用程序池/服务即可。
若点击“开启通知”后提示后台服务无法启动,浏览器未能安装或激活 `<网站根路径>/service-worker.js`。先强制刷新/重新打开已安装应用;仍失败时,直接访问该 URL 确认返回 JavaScript(不是 HTML 登录页或 404),并检查反向代理没有把此文件重写到 SPA `index.html`。
点击“开启通知”后,客户端会依次显示“请求浏览器通知权限”“创建设备订阅”“保存设备设置”。前两步完全在浏览器内完成,**不会产生 API 请求**;只有设备订阅成功后,才会发出 `PUT /api/respondent/push/subscription`。若停在前两步,优先检查手机的通知权限和 `service-worker.js`,而不是数据库或 API 权限。
移动端同时监听标准 `click` 和 `pointerup`;两者会共用同一个防重入操作。若仍看不到上述任一阶段文字,设备正在执行旧的前端 bundle,或页面上有覆盖按钮的元素。应先重新构建/发布 SPA,彻底关闭后重新打开已安装的 Web App;在浏览器打开 Dashboard 时也可用开发者工具确认加载的 `assets/index-<hash>.js` 已更新。
VAPID 配置
VAPID 是 Web Push 的应用服务器身份密钥。每个生产站点生成一对密钥并长期保存;不能为每个用户、每次发布或每台设备重新生成。
可在安全的管理电脑上生成一对密钥,例如:
npx --yes web-push generate-vapid-keys --json
将输出中的 `publicKey` 和 `privateKey` 保存到密码库。不要把 private key 提交到 Git,也不要写进公开的 `appsettings.json`。
开发机可使用 User Secrets:
dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "PushNotifications:Enabled" "true"
dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "PushNotifications:VapidSubject" "mailto:security@example.com"
dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "PushNotifications:VapidPublicKey" "<publicKey>"
dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "PushNotifications:VapidPrivateKey" "<privateKey>"
生产环境应把同一段设置写到 FormPlatform 已加密的 secrets JSON 配置源中:
{
"PushNotifications": {
"Enabled": true,
"VapidSubject": "mailto:security@example.com",
"VapidPublicKey": "<publicKey>",
"VapidPrivateKey": "<privateKey>",
"TimeToLiveSeconds": 86400
}
}
`VapidSubject` 必须是 `mailto:` 地址或 HTTPS URL。`TimeToLiveSeconds` 是推送服务在设备离线时保留通知的最长秒数,最大值为 2,419,200(四周)。修改 VAPID 密钥会使所有旧浏览器订阅失效,受访者需要再次开启通知。
数据与安全模型
迁移 `FormPlatform.Core/007_respondent_push_subscriptions` 创建表 `survey_push_subscription`:
| 字段 | 用途 |
|---|---|
| `identity_id` / `identity_kind` | 当前受访者身份;`local` 或 `external` |
| `endpoint`、`endpoint_hash`、`p256dh`、`auth` | 浏览器 Push API 产生的订阅材料;hash 用于跨数据库安全地建立唯一索引 |
| `last_success_at`、`last_failure_at`、`last_error` | 运维诊断信息 |
- 浏览器只能对当前 Respondent cookie 身份调用订阅/取消订阅 API;服务器完全忽略客户端对身份的任何主张。
- Push payload 只包含 `title`、`body`、相对应用 URL 和随机 `tag`,不能放入表单回答、密码、文件 ID 或其他敏感信息。
- Service Worker 点通知后回到同一公开站点中的目标路由;它会自动处理 `/formplatform/` 这类子路径发布基址。
- 外部 Google/Facebook 身份仍按 `external` 保存。匿名部署没有自然的“所有访问者”订阅清单,因此只能向明确的 `respondentId + identityKind=external` 发送,不会向未知匿名访问者广播。
服务器端接口
受访者端(必须是 Respondent 登录状态):
| 方法 | 路径 | 用途 |
|---|---|---|
| `GET` | `/api/respondent/push/config` | 读取是否启用及公开 VAPID key |
| `PUT` | `/api/respondent/push/subscription` | 写入当前设备订阅 |
| `DELETE` | `/api/respondent/push/subscription` | 删除当前身份的一台设备订阅 |
管理端发送接口(需要 `Administration` 权限):
POST /api/admin/survey/push/send
Content-Type: application/json
发给某个本地受访者:
{
"respondentId": "<survey_respondent id>",
"identityKind": "local",
"title": "请完成问卷",
"body": "您的问卷已准备好。",
"url": "/respondent/dashboard"
}
管理员批量发送表单
系统管理员可从左侧“问卷调查管理 → 发送推送通知”打开 `Survey_Push_Notification`(`f0000000-0000-0000-0000-000000000031`)。该表单提供两种互斥的目标模式:
- **选定受访者**:搜索并多选活跃本地受访者;一个受访者的所有已订阅设备都会收到通知。
- **部署清单中的活跃受访者**:选择一个带受访者清单的部署;系统只向该清单中 `is_active=true` 的本地受访者发送。
表单调用的批量 API 为:
POST /api/admin/survey/push/send-batch
Content-Type: application/json
多选受访者请求示例:
{
"respondentIds": ["<survey_respondent id 1>", "<survey_respondent id 2>"],
"title": "请完成问卷",
"body": "您的问卷已准备好。",
"url": "/respondent/dashboard"
}
部署清单请求示例:
{
"deploymentId": "<survey_deployment id>",
"title": "新的问卷任务",
"body": "请在截止日期前完成。",
"url": "/respondent/dashboard"
}
`respondentIds` 和 `deploymentId` 必须二选一。直接选择模式最多 1,000 名受访者;更大的目标群体应使用部署清单。接口返回 `delivered`、`expired`、`failed`;目标设备尚未开启通知时,`delivered` 为零并不是接口错误。
返回值中的 `delivered`、`expired`、`failed` 分别表示推送服务接受、已自动删除的无效订阅、以及本次发送失败数量。业务模块、Server Action 或受控 Trigger 可调用 API;建议只在部署创建、开始时间到达、截止提醒等明确业务节点发送,避免频繁通知。
开发与验证清单
1. 重启应用以运行第 007 个数据库迁移,并使 `Survey_Respondent_Dashboard` 的系统定义更新为卡片布局。
2. 在 HTTPS 浏览器登录受访者账号,确认 Dashboard 显示卡片和“继续加载”按钮,而不是旧 DataGrid。
3. 点“开启通知”,确认浏览器权限提示、`survey_push_subscription` 新记录及页面成功提示。
4. 用管理员 cookie 调用发送 API;手机应收到通知,点击后打开 Dashboard 或指定问卷路由。
5. 在浏览器设置中删除网站权限或取消订阅后再发送,确认服务端删除返回 404/410 的旧记录。
本次变更未由 Codex 构建、运行或发送真实通知;请按项目约定自行执行后端构建、前端构建和上述手动验证。
---
DNN Portal Bridge 单点登录
FormPlatform 保持独立部署。DNN 仅作为系统用户的身份来源、门户和导航入口;respondent 登录、Google/Facebook respondent 登录、匿名表单以及原有 FormPlatform 用户名密码登录均不改变。
工作方式
已登录的 DNN 用户点击启用了 SSO 的 Portal Bridge 时,模块会在浏览器内使用 `POST` 向 FormPlatform 的 `/api/auth/dnn/sso` 发送一张短期 ticket。ticket 不放在 URL 中,包含 DNN Portal ID、DNN User ID、用户名、邮箱、角色和目标本地路径,并使用双方共享的 HMAC-SHA256 密钥签名。
FormPlatform 验证签名、签发者、受众、有效期(默认 60 秒)、本地 return path 和一次性 Ticket ID 后:
1. 以 `(portal_id, dnn_user_id)` 查询 `app_dnn_user_mappings`;
2. 首次访问时创建一个受管理的 `app_users` 记录;
3. 按 `DnnSso:RoleMappings` 同步 DNN 来源的 FormPlatform 角色;
4. 签发原有的 `FormPlatform.Auth` Cookie;
5. 跳转回 ticket 内签名的 FormPlatform 本地路径。
未映射的 DNN 角色**不授予任何权限**。DNN 同步的角色在 `app_user_roles.source = 'dnn'` 中标识;下一次 DNN 登录时不再拥有的 DNN 映射角色会被移除。请不要通过 FormPlatform 用户角色页面手工维护 DNN 受管理用户的 DNN 来源角色。
配置步骤
1. 生成共享密钥
在管理员 PowerShell 中生成一次:
$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)
将输出保存到受控密码库。不要提交到 Git、DNN 模块设置或普通 `appsettings.json`。
2. 配置 FormPlatform
建议通过 User Secrets、systemd credential 或环境变量配置密钥。例如开发机:
dotnet user-secrets set "DnnSso:SharedSecret" "<同一 Base64 密钥>"
dotnet user-secrets set "DnnSso:Enabled" "true"
在生产配置源中还应设置角色映射:
{
"DnnSso": {
"Enabled": true,
"Issuer": "DnnFormPlatform.PortalBridge",
"Audience": "FormPlatform",
"MaximumTicketLifetimeSeconds": 60,
"AutoProvisionUsers": true,
"RoleMappings": {
"Administrators": ["Administrator"],
"Form Authors": ["FormDesigner"],
"Survey Assistants": ["SurveyAssistant"]
}
}
}
映射目标必须是已有的 FormPlatform 角色。建议只映射经过审查的 DNN 角色,尤其不要启用“按同名角色自动授权”。
3. 配置 DNN 服务器
在 DNN 网站的 `web.config` `<appSettings>` 添加以下服务器级设置(不要通过模块 Settings 保存密钥):
<add key="FormPlatform.DnnSso.Enabled" value="true" />
<add key="FormPlatform.DnnSso.SharedSecret" value="<同一 Base64 密钥>" />
<add key="FormPlatform.DnnSso.Issuer" value="DnnFormPlatform.PortalBridge" />
<add key="FormPlatform.DnnSso.Audience" value="FormPlatform" />
<add key="FormPlatform.DnnSso.TicketLifetimeSeconds" value="60" />
生产环境应使用受保护的 ASP.NET 配置节或服务器秘密管理机制保护该密钥。更改 `web.config` 会回收 DNN 应用程序池,这是正常现象。
4. 配置模块实例
在 DNN 页面添加 **FormPlatform Portal Bridge** 模块,配置 FormPlatform Base URL、Launch Path,并勾选 **Use DNN single sign-on**。只有已登录 DNN 用户会获得 SSO;匿名 DNN 访问仍是普通 FormPlatform 跳转。
安全和边界
- DNN 与 FormPlatform 必须都使用 HTTPS。
- 共享密钥至少 32 字节;轮换密钥时需在两个应用中同时更新,旧 ticket 最多 60 秒后自然失效。
- FormPlatform 的本地登录、ACL、许可证、表单权限检查继续生效。DNN ticket 不是绕过 ACL 的管理员票据。
- respondent Cookie 与系统用户 Cookie 不混用;DNN 不会获得 respondent 权限。
- 当前一次性 ticket 防重放缓存位于 FormPlatform 进程内。多实例 FormPlatform 部署时,应将该缓存替换为共享的分布式缓存后再启用 DNN SSO。