wecom_dingtalk_integration_plan.md 60 KB

Client/tboss_oa_module 企业微信与钉钉集成执行计划

目标:在保留 Client/AndroidClient/IOS 现有 Flutter Add-to-App 能力的同时,将 Client/tboss_oa_module 通过 Flutter Web + H5 微应用 集成到企业微信和钉钉工作台。
应用类型:企业微信自建应用、钉钉企业内部应用。
服务端ErpServer/SunGate.RestApi(.NET Framework 4.8、OWIN Self Host Web API)。
设计依据Client/tboss_oa_module/docs/wecomClient/tboss_oa_module/docs/dingtalk 中的本地官方文档,以及现有 Flutter、SunGate.RestApiManager.BusinessManager.DataAccess 代码。


1. 已核实的现状与实施原则

1.0 SerialNo 客户输入与内部路由标识

  • 客户可识别、可提供的业务编号只有 ERP SerialNo
  • LinkerSerialNo 是现有程序内部使用的路由标识,不是客户配置项:
普通 ERP:       LinkerSerialNo = "0" + SerialNo.ToUpper()
MasterService: LinkerSerialNo = "1" + SerialNo.ToUpper()
  • 上述规则已由 SunGate.SunSystem/AutoRunProc.cs 根据 ErpSystem.VersionType 自动执行,并把结果传给 LinkerConnect.ConnectLinkerServer() 完成隧道登记。
  • ERP 配置页面、实施向导和客户文档禁止要求客户填写、计算或理解 LinkerSerialNo
  • ERP 配置页面应直接读取本机 ErpSystem.SerialNo + VersionType,在内部生成路由标识和完整的企微/钉钉 URL;客户只点击“复制 URL”并粘贴到平台后台。
  • 如将来提供独立的公网配置向导,其输入也只能是 SerialNo,必须由服务端根据已登记的隧道元数据解析内部路由标识;禁止要求客户自行补 0/1 前缀。

1.1 现有系统保持稳定、采用增量改造

  • Android/iOS 原生嵌入已经可用,现有 MethodChannel、宿主配置和页面路由行为必须保留。
  • 企微/钉钉集成不得影响现有 Android/iOS Add-to-App。 Web 能力只能以增量方式接入,不能改变原生入口、宿主协议、初始化顺序、登录参数、请求头、草稿、附件、导航和审核页面的既有行为。
  • Android/iOS 是否迁移到新的 AuthToken 属于独立的后续安全改造,不是本次企微/钉钉集成的上线前置条件;未经单独设计、实施和回归,不得修改现有原生鉴权链。
  • 不在现有稳定控制器中进行大范围重构;新增能力优先通过独立控制器、业务类、数据访问类和鉴权处理器接入。
  • 所有数据库读写严格遵循现有服务端分层:
SunGate.RestApi
    ↓
Manager.Business
    ↓
Manager.DataAccess
    ↓
SQL Server
  • SunGate.RestApi 不直接执行 SQL。
  • Manager.Business 负责业务规则、平台配置校验、平台接口编排、用户身份匹配和 ERP 登录上下文处理。
  • Manager.DataAccess 继承现有 DbObject,使用参数化 SqlParameterSunlikeDataSet 和现有连接配置访问数据库。
  • 新增 .cs 文件后必须同步加入旧式项目文件中的 <Compile Include="...">

1.2 企业应用配置来自数据库

企业微信和钉钉配置不写入 app.config,由 ERP 配置页面维护在:

TbrSystem.dbo.CORP_AUTH

已在测试数据库核实表结构:

字段 类型 说明
COMPNO nvarchar(4) ERP 公司编号,联合主键
AUTH_TYPE nvarchar(20) 平台类型,联合主键
CORP_ID nvarchar(128) 企业 CorpId
AGENT_ID nvarchar(32) 应用 AgentId
APP_KEY nvarchar(128) 钉钉 Client ID/AppKey 等公开应用标识
APP_SECRET nvarchar(256) 应用 Secret,敏感字段
TOKEN nvarchar(256) 平台回调 Token,MVP 免登暂不使用
ENCODING_AES_KEY nvarchar(128) 平台回调加密密钥,MVP 免登暂不使用
IS_ENABLE nvarchar(1) 是否启用

数据库主键为 (COMPNO, AUTH_TYPE)。由于 H5 首页不携带 COMPNO,ERP 配置页面必须保证同一 ErpServer、同一 AUTH_TYPE 的所有有效记录使用同一组企业应用凭证;服务端按平台读取后进行唯一一致性校验。AccessToken 和后续 JSAPI Ticket 按 AUTH_TYPE + CORP_ID + AGENT_ID/APP_KEY 隔离。

建议统一 AUTH_TYPE 常量,并与 ERP 配置页面保存值保持一致:

WECOM
DINGTALK

最终常量值须在编码前与 ERP 配置页面实现确认,禁止在多处使用不同自由文本。

1.3 用户映射来自数据库

平台用户与 ERP 用户的映射保存在:

TbrSystem.dbo.USER_CORP_AUTH

已在测试数据库核实实际表结构:

字段 类型 可空 说明
COMPNO nvarchar(4) ERP 公司编号,联合主键
AUTH_TYPE nvarchar(20) 平台类型,联合主键
CORP_ID nvarchar(128) 平台企业 ID,联合主键
PLATFORM_USER_ID nvarchar(128) 企微 userid 或钉钉 userid,联合主键
PLATFORM_USER_NAME nvarchar(128) 平台通讯录姓名,仅用于展示、核对和审计
UNION_ID nvarchar(128) 钉钉 unionid;企微可为空
USR nvarchar(12) 绑定的 ERP 用户;为空表示尚未绑定
IS_ENABLE nvarchar(1) 映射是否启用
CREATE_DD datetime 创建时间
CREATE_USR nvarchar(12) 创建人
UPDATE_DD datetime 修改时间
UPDATE_USR nvarchar(12) 修改人

数据库主键为:

(COMPNO, AUTH_TYPE, CORP_ID, PLATFORM_USER_ID)

当前表没有外键、Check Constraint、默认值和额外非聚集索引,因此服务端及 ERP 配置页面不能依赖数据库自动补齐 IS_ENABLE、创建时间或修改时间。

新需求需要跨 COMPNO 按平台身份查询唯一有效绑定。上线前应先检查并清理重复有效数据,再评审增加唯一筛选索引:

CREATE UNIQUE NONCLUSTERED INDEX UX_USER_CORP_AUTH_ACTIVE_PLATFORM
ON dbo.USER_CORP_AUTH (AUTH_TYPE, CORP_ID, PLATFORM_USER_ID)
INCLUDE (COMPNO, USR, PLATFORM_USER_NAME, UNION_ID)
WHERE IS_ENABLE = N'T';

该索引只约束“同一平台用户只能有一条有效绑定”,不会删除历史停用绑定。正式执行数据库变更前必须在测试库验证 SQL Server 版本、现有数据和旧 ERP 配置页面兼容性。

用户映射规则:

  • 只使用 PLATFORM_USER_ID 等稳定 ID 匹配身份,禁止使用姓名匹配。
  • PLATFORM_USER_NAME 保存最近一次从平台取得的企业通讯录姓名,变化时允许更新,不影响绑定关系。
  • 企微 snsapi_base 换号只返回 UserId;只有具备成员信息读取权限时才进一步读取企业通讯录姓名,否则保留 ERP 配置页面中已有的姓名。
  • 钉钉免登换号接口直接返回 name,可用于刷新 PLATFORM_USER_NAME;同时保存返回的 unionid 供辅助核对。
  • 不把个人昵称当作企业通讯录姓名;MVP 不为获取昵称或真实头像增加额外个人授权。
  • 平台用户首次进入且不存在有效映射时,由 Web 显示类似 Android/iOS 的 ERP 登录窗口;用户选择帐套并输入 ERP 用户账号、密码。
  • 只有 ERP 帐套、账号、密码通过现有 ERP 登录校验后,服务端才自动写入 USER_CORP_AUTH
  • ERP 密码仅用于当次验证,禁止写入 USER_CORP_AUTH、日志、Token、浏览器持久化存储或任何平台配置表。
  • USR IS NULL 表示尚未完成 ERP 凭证绑定,不能签发业务 AuthToken。
  • USR IS NOT NULLIS_ENABLE = 'T' 才允许后续平台免密自动登录。
  • IS_ENABLE = 'F' 表示映射停用。
  • 表中不保存 DEP;登录时通过 COMPNO + USR 查询 ERP 用户当前部门,避免调部门后残留旧映射。
  • 表中不保存 Web LoginId、一次性授权码、平台 AccessToken 或 AuthToken。
  • MVP 对同一 (AUTH_TYPE, CORP_ID, PLATFORM_USER_ID) 在同一 ErpServer 内只允许一个有效绑定;切换帐套必须重新验证目标帐套的 ERP 账号密码,并在同一业务事务中停用旧绑定、启用新绑定。

1.4 免登与 JS-SDK 鉴权解耦

  • 企业微信使用 OAuth2 snsapi_base 静默授权获取 code,服务端调用 auth/getuserinfo 换取 UserId。免登不依赖 ww.register
  • 钉钉使用 dd.requestAuthCode 获取免登码,服务端调用 topapi/v2/user/getuserinfo 换取 userid。该 API 无需先执行 dd.config
  • 第一阶段仅实现工作台免登和现有 OA 业务,不实现非必要的 JSAPI 鉴权。
  • 后续确需使用通讯录、扫码、定位等受保护 JSAPI 时,再增加签名接口:
    • 企业微信:SHA-1。
    • 钉钉当前本地官方文档:SHA-256。

1.5 不信任浏览器提交的 ERP 身份

Web 端传入的 LoginIdCompNoDepUsr 不能作为可信身份。

  • COMPNO 不参与首页路由和平台启动配置选择。首次绑定时它只是用户选择的 ERP 登录目标,必须经过 ERP 凭证校验;后续自动登录时只能来自服务端映射。
  • 平台身份验证成功后,由服务端查询映射决定是自动登录,还是返回 BindingTicket 进入首次 ERP 登录窗口。
  • 业务请求必须携带服务端签发的短期 AuthToken。
  • 服务端验证 AuthToken 后建立可信身份上下文,再供 OA/File 等控制器使用。
  • 不在 ApiCommon.getHeadValue() 中加入隐式验签逻辑;使用独立 DelegatingHandler 或等价统一处理器,避免重复验签和隐藏副作用。

2. 实施前必须确认的业务规则

以下事项未确认前,不进入正式免登开发:

2.1 首次登录绑定与后续自动登录

CORP_AUTH 保存企业应用配置,USER_CORP_AUTH 保存平台用户与 ERP 用户映射:

平台免登:AUTH_TYPE + CorpId + PlatformUserId
                    ↓ 查询所有帐套
USER_CORP_AUTH
      ├─ 唯一有效绑定 → CompNo + Usr → 自动登录
      └─ 无有效绑定   → 返回一次性 BindingTicket
                              ↓
                    选择帐套 + ERP账号 + ERP密码
                              ↓
                    ERP凭证校验成功后写入绑定

实施规则:

  1. URL 不携带 COMPNO。完成平台身份验证后,以 (AUTH_TYPE, CORP_ID, PLATFORM_USER_ID, IS_ENABLE='T') 跨帐套查询当前 ErpServer 内的有效映射。
  2. 找到且仅找到一条有效映射时,读取其中的 COMPNO + USR,动态取得当前部门和用户状态,直接建立 Web LoginId 并签发 AuthToken。
  3. 未找到有效映射时,不签发业务 AuthToken;签发短期、一次性的 BindingTicket,只允许读取帐套列表和提交首次绑定。
  4. Flutter Web 使用 BindingTicket 获取可选帐套列表,用户选择帐套并输入 ERP 用户账号、密码。
  5. 服务端严格复用现有 Manager.Business.Users.CheckUsrDataLogin() 验证帐套、用户、密码和有效期,再使用 Manager.Business.LoginInfo.Logon() 创建登录上下文。
  6. ERP 登录成功后,通过 Manager.Business.UserCorpAuth → Manager.DataAccess.DbUserCorpAuth 写入 USER_CORP_AUTH,随后签发业务 AuthToken。
  7. 写入绑定与“停用该平台用户的其他有效绑定”必须在业务层控制的一致性操作中完成。当前表没有对应唯一约束,DataAccess 不得自行决定冲突策略。
  8. 如果发现同一平台用户存在多条有效绑定,禁止按第一条、更新时间或 COMPNO 排序自动选择;返回“绑定数据冲突”,由切换帐套流程或 ERP 管理页面修复。
  9. 使用 PLATFORM_USER_NAME 在 ERP 页面展示并辅助核对,不能据此自动绑定。
  10. 每次自动登录动态读取 ERP 用户当前部门、姓名、有效期和启用状态;ERP 用户失效时拒绝登录,但不自动改绑其他用户。
  11. 钉钉 userid 作为当前企业内的主要映射 ID,UNION_ID 用于辅助核对和后续跨应用场景。
  12. 企微互联企业返回 CorpId/UserId 格式时,必须按最终确认的兼容规则规范化后再查询。

禁止默认假设平台 UserId 与 ERP Usr 相同。

切换帐套视为高风险重新绑定操作:必须重新输入目标帐套 ERP 账号密码,不能仅凭当前 AuthToken 修改 COMPNO/USR

2.2 ERP LoginId 生命周期

现有 OA 接口通过 ApiCommon.GetCompInfoByLoginId() 查询 LOGIN_INFO_MOBILE 中的登录上下文。必须确定 Web 免登成功后:

  • 调用现有 Manager.Business.LoginInfo.Logon() 创建 Web LoginId;或
  • 新鉴权上下文直接提供 CompNo/Dep/Usr,逐步减少对 LoginId 的依赖。

MVP 推荐复用现有 LoginInfo.Logon(),生成独立 Web LoginId,以最小改动兼容现有控制器。退出或 Token 失效后的 LoginId 清理策略需与现有登录记录生命周期一致。

2.3 客户 ErpServer 与帐套来源

企业内部应用首页不携带 COMPNO。URL 路径内部包含用于定位客户 ErpServer 的 LinkerSerialNo,但该值完全由 ERP 配置页面生成,不是客户输入:

ERP 页面生成并供客户直接复制:

企业微信(实际页面显示完整值,不显示占位符):
https://www.linkerplus.com/oa/{LinkerSerialNo}/wecom/

钉钉(实际页面显示完整值,不显示占位符):
https://www.linkerplus.com/oa/{LinkerSerialNo}/dingtalk/?corpid=$CORPID$

LinkerSerialNo 只在程序内部使用,取 ErpServer 当前向 Linker 上报的完整路由值,严格保持现有规则:

MasterService:1 + ErpSystem.SerialNo.ToUpper()
其他 ERP 类型:0 + ErpSystem.SerialNo.ToUpper()

配置规则:

  • 每个客户只需要知道原始 SerialNo,不需要知道 LinkerSerialNo 或 ERP 类型前缀。
  • ERP 配置页面从当前 ErpServer 运行环境读取 SerialNoVersionType,自动生成内部 LinkerSerialNo 及上述完整 URL。
  • ERP 页面只读显示原始 SerialNo 和完整 URL,并提供复制;不提供 LinkerSerialNo 输入框,也不要求客户手工拼接 URL。
  • 用户把生成的 URL 分别复制到企业微信自建应用首页、钉钉网页应用首页和 PC 端首页。
  • 平台后台允许管理员填写 URL,系统无法阻止其他企业管理员把已知的客户 URL 填入其自有应用;安全目标不是隐藏 SerialNo,而是保证错误企业/错误应用即使使用该 URL,也无法通过平台身份校验、获取 BindingTicket、读取帐套或建立用户映射。
  • 钉钉官方支持在首页地址中配置 corpid=$CORPID$,钉钉容器会替换为当前组织 CorpId;服务端仍须与 CORP_AUTH.CORP_ID 比较,不能直接信任该参数。
  • 企业微信没有通过授权码返回 ERP SerialNo 的机制;其 OAuth code 只用于取得企业微信 UserId。
  • 钉钉 requestAuthCode 返回的 code 同样不包含 ERP SerialNo
  • 因此,两个平台都必须使用 ERP 页面生成的固定首页 URL;内部路由值不能在授权完成后推断,也不能交由客户手工计算。
  • SerialNo 不重复保存到 CORP_AUTH。数据库已经属于确定的客户 ErpServer,重复保存会产生两处数据不一致风险。
  • LinkerSerialNo 只负责选取客户隧道,不是认证凭据。

COMPNO 的来源:

  • 首次绑定时,通过现有 Manager.Business.SunSystem.GetCompData() 获取当前 ErpServer 的帐套列表,由用户在登录窗口选择。
  • 后续自动登录时,从唯一有效的 USER_CORP_AUTH.COMPNO 取得。
  • 切换帐套时重新展示帐套列表,并要求验证目标帐套 ERP 账号密码。
  • Flutter Web 不能缓存一个 COMPNO 并将其作为可信身份。

由于 URL 不再提供 COMPNO,平台身份验证前必须能唯一确定当前客户的企微/钉钉应用配置。MVP 约束为:

  • 同一 ErpServer 对每个 AUTH_TYPE 只允许一个有效的企业内部应用配置。
  • 如果 CORP_AUTH 因现有联合主键存在多条同平台记录,这些记录的 CORP_ID/AGENT_ID/APP_KEY/APP_SECRET 必须完全一致,由 ERP 配置页面同步维护。
  • 服务端按 AUTH_TYPE 读取并校验有效配置;不存在返回“平台未配置”,出现不同凭证返回“平台配置冲突”,不能任取第一条。

服务端必须验证:

  • URL 中的 LinkerSerialNo 对应当前在线 SSH 隧道。
  • Linker 网关注入的可信路由标识与本机当前 LinkerSerialNo 一致。
  • 当前 AUTH_TYPE 的有效应用配置唯一且无冲突。
  • 平台授权码必须使用当前 ErpServer 的 CORP_AUTH 凭证在服务端兑换成功;客户端不得提交或覆盖 CORP_ID/AGENT_ID/APP_KEY/APP_SECRET
  • 平台换号上下文属于该配置对应的企业和应用;若平台接口返回 CorpId,则必须与 CORP_AUTH.CORP_ID 精确一致;若换号接口不返回 CorpId,则以“使用该企业内部应用凭证成功兑换该 code”为权威归属证明。
  • 首次绑定提交的 COMPNO 存在于当前 ErpServer,并且 ERP 凭证验证成功。
  • 自动登录使用的 COMPNO 必须来自 USER_CORP_AUTH,不能来自浏览器参数。

2.4 跨客户 SerialNo 冒用防护

SerialNo 可能被看到或猜到,因此不得把它当作 Secret。系统采用下面的双重归属校验:

URL 中的 LinkerSerialNo
    ↓ 只用于路由
Linker 网关根据在线隧道登记关系转发
    ↓ 注入不可由浏览器伪造的可信路由标识
目标 ErpServer 校验:可信路由标识 == 本机实际 LinkerSerialNo
    ↓
只读取目标 ErpServer 本机数据库中的 CORP_AUTH
    ↓
使用本机 CORP_AUTH 的企业内部应用凭证兑换平台 code
    ↓
确认 code 属于该 CorpId + AgentId/AppKey 对应的企业内部应用
    ↓
成功后才允许查询 USER_CORP_AUTH 或签发 BindingTicket

必须遵循以下规则:

  1. BootstrapConfig 只从路由到达的 ErpServer 本机 CORP_AUTH 返回必要公开标识,禁止从 URL、Query、Header 或登录请求接受平台凭证。
  2. Linker 网关必须移除浏览器传入的所有内部路由 Header,再根据实际选中的在线隧道注入可信路由标识。ErpServer 校验失败立即拒绝,不进入平台换号。
  3. 企业微信必须使用本机配置的 CORP_ID + APP_SECRET 获取 AccessToken,并在本机配置的企业/应用上下文中兑换 OAuth code。其他企业成员不能凭自己的企业应用 code 换得本客户 UserId
  4. 钉钉先把 $CORPID$ 的替换值与本机 CORP_AUTH.CORP_ID 比较,不一致时前端停止并由服务端再次拒绝;最终仍以本机 APP_KEY + APP_SECRET 对应的 AccessToken 能否成功兑换 requestAuthCode 为权威判断,不能仅信任 $CORPID$ Query。
  5. 平台 code 兑换成功之前,禁止查询或返回帐套列表、查询 USER_CORP_AUTH、签发 BindingTicket/AuthToken、创建 LoginId。CompanyListBindErpUser 只接受已完成上述双重校验后签发的一次性 BindingTicket。
  6. ERP 配置页面只能修改当前 ErpServer 本机数据库。客户乙即使在自己的企微/钉钉后台使用客户甲生成的完整 URL,也不能修改客户甲的 CORP_AUTH;访问客户甲路径时只会使用客户甲的应用凭证,客户乙的 code 将因企业/应用不匹配而失败。
  7. 配置保存时先保持 IS_ENABLE='F',由 Manager.Business 使用所填凭证调用平台接口验证凭证有效性及应用归属;验证成功后才允许启用。条件允许时增加一次真实端内免登验证,确认工作台运行时 CorpId、应用标识和本机配置一致。
  8. CORP_AUTH 的 Secret 即使验证成功也不得返回浏览器。公开的 CorpId、AgentId、AppKey 和 SerialNo 不能代替 Secret,也不能单独构成登录凭据。
  9. BootstrapConfig、平台登录和绑定接口按 LinkerSerialNo + AUTH_TYPE + IP 限流,错误响应统一使用“客户或平台配置不匹配”,避免通过详细错误枚举客户配置。

典型冒用场景的结果:

客户乙在自己的企微/钉钉后台使用客户甲生成的完整 URL
    ↓
请求被正确路由到客户甲 ErpServer
    ↓
服务端只读取客户甲 CORP_AUTH
    ↓
客户乙的 CorpId/code 与客户甲企业内部应用上下文不匹配
    ↓
拒绝登录;不返回帐套;不签发 BindingTicket;不写 USER_CORP_AUTH

此方案不依赖 SerialNo 保密。即使 SerialNo 泄露,攻击者最多发起到目标客户公网入口的无效请求,不能形成目标客户的平台身份或 ERP 身份。仍需通过限流、审计和统一错误响应控制扫描与拒绝服务风险。

2.5 官方文档对路由方案的支持

企业微信:

  • docs/wecom/server/网页授权登录-构造网页授权链接.md 明确 redirect_uri 由应用指定,授权后回跳该地址并附加 codestate
  • 同一文档明确 state 只允许 a-zA-Z0-9,长度不超过 128 字节,因此使用服务端缓存的一次性随机状态号,不直接把完整业务 JSON 放入 state
  • docs/wecom/server/网页授权登录-开始开发.md 明确可信域名要求域名和端口一致,但校验与 URL 路径无关,因此同一 www.linkerplus.com 下可以使用不同客户路径。
  • 企微 code 只能换取平台 UserId,不会返回 ERP SerialNo;客户路径必须由应用首页和 redirect_uri 保留。

钉钉:

  • docs/dingtalk/client/requestAuthCode.md 明确企业内部网页应用支持 requestAuthCode,输入为 corpId + clientId,返回值只有一次性 code
  • 同一文档明确首页地址/PC 端首页可添加 corpid=$CORPID$,钉钉容器会替换为当前组织 CorpId。
  • docs/dingtalk/server/身份验证-网页应用(H5微应用)免登.md 明确应用首页、重定向 URL/端内免登地址与运行 JSAPI 的页面域名必须一致,并且免登必须在钉钉端内执行。
  • 钉钉没有自动注入 ERP SerialNo 的占位符;SerialNo 仍由每个客户固定首页 URL 的路径提供。

3. 端到端架构

3.1 启动配置

钉钉 dd.requestAuthCode 需要 corpIdclientId;企业微信构造 OAuth URL 需要 CorpIdAgentId 和回调地址。因此增加只返回公开字段的启动配置接口:

GET /oa/{LinkerSerialNo}/api/ext_erp/ExternalAuth/BootstrapConfig?platform=dingtalk

允许返回:

{
  "platform": "dingtalk",
  "corpId": "ding-corp-id",
  "agentId": "123456",
  "clientId": "ding-app-key",
  "enabled": true
}

禁止返回:

  • APP_SECRET
  • TOKEN
  • ENCODING_AES_KEY
  • AccessToken/JSAPI Ticket

启动配置按当前 ErpServer 的唯一有效 AUTH_TYPE 配置读取,不依赖 COMPNO。如果同一平台存在多组不同应用凭证,返回配置冲突并停止免登。

3.2 企业微信免登

工作台打开固定客户 H5 路径
    ↓
URL 已携带 LinkerSerialNo + wecom
    ↓
Linker 网关按 LinkerSerialNo 路由到客户 ErpServer
    ↓
Web 调用同一客户路径下的 BootstrapConfig
    ↓
服务端生成并缓存绑定客户/平台的 state
    ↓
没有 code:构造 snsapi_base OAuth URL
    ↓
企业微信回跳同一客户 H5 路径:code + state
    ↓
POST 同一客户路径下的 WeComLogin(code, state)
    ↓
ExternalAuthController
    ↓
Manager.Business.CorpAuth / ExternalAuth
    ↓
Manager.DataAccess.DbCorpAuth 读取 CORP_AUTH
    ↓
获取并缓存企微 AccessToken
    ↓
auth/getuserinfo 换取 UserId
    ↓
Manager.Business.UserCorpAuth
    ↓
Manager.DataAccess.DbUserCorpAuth 跨帐套查询有效绑定
    ├─ 唯一绑定:取得 COMPNO/USR,动态解析 Dep,创建 Web LoginId,签发 AuthToken
    ├─ 无绑定:签发一次性 BindingTicket,进入 ERP 登录窗口
    └─ 多条有效绑定:返回绑定冲突,不自动选择

企微授权注意事项:

  • redirect_uri 必须 URL encode。
  • redirect_uri 固定为当前客户路径 https://www.linkerplus.com/oa/{LinkerSerialNo}/wecom/,OAuth 回跳后不会丢失客户路由。
  • 回调域名、端口必须与可信域名配置一致。
  • code 最长 512 字节、5 分钟有效且只能使用一次。
  • 企微官方规定 state 只能使用 a-zA-Z0-9 且不超过 128 字节。推荐使用 32~64 位随机十六进制状态号,服务端短期缓存其对应的 LinkerSerialNo + AUTH_TYPE + nonce + expiresAt,并在登录时一次性核销。
  • state 必须由服务端验证,不能只由 Flutter Web 本地比较。
  • 登录完成后用 history.replaceState 清理 URL 中的 code/state,避免刷新重复兑换。

3.3 钉钉免登

工作台打开固定客户 H5 路径
    ↓
URL 已携带 LinkerSerialNo + dingtalk
    ↓
钉钉将首页参数 $CORPID$ 替换为当前组织 CorpId
    ↓
Linker 网关按 LinkerSerialNo 路由到客户 ErpServer
    ↓
Web 调用同一客户路径下的 BootstrapConfig
    ↓
使用 corpId + clientId 调用 dd.requestAuthCode
    ↓
POST 同一客户路径下的 DingTalkLogin(code)
    ↓
ExternalAuthController
    ↓
Manager.Business.CorpAuth / ExternalAuth
    ↓
Manager.DataAccess.DbCorpAuth 读取 CORP_AUTH
    ↓
获取并缓存钉钉 AccessToken
    ↓
topapi/v2/user/getuserinfo 换取 userid
    ↓
Manager.Business.UserCorpAuth
    ↓
Manager.DataAccess.DbUserCorpAuth 跨帐套查询有效绑定
    ├─ 唯一绑定:取得 COMPNO/USR,动态解析 Dep,创建 Web LoginId,签发 AuthToken
    ├─ 无绑定:签发一次性 BindingTicket,进入 ERP 登录窗口
    └─ 多条有效绑定:返回绑定冲突,不自动选择

钉钉免登码 5 分钟有效且只能使用一次,前端应对同一次初始化做互斥保护,避免 Widget 重建或重复回调导致二次兑换。

钉钉官方要求网页应用首页地址、重定向 URL/端内免登地址与调用 JSAPI 的页面域名保持一致。requestAuthCode 返回值仅包含 code,因此后续登录请求必须继续使用当前页面中 /oa/{LinkerSerialNo}/ 的 API 前缀。

3.4 首次 ERP 登录与自动绑定

平台身份已验证但不存在映射时:

PlatformLogin 返回 BindingTicket
    ↓
GET CompanyList(BindingTicket)
    ↓
Manager.Business.SunSystem.GetCompData()
    ↓
Flutter 显示帐套 + ERP用户账号 + 密码登录窗口
    ↓
POST BindErpUser(BindingTicket, compNo, usr, password)
    ↓
Manager.Business.ExternalAuth / UserCorpAuth
    ↓
Manager.Business.Users.CheckUsrDataLogin()
    ↓
失败:统一登录失败响应,不写映射
成功:
    ↓
Manager.Business.UserCorpAuth
    ↓
Manager.DataAccess.DbUserCorpAuth 写 USER_CORP_AUTH
    ↓
Manager.Business.LoginInfo.Logon()
    ↓
签发 AuthToken,进入 OA

BindingTicket 要求:

  • 由服务端签发,绑定 LinkerSerialNo + AUTH_TYPE + CORP_ID + PLATFORM_USER_ID + PLATFORM_USER_NAME + UNION_ID
  • 5 分钟内有效、只能成功使用一次,不能调用普通 OA/File 业务接口。
  • 不包含 ERP 密码,不允许客户端修改平台 UserID。
  • 获取帐套列表和提交绑定都必须限流;连续密码失败达到阈值后冻结该 Ticket,并按平台用户、IP 和客户路由审计。

ERP 登录要求:

  • 帐套列表复用 Manager.Business.SunSystem.GetCompData(),只向未绑定页面返回 COMPNO + NAME 等必要公开字段,不能返回数据库服务器、数据库名、账号或连接密码。
  • 账号密码校验复用 Manager.Business.Users.CheckUsrDataLogin(),不得在 Controller 或 Flutter 中复制密码校验规则。
  • 校验成功后从 ERP 用户资料动态取得当前 DEP 和姓名,并复用 Manager.Business.LoginInfo.Logon()
  • 密码通过 HTTPS 请求,仅驻留于本次服务端调用内存;禁止保存、回显或记录。
  • 绑定成功后 MVP 保证该平台用户在当前 ErpServer 只有一条 IS_ENABLE='T' 映射。

3.5 业务请求鉴权

Flutter Web
Authorization: Bearer <AuthToken>
    ↓
WebAuthHandler(统一验签、有效期、LinkerSerialNo、平台、CompNo)
    ↓
建立可信 LoginId / CompNo / Dep / Usr 上下文
    ↓
现有 OAController / FileController

AuthToken 至少包含:

  • Token ID
  • LinkerSerialNo 或其不可逆摘要
  • 平台
  • CompNo
  • LoginId
  • Usr
  • 签发时间
  • 过期时间

采用标准、可审计的 HMAC 签名方案,不自行组合不明确的“HMAC + AES”。签名密钥不得硬编码或提交 Git,应使用现有安全配置机制、外部安全配置或操作系统保护的密钥,并支持轮换。服务端验证 Token 时还必须确认 Token 客户路由与本机当前 LinkerSerialNo 一致,防止 Token 经另一客户路径重放。

3.6 Android/iOS 兼容策略

不能在未升级原生认证链之前直接全局拒绝所有无 Bearer Token 的请求,否则会破坏现有 Android/iOS。

实施边界:

  1. 新增 Web Bearer 鉴权链,先覆盖 Web 发布范围,并保留原生现有调用方式。
  2. Android/iOS AuthToken 迁移另行立项、设计和验收。未来如需迁移,可在原生登录成功后由宿主向 Flutter 提供服务端签发的 AuthToken,再逐步关闭 Legacy LoginId;该工作不纳入本次企微/钉钉集成,也不得与本次 Flutter Web 代码合并实施。

正式对公网发布 H5 前,必须完成安全评审,确认 Legacy 模式不会成为绕过 Web AuthToken 的入口。不能仅依赖 Origin、User-Agent 或自定义客户端 Header 区分可信原生请求。


4. 服务端改造

4.1 Manager.DataAccess

新增:

ErpServer/Manager.DataAccess/DbCorpAuth.cs
ErpServer/Manager.DataAccess/DbUserCorpAuth.cs

命名空间和实现风格参照现有代码:

namespace Manager.Business.Data
{
    public class DbCorpAuth : DbObject
    {
        public DbCorpAuth(string connStr) : base(connStr) { }

        public SunlikeDataSet GetEnabledData(string authType)
        {
            // 按平台读取有效配置,由 Business 校验应用凭证唯一一致
        }
    }
}

DbUserCorpAuth 按相同风格实现:

public SunlikeDataSet GetEnabledBindings(
    string authType,
    string corpId,
    string platformUserId)
{
    // 跨 COMPNO 查询该平台用户在当前 ErpServer 的有效绑定
}

要求:

  • 使用现有 TbrSystem/SunSystem 连接配置,不新增独立连接字符串。
  • 所有参数使用 SqlParameter,禁止字符串拼接。
  • CORP_AUTH 增加按 AUTH_TYPE 读取全部有效配置的方法,由 Business 校验是否为唯一一致的应用配置。
  • USER_CORP_AUTH 自动登录查询使用 AUTH_TYPE + CORP_ID + PLATFORM_USER_ID + IS_ENABLE='T',不能要求浏览器先提供 COMPNO
  • USER_CORP_AUTH 写入仍使用完整主键 COMPNO + AUTH_TYPE + CORP_ID + PLATFORM_USER_ID
  • 新增参数化的绑定写入、更新平台姓名和停用其他有效绑定方法;显式维护 IS_ENABLE/CREATE_DD/CREATE_USR/UPDATE_DD/UPDATE_USR
  • 返回字段范围固定,避免 SELECT *
  • DataAccess 只负责数据库访问,不调用企微/钉钉 HTTP API。
  • DbCorpAuth.csDbUserCorpAuth.cs 加入 Manager.DataAccess.csproj

CORP_AUTH 仍由 ERP 配置页面维护;USER_CORP_AUTH 除 ERP 管理页面外,允许在“平台身份已验证 + ERP 凭证验证成功”的首次绑定流程中由服务端写入。所有读写继续遵循 RestApi → Business → DataAccess 调用链。

4.2 Manager.Business

新增或按现有命名规范拆分:

ErpServer/Manager.Business/CorpAuth.cs
ErpServer/Manager.Business/UserCorpAuth.cs
ErpServer/Manager.Business/ExternalAuth.cs

职责:

  • 通过 Manager.Business.Data.DbCorpAuth 读取配置。
  • 通过 Manager.Business.Data.DbUserCorpAuth 读取用户映射。
  • 校验 IS_ENABLE、必填字段和 AUTH_TYPE
  • 按 ERP 现有加解密规范处理 APP_SECRET
  • 以平台应用身份管理 AccessToken 缓存。
  • 调用企业微信/钉钉开放平台接口。
  • 保存平台配置时先禁用,再验证凭证有效性和企业内部应用归属;验证失败不得将 IS_ENABLE 改为 T
  • 将“可信 Linker 路由 + 本机 CORP_AUTH 平台换号成功”作为平台身份成立的必要条件;任一条件失败均不得继续查询帐套或用户映射。
  • 处理平台错误码、超时和有限重试。
  • 按平台身份跨帐套查询有效映射;唯一映射自动登录,无映射签发 BindingTicket,多映射返回冲突。
  • 通过现有 SunSystem.GetCompData() 返回精简帐套列表。
  • 通过现有 Users.CheckUsrDataLogin() 校验首次绑定或切换帐套的 ERP 凭证。
  • ERP 凭证成功后编排 USER_CORP_AUTH 写入及旧有效绑定停用。
  • 取得 USR 后,通过现有 ERP 逻辑动态解析当前 Dep 和用户姓名。
  • 只把 PLATFORM_USER_NAME 用于显示、核对与审计,禁止按姓名匹配。
  • 复用 Manager.Business.LoginInfo.Logon() 创建 Web LoginId。
  • 将新增文件加入 Manager.Business.csproj

AccessToken 缓存要求:

  • 企微和钉钉分别缓存。
  • 缓存键必须包含 AUTH_TYPE + CORP_ID + AGENT_ID/APP_KEY,不能依赖尚未选择的 COMPNO
  • 过期时间以平台返回的 expires_in/expireIn 为准,并预留安全刷新窗口。
  • 同一缓存键并发刷新时只允许一个实际平台请求。
  • 平台返回 Token 无效错误时清除缓存并允许一次刷新重试。
  • ERP 配置变更后不得继续长期使用旧 Secret 对应的缓存。

4.3 SunGate.RestApi

新增:

Controllers/ExternalAuthController.cs
Models/ExternalAuthModels.cs
Providers/WebAuthHandler.cs
Providers/AuthTokenManager.cs

接口:

GET  api/ExternalAuth/BootstrapConfig
POST api/ExternalAuth/WeComLogin
POST api/ExternalAuth/DingTalkLogin
GET  api/ExternalAuth/CompanyList
POST api/ExternalAuth/BindErpUser
POST api/ExternalAuth/SwitchErpAccount
POST api/ExternalAuth/Logout        # 如采用服务端会话/撤销列表

公网统一通过以下路径访问,Linker 网关解析 LinkerSerialNo 后移除客户路由前缀,再转发到现有接口:

/oa/{LinkerSerialNo}/api/ext_erp/ExternalAuth/...

登录请求:

{
  "code": "one-time-auth-code",
  "state": "wecom-only-server-state"
}

登录接口不得接收 corpIdagentIdappKeyappSecret 并据此选择平台配置。平台配置只能来自实际路由到达的 ErpServer 本机数据库。钉钉 $CORPID$ 只用于提前发现配置错误,不作为服务端身份依据。

已有唯一绑定时返回:

{
  "code": 0,
  "data": {
    "authToken": "...",
    "expiresIn": 3600,
    "config": {
      "baseUrl": "/oa/{LinkerSerialNo}/api/ext_erp/",
      "loginId": "...",
      "compNo": "0001",
      "dep": "...",
      "depName": "...",
      "usr": "...",
      "usrName": "...",
      "poiAmt": 2
    }
  }
}

无绑定时返回:

{
  "code": 0,
  "data": {
    "status": "binding_required",
    "bindingTicket": "...",
    "expiresIn": 300,
    "platformUserName": "张三"
  }
}

首次绑定请求:

{
  "bindingTicket": "...",
  "compNo": "0001",
  "usr": "A001",
  "password": "request-only-plain-value-over-https"
}

要求:

  • Controller 只处理 HTTP 参数、状态码和响应模型,数据库调用必须经过 Manager.Business
  • Controller 不直接读取帐套表、校验 ERP 密码或写入 USER_CORP_AUTH
  • Web 不提交 sn Header 选择客户;客户路由由 URL 路径和 Linker 网关确定。
  • Linker 网关必须删除客户端伪造的内部路由 Header,再注入由实际隧道登记信息产生的可信 LinkerSerialNo
  • Android/iOS 继续使用现有 LinkerTransMitUrl + sn Header,本次路径路由不得改变现有 Native 请求。
  • WebAuthHandler 在路由执行前统一验证 Bearer Token,并将可信身份写入请求上下文。
  • 不修改 ApiCommon.getHeadValue() 的通用语义。
  • 敏感信息、平台 AccessToken、授权码和 AuthToken 不得写入日志或 HelpPage 示例。
  • 将新增文件加入 SunGate.RestApi.csproj,并在 OwinStartup/AttributeRoutingConfig 中按现有方式注册处理器。

4.4 平台 API 规范

企业微信:

GET https://qyapi.weixin.qq.com/cgi-bin/gettoken
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo

钉钉:

POST https://api.dingtalk.com/v1.0/oauth2/accessToken
POST https://oapi.dingtalk.com/topapi/v2/user/getuserinfo

服务端 HTTP 调用统一要求:

  • 明确连接和读取超时。
  • 校验 HTTP 状态码及平台业务错误码。
  • 不无限重试;授权码兑换失败通常不重试同一个 code。
  • 日志只记录平台、公司、接口名、错误码和平台 request ID,不记录 Secret、Token、code。

5. Flutter Web 改造

5.1 Web 构建入口

  • 为 module 补全 web/index.html、图标和 manifest。
  • MVP 仅加载钉钉 requestAuthCode 所需的固定版本 JS-SDK。
  • 企业微信 OAuth 免登不依赖企微 JS-SDK,第一阶段不必加载 wecom-jssdk
  • 后续需要企微 JSAPI 时再引入 wecom-jssdk-2.4.0.js 并实现 ww.register
  • 配置 CSP,限制脚本、接口和资源来源。

5.2 Native 与 Web 隔离硬约束

Flutter Web 适配必须遵守以下边界:

  1. 入口隔离

    • Web 使用独立的 web/index.html 和 Web 认证启动流程。
    • Android/iOS 继续使用现有 Flutter module 入口及宿主启动方式,不要求宿主配合企微/钉钉逻辑。
    • 不得在原生启动路径中加载企微/钉钉 JS-SDK,不得触发 OAuth、dd.requestAuthCode 或读取浏览器 URL 参数。
  2. 编译隔离

    • Web API、JS interop、企微/钉钉 SDK 只能出现在 Web 条件实现中。
    • dart:io、MethodChannel 和原生插件只能出现在 Native 条件实现中。
    • 公共业务代码不得直接依赖 dart:htmldart:js_interopdart:io 或具体宿主 SDK。
    • 隔离必须依靠条件导入/导出保证各目标平台只编译自己的实现,不能仅依靠运行时 kIsWeb 判断。
  3. 协议兼容

    • 现有 Android/iOS MethodChannel 名称、Method 名称、参数结构、返回结构和错误语义保持不变。
    • 现有原生传入的 ERP 配置、LoginId、用户信息及路由参数保持兼容。
    • 为 Web 新增的平台接口应通过新的抽象方法或 Web 实现提供,不能要求原生宿主补传 platformcodecorpId 或 Web AuthToken。
  4. 业务兼容

    • 公共模型需要为 Web 扩展时,新增字段必须可空或提供兼容默认值;不能删除、改名或改变原生正在使用字段的含义。
    • 文件、草稿、导航、下载、预览和审核轨迹采用“公共接口 + Native/Web 独立实现”,Native 默认继续走现有实现。
    • 不得为了 Web 将原生本地文件处理整体改为内存字节模式;可以扩展跨平台模型,但原生路径和现有插件链必须继续有效。
  5. 服务调用兼容

    • Web 使用新免登和 Bearer AuthToken 链路。
    • Android/iOS 继续使用现有登录上下文和请求方式;本次改造不得全局强制 Bearer Token,也不得改变现有通用 Header 读取行为。
    • Web 新增拦截器、Token Provider 或错误处理时,必须只在 Web 初始化路径启用。
  6. 变更控制

    • 优先新增文件和适配层;修改原生已使用的公共文件前,必须先记录现有行为并补充回归用例。
    • 每一批 Flutter Web 改造完成后,均需同时执行 Android、iOS 宿主回归,不能等到全部 Web 功能完成后一次性验证。
    • 若某项 Web 适配必须改变原生对外协议,应从本执行计划中拆出,单独评审后实施。

目标结构:

公共业务页面与领域模型
        ↓
平台能力接口
   ┌────┴────┐
Native 实现  Web 实现
   ↓           ↓
现有宿主通道   企微/钉钉 H5 能力

5.3 平台抽象与条件导入

新增:

lib/core/platform/platform_service.dart
lib/core/platform/platform_service_stub.dart
lib/core/platform/native_platform_service.dart
lib/core/platform/web_platform_service.dart

使用真正的 Dart 条件导入/导出,不能只依靠运行时 kIsWeb 分支隔离 dart:io 或 Web API:

export 'platform_service_stub.dart'
    if (dart.library.io) 'native_platform_service.dart'
    if (dart.library.js_interop) 'web_platform_service.dart';

Web 实现优先使用 package:web 和 Dart JS interop;避免在公共文件中直接导入 dart:iodart:html

平台能力至少覆盖:

  • 获取宿主/ERP 配置。
  • 获取与持久化 AuthToken。
  • 企微 OAuth 和钉钉 requestAuthCode
  • 文件选择、上传、下载和预览。
  • 审核轨迹展示。
  • 草稿存储。
  • 原生宿主导航与 Web GoRouter 导航。

5.4 认证启动顺序

Web:

WidgetsFlutterBinding.ensureInitialized
    ↓
从 /oa/{LinkerSerialNo}/{platform}/ 解析客户与平台上下文
    ↓
BootstrapConfig
    ↓
平台免登
    ↓
保存 AuthToken + ERPConfig
    ↓
创建 ApiClient
    ↓
runApp

原生:

WidgetsFlutterBinding.ensureInitialized
    ↓
Native MethodChannel getConfig
    ↓
保持现有初始化行为
    ↓
runApp

修正现有 AuthService.fetchToken() 未被调用的问题。ApiClient 创建前必须完成认证初始化,或通过可响应更新的 Provider/Interceptor 获取最新 Token。

Web Token 默认只使用内存;如使用 sessionStorage,Key 必须按 LinkerSerialNo + AUTH_TYPE 命名,Token 内部继续绑定实际 COMPNO。当前 URL 的客户或平台与存储上下文不一致时立即清除。由于所有客户共用 www.linkerplus.com Origin,不使用未按客户隔离的 Cookie 或 localStorage 保存登录状态。企微登录后仅清理 URL 中的一次性 code/state,必须保留 /oa/{LinkerSerialNo}/{platform}/ 路径。

上述 AuthService、Token Provider 和 Interceptor 调整只适用于 Web 初始化链;Native 必须继续使用现有配置注入和请求初始化方式。若需要复用同一个 ApiClient 类型,应通过构造参数注入不同认证提供者,不能在 Native 路径中隐式启用 Web Token 获取。

Web ApiClient 使用 /oa/{LinkerSerialNo}/api/ext_erp/ 相对 Base URL,不发送 Native 使用的 snConnection: closeAccept-Encoding: identity。Native ApiClient 继续保留现有 Header 和 HostAppChannel 行为。

5.5 文件模型与上传

当前以下文件直接使用 dart:io、本地路径或 MultipartFile.fromFile,必须纳入改造:

  • lib/core/navigation/host_app_channel.dart
  • lib/shared/models/attachment_file.dart
  • lib/shared/widgets/attachment_picker.dart
  • lib/features/expense/expense_create_page.dart
  • lib/features/expense/expense_detail_page.dart
  • lib/features/expense_apply/expense_apply_detail_page.dart
  • lib/features/expense/expense_api.dart
  • lib/features/expense_apply/expense_apply_api.dart

AttachmentFile 不能再假定始终存在本地文件路径,应能保存:

  • 文件名
  • MIME 类型
  • 文件长度
  • Uint8List 或可延迟读取字节的跨平台对象
  • 原生路径(仅原生可选字段)

该调整必须保持现有 Native 构造方式和路径访问能力兼容。推荐新增跨平台数据来源抽象或兼容构造函数,禁止直接删除原生路径字段或强制 Android/iOS 先把所有文件完整读入内存。

Web 上传使用:

MultipartFile.fromBytes(bytes, filename: fileName)

必须验证:

  • 图片选择和拍照。
  • 多文件选择。
  • 大文件内存占用和大小限制。
  • 草稿恢复时 Web 不能依赖已失效的浏览器本地文件路径。

5.6 下载、预览和审核轨迹

  • 原生继续使用现有 MethodChannel、path_provideropen_filex
  • Web 下载使用 Blob/Object URL,并在完成后释放 Object URL。
  • Web 预览根据 MIME 类型使用新标签页、内嵌预览或直接下载。
  • 处理 WebView 弹窗拦截和下载能力差异。
  • 审核轨迹复用现有:
    • GET api/OA/GetAuditTrail
    • AuditTrailDialog
  • Native 仍可跳转现有原生审核页面,Web 使用 Flutter Dialog 展示同一服务端数据。

5.7 草稿与路由

  • Native 保留 MethodChannel 草稿存储。
  • Web 使用支持 Web 的存储实现;若采用 shared_preferences,需在 pubspec.yaml 增加依赖并验证其 Web 实现。
  • Web 草稿不得持久化无法恢复的临时文件路径。
  • GoRouter 使用适合 H5 部署的 URL 策略;Nginx/静态服务器必须为前端路由配置回退到 index.html

6. 部署与安全

6.1 Linker 客户路径路由

采用统一公共域名下的客户路径方案:

https://www.linkerplus.com/oa/{LinkerSerialNo}/{platform}/
    → Flutter Web

https://www.linkerplus.com/oa/{LinkerSerialNo}/api/ext_erp/*
    → Linker 网关
    → SerialNo 对应的 SSH 反向隧道
    → 客户内网 SunGate.RestApi

Linker 网关必须实现:

  • 严格校验并规范化 URL 中的完整 LinkerSerialNo
  • 使用现有 SerialNo → SSH IP/动态端口 登记关系选择在线隧道。
  • 转发前移除 /oa/{LinkerSerialNo} 公网前缀,保持现有 SunGate.RestApi 路由不变。
  • 删除客户端传入的内部路由 Header,并注入实际隧道登记的可信路由标识供 ErpServer 交叉验证。
  • 找不到在线隧道时返回统一 503 ERP_OFFLINE,不泄露 SSH IP、端口或客户内部信息。
  • 限制允许转发的路径、请求体和上传大小、连接时间及响应超时。
  • SerialNo 仅作为客户路由键,不能作为用户认证或接口授权依据。

ERP 配置页面必须:

  • 从当前运行环境读取 ErpSystem.SerialNo,按现有 ERP 类型前缀规则生成完整 LinkerSerialNo
  • 自动生成企业微信应用首页、企微 OAuth 回调示例、钉钉应用首页和 PC 端首页。
  • 客户界面只展示其熟悉的原始 SerialNoLinkerSerialNo 仅供内部诊断,不作为输入项或要求客户理解的配置项。
  • URL 以已经替换路由值的完整结果只读展示并提供一键复制,不向客户展示 {LinkerSerialNo} 占位符,也不要求客户补 0/1 前缀。
  • SerialNo 变化时提示管理员重新配置平台后台;网关登记切换后旧客户路径失效。

所有客户路径共用 www.linkerplus.com Origin,因此登录状态必须按客户隔离:

  • AuthToken 首选只保存在内存。
  • 如使用 sessionStorage,Key 至少包含 LinkerSerialNo + AUTH_TYPE;实际帐套从 Token 可信声明取得。
  • 页面启动时发现存储的客户或平台与当前 URL 不一致,必须清除旧 Token 和登录上下文;Token 中的帐套由服务端签名保护。
  • 禁止使用不区分客户的 Cookie 或 localStorage Key 保存登录状态。

6.2 域名和 HTTPS

企业微信:

  • OAuth 回调域名必须与可信域名完全匹配,包括端口。
  • 官方文档确认可信域名校验与 URL 路径无关,因此所有客户可使用同一个可信域名 www.linkerplus.com,通过不同 /oa/{LinkerSerialNo}/... 路径区分。
  • JSAPI 可信域名通常要求备案和域名归属验证。

钉钉:

  • 配置本客户固定的应用首页和 PC 端首页,并附加官方支持的 corpid=$CORPID$
  • 确认 CorpId、AppKey、AgentId 属于同一个企业内部应用。
  • 首页地址、重定向 URL/端内免登地址与运行 requestAuthCode 的页面保持相同域名。

两个平台均使用 HTTPS,禁止在生产环境混合内容。当前 ErpServer app.config 中 Linker 相关 URL 仍为 HTTP;H5 公网入口必须使用 HTTPS,Linker 的连接分配和端口上报接口也应升级为 HTTPS 并完成兼容验证。

6.3 同源和 CORS

Flutter Web 与 API 固定使用同一个 Origin:

https://www.linkerplus.com/oa/{LinkerSerialNo}/{platform}/
https://www.linkerplus.com/oa/{LinkerSerialNo}/api/ext_erp/

Web API 使用上述相对客户路径,不跨域调用原有 /api/ext_erp/。这样可以避免浏览器 OPTIONS 预检无法携带实际 sn、第三方 Cookie 限制及当前 CorsHandler 反射任意 Origin 的安全问题。CORS 不作为正常访问链路。

6.4 敏感数据

  • APP_SECRET 按 ERP 现有安全规范加密保存和解密使用。
  • TOKENENCODING_AES_KEY 只在后续回调能力中使用。
  • Secret、ERP 密码、BindingTicket、平台 AccessToken、AuthToken、一次性 code 禁止输出到日志、Swagger/HelpPage、异常响应和浏览器控制台。
  • 当前 Flutter ApiClientLogInterceptor(requestBody: true) 必须对 BindErpUser/SwitchErpAccount 禁用或脱敏,任何构建模式均不得记录密码字段。
  • 登录接口增加频率限制、失败审计和重放保护。

7. 分阶段实施路线

阶段 0:业务与安全前置确认

  • 0.1 确认 AUTH_TYPE 的实际保存值。
  • 0.2 确认平台用户映射表为 USER_CORP_AUTH,并核实实际字段和主键。
  • 0.3 确认 Web LoginId 的创建、续期和清理规则。
  • 0.4 确认工作台首页仅使用 /oa/{LinkerSerialNo}/{platform}/ 提供客户和平台上下文,COMPNO 不进入 URL。
  • 0.5 将 Android/iOS 从 Legacy LoginId 迁移到 AuthToken 记录为独立后续安全项目;确认本次集成不修改现有原生鉴权链。
  • 0.6 确认 AuthToken 密钥保存和轮换机制。
  • 0.7 确认同一 ERP 用户能否在同一平台绑定多个平台账号。
  • 0.8 确认客户只识别原始 SerialNo;内部 LinkerSerialNo 由当前 ErpServer 根据 VersionType 自动派生,不新增到 CORP_AUTH,也不允许客户手工输入或补前缀。
  • 0.9 确认 MVP 同一平台用户在同一 ErpServer 只保留一条有效帐套绑定;切换帐套必须重新验证 ERP 凭证。
  • 0.10 确认 SerialNo 是公开路由键而非认证凭据,采用“可信隧道路由 + 本机平台应用凭证换号”的双重客户归属校验。

阶段 0A:Linker 公网路由

  • 0A.1www.linkerplus.com 部署 Flutter Web,并为 /oa/ 配置 SPA 静态资源和路由回退。
  • 0A.2 实现 /oa/{LinkerSerialNo}/api/ext_erp/* 路由解析、隧道选择和前缀移除。
  • 0A.3 网关删除客户端路由 Header,注入实际隧道登记的可信 LinkerSerialNo
  • 0A.4 实现隧道离线 503 ERP_OFFLINE、超时、上传大小、路径白名单和审计。
  • 0A.5 将 H5 公网入口、Linker 连接分配和端口上报升级为 HTTPS。
  • 0A.6 验证现有 Android/iOS 的 /api/ext_erp/ + sn Header 路由保持不变。

阶段 1:服务端配置读取

  • 1.1 新增 Manager.DataAccess/DbCorpAuth.cs,参数化读取 CORP_AUTH
  • 1.2 新增 Manager.DataAccess/DbUserCorpAuth.cs,实现跨帐套有效绑定查询及完整主键写入。
  • 1.3 新增 Manager.Business/CorpAuth.cs,校验平台配置与启用状态。
  • 1.4 新增 Manager.Business/UserCorpAuth.cs,校验用户绑定、启用状态和 ERP 用户状态。
  • 1.5 更新两个项目的 .csproj
  • 1.6 实现并测试 BootstrapConfig,确保不泄露敏感字段。
  • 1.7 ERP 配置页面读取本机原始 SerialNoVersionType,自动生成并只读展示可直接复制的企微/钉钉完整 URL;客户不填写 LinkerSerialNo,数据库也不重复保存 SerialNo。
  • 1.8 ERP 配置页面保证同一 AUTH_TYPE 的有效平台应用凭证唯一一致。
  • 1.9 检查重复有效绑定并评审创建 UX_USER_CORP_AUTH_ACTIVE_PLATFORM 唯一筛选索引。
  • 1.10 平台配置先以 IS_ENABLE='F' 保存,由 Business 验证 CorpId、AgentId/AppKey、Secret 和应用归属,成功后才允许启用。

阶段 2:服务端免登与 AuthToken

  • 2.1 实现按 AUTH_TYPE + CORP_ID + AGENT_ID/APP_KEY 隔离的平台 AccessToken 缓存。
  • 2.2 实现企微 OAuth code 换 UserId。
  • 2.3 实现钉钉免登码换 userid。
  • 2.4 按平台身份跨帐套查询 USER_CORP_AUTH,实现唯一绑定、未绑定和绑定冲突三种结果。
  • 2.5 使用 COMPNO + USR 动态解析当前部门和用户资料。
  • 2.6 创建/复用 Web LoginId。
  • 2.7 实现 AuthToken 签发、验证、过期和密钥轮换。
  • 2.8 新增 WebAuthHandler,不改变 getHeadValue() 的通用行为。
  • 2.9 实现 ExternalAuthController 及统一响应模型。
  • 2.10 更新 SunGate.RestApi.csproj 和处理器注册。
  • 2.11 校验网关注入的可信 LinkerSerialNo 与本机当前 SerialNo,并将其绑定到 state 和 AuthToken。
  • 2.12 实现短期一次性 BindingTicket、帐套列表和绑定限流。
  • 2.13 复用 SunSystem.GetCompData() 提供精简帐套列表。
  • 2.14 复用 Users.CheckUsrDataLogin() 校验 ERP 帐号密码,成功后写入 USER_CORP_AUTH
  • 2.15 实现切换帐套:重新校验目标 ERP 凭证,并停用旧有效绑定。
  • 2.16 平台 code 兑换前后实施跨客户归属校验;失败时禁止访问 CompanyListUSER_CORP_AUTH、BindingTicket、LoginId 和 AuthToken。
  • 2.17 对跨 SerialNo、跨 CorpId、跨 AgentId/AppKey、跨平台和 code 重放返回统一拒绝结果并记录安全审计。

阶段 3:Flutter Web 最小闭环

  • 3.1 创建并验证 web/ 构建入口。
  • 3.2 记录 Android/iOS 当前入口、MethodChannel、初始化、鉴权、导航和附件行为,形成回归基线。
  • 3.3 实现 Native/Web 平台服务条件导入,确认 Web SDK 不进入 Native 编译和启动路径。
  • 3.4 实现企微 OAuth 和钉钉 dd.requestAuthCode
  • 3.4.1 企微使用服务端一次性 state,并保证 OAuth 回调保留客户路径。
  • 3.4.2 钉钉读取 $CORPID$ 替换结果,与 Bootstrap 返回的 CorpId 交叉验证。
  • 3.5 实现类似 Android/iOS 的帐套选择、ERP账号和密码登录窗口。
  • 3.6 实现未绑定首次登录、绑定成功进入系统及以后自动登录。
  • 3.7 完成 Web AuthToken、ERPConfig 初始化和 ApiClient 注入,Native 继续使用现有初始化方式。
  • 3.8 使用一个只读 OA 接口完成端到端联调。
  • 3.9 使用现有 Android/iOS 宿主构建流程完成编译、启动、登录、页面进入和接口调用回归。
  • 3.10 对比回归基线,确认 MethodChannel 协议、原生路由和请求行为没有变化。

阶段 4:Web 完整功能适配

  • 4.1 以兼容方式扩展文件模型,保留 Native 路径和现有构造方式。
  • 4.2 附件选择、上传、下载和预览适配。
  • 4.3 审核轨迹复用 GetAuditTrailAuditTrailDialog
  • 4.4 草稿存储和 Web 路由适配。
  • 4.5 完成报销、报销申请、加班、用车、公告和报表回归。
  • 4.6 每完成附件、草稿、导航等一类公共代码改造,立即执行对应 Android/iOS 回归。

阶段 5:部署与平台联调

  • 5.1 执行 flutter analyze
  • 5.2 执行 flutter build web --release
  • 5.3 编译 Manager.DataAccessManager.BusinessSunGate.RestApi 及宿主服务。
  • 5.4 配置 www.linkerplus.com HTTPS 和 /oa/{LinkerSerialNo}/ 同源路径代理。
  • 5.5 配置企微可信域名、客户固定应用首页和保留客户路径的 OAuth 回调。
  • 5.6 配置钉钉客户固定应用首页、PC 首页、corpid=$CORPID$ 和权限。
  • 5.7 在真机企业微信、钉钉及 PC 客户端完成联调。

8. 验收标准

8.1 构建

  • flutter analyze 无错误。
  • flutter build web --release 成功。
  • 使用现有 Android/iOS 宿主构建或 Flutter module 打包流程均成功,不要求修改宿主才能通过。
  • Web 专用 SDK、JS interop 和浏览器 API 未进入 Android/iOS 编译产物或启动路径。
  • C# 相关项目和宿主服务编译成功。

8.2 配置与隔离

  • ERP 页面只要求客户识别原始 SerialNo,并使用本机自动派生的内部 LinkerSerialNo 生成企微/钉钉完整 URL;客户无需填写前缀或修改 URL。
  • SerialNo 不重复保存到 CORP_AUTH
  • 不同客户路径只路由到各自登记的 SSH 隧道,隧道离线返回 ERP_OFFLINE
  • 网关不信任客户端路由 Header,ErpServer 会校验可信路由标识与本机 SerialNo。
  • SerialNo 泄露或被其他客户填入其平台后台时,该客户的平台 code 无法通过目标 ErpServer 本机 CORP_AUTH 凭证兑换。
  • CORP_AUTHUSER_CORP_AUTH 只能通过 Manager.Business → Manager.DataAccess 访问。
  • 不同平台企业应用的配置和 AccessToken 缓存不会串用。
  • 禁用配置无法登录。
  • 新配置在平台凭证和应用归属验证成功前保持禁用。
  • Bootstrap 接口不返回任何敏感字段。
  • 自动登录按 (AUTH_TYPE, CORP_ID, PLATFORM_USER_ID, IS_ENABLE='T') 跨帐套查询,绑定写入仍使用完整联合主键。
  • 数据库唯一筛选索引或等价强约束保证同一平台用户最多一条有效绑定。
  • USR 为空或无映射时进入首次 ERP 登录绑定流程,不直接签发业务 AuthToken。
  • 映射停用或 ERP 用户无效时不能自动登录。
  • PLATFORM_USER_NAME 变化不会改变绑定身份。
  • ERP 用户部门变化后,无需修改映射表即可取得当前部门。

8.3 免登

  • 企业微信首次打开可完成静默 OAuth 登录。
  • 企业微信 OAuth 回调后仍处于原客户 LinkerSerialNo/platform 路径,state 跨客户、过期或重放均被拒绝。
  • 客户乙企微自建应用使用客户甲 SerialNo 时,不能换取客户甲平台用户、不能看到客户甲帐套且不能写入绑定。
  • 钉钉首次打开可通过 dd.requestAuthCode 完成免登。
  • 钉钉 $CORPID$ 替换结果与 CORP_AUTH.CORP_ID 不一致时拒绝登录。
  • 客户乙钉钉企业内部应用使用客户甲 SerialNo 时,即使篡改 $CORPID$ Query,其 code 仍不能通过客户甲 AppKey/Secret 上下文兑换。
  • code 重放、过期、跨企业使用会被拒绝。
  • 未绑定平台用户看到帐套、ERP账号和密码登录窗口。
  • ERP 凭证正确后自动写入绑定并进入系统,密码错误时不产生任何映射。
  • 已有唯一有效绑定的用户再次打开应用时不再输入 ERP 密码,直接进入原绑定帐套。
  • 发现多条有效绑定时不任意选择帐套,返回明确的绑定冲突。
  • 切换帐套必须重新验证目标帐套 ERP 凭证。

8.4 安全

  • Web 业务请求必须通过有效 AuthToken 建立可信 ERP 身份。
  • 篡改 LoginId/CompNo/Usr 不能改变服务端身份。
  • 篡改 URL 中的 LinkerSerialNo 只能到达对应客户隧道,不能复用原客户 state 或 AuthToken。
  • 平台身份校验成功前调用 CompanyListBindErpUser 或任意业务接口均被拒绝,不泄露帐套、用户映射或 ERP 登录上下文。
  • 不同客户、平台和 COMPNOsessionStorage 登录状态不会串用。
  • 篡改、过期或错误签名 Token 被拒绝。
  • 日志、HelpPage 和浏览器控制台不出现 Secret、AccessToken、AuthToken 或 code。
  • Legacy 原生鉴权不会成为 Web 请求绕过 AuthToken 的入口。
  • CORS 仅允许白名单来源,或使用同源部署。

8.5 功能

  • 报销、报销申请、加班、用车、公告和报表核心流程可用。
  • 附件在 WebView 中可以选择、上传、下载和预览。
  • 审核轨迹在 Native 和 Web 中均可查看。
  • 草稿、刷新、返回和深链路由行为符合平台预期。

8.6 Android/iOS 零回归

  • Android/iOS Flutter module 的入口、初始化顺序及 Add-to-App 嵌入方式与改造前一致。
  • 现有 MethodChannel 的 channel 名、method 名、参数、返回值和错误处理保持兼容。
  • 原生登录不触发企微/钉钉免登,不依赖浏览器 URL、JS-SDK、corpId 或 Web AuthToken。
  • Android/iOS 现有 ERP 配置获取、Legacy LoginId 和 API 请求方式继续可用。
  • 原生导航、返回、审核页面、草稿、附件选择、拍照、上传、下载和文件打开能力通过回归。
  • Web 的 Token、路由、存储和 SDK 初始化仅在 Web 实现中生效。
  • 公共模型扩展不会导致原生序列化、空值处理或已有调用方异常。
  • 任一 Android/iOS 基线用例失败都视为企微/钉钉集成未通过验收,不允许带回归上线。

9. 非 MVP 范围

除非业务明确提出,第一阶段不实施:

  • 企业微信 ww.register 和 JSAPI Ticket。
  • 钉钉 dd.config 和 JSAPI Ticket。
  • 企业微信/钉钉消息回调、事件订阅及 TOKEN/ENCODING_AES_KEY 加解密。
  • 通讯录同步。
  • 扫码、定位、选人等平台增强 JSAPI。

这些能力应在免登和现有 OA 全流程稳定后单独立项。