目标:在保留
Client/Android、Client/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/wecom、Client/tboss_oa_module/docs/dingtalk中的本地官方文档,以及现有 Flutter、SunGate.RestApi、Manager.Business、Manager.DataAccess代码。
SerialNo。LinkerSerialNo 是现有程序内部使用的路由标识,不是客户配置项:普通 ERP: LinkerSerialNo = "0" + SerialNo.ToUpper()
MasterService: LinkerSerialNo = "1" + SerialNo.ToUpper()
SunGate.SunSystem/AutoRunProc.cs 根据 ErpSystem.VersionType 自动执行,并把结果传给 LinkerConnect.ConnectLinkerServer() 完成隧道登记。LinkerSerialNo。ErpSystem.SerialNo + VersionType,在内部生成路由标识和完整的企微/钉钉 URL;客户只点击“复制 URL”并粘贴到平台后台。SerialNo,必须由服务端根据已登记的隧道元数据解析内部路由标识;禁止要求客户自行补 0/1 前缀。MethodChannel、宿主配置和页面路由行为必须保留。SunGate.RestApi
↓
Manager.Business
↓
Manager.DataAccess
↓
SQL Server
SunGate.RestApi 不直接执行 SQL。Manager.Business 负责业务规则、平台配置校验、平台接口编排、用户身份匹配和 ERP 登录上下文处理。Manager.DataAccess 继承现有 DbObject,使用参数化 SqlParameter、SunlikeDataSet 和现有连接配置访问数据库。.cs 文件后必须同步加入旧式项目文件中的 <Compile Include="...">。企业微信和钉钉配置不写入 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 配置页面实现确认,禁止在多处使用不同自由文本。
平台用户与 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 供辅助核对。USER_CORP_AUTH。USER_CORP_AUTH、日志、Token、浏览器持久化存储或任何平台配置表。USR IS NULL 表示尚未完成 ERP 凭证绑定,不能签发业务 AuthToken。USR IS NOT NULL 且 IS_ENABLE = 'T' 才允许后续平台免密自动登录。IS_ENABLE = 'F' 表示映射停用。DEP;登录时通过 COMPNO + USR 查询 ERP 用户当前部门,避免调部门后残留旧映射。LoginId、一次性授权码、平台 AccessToken 或 AuthToken。(AUTH_TYPE, CORP_ID, PLATFORM_USER_ID) 在同一 ErpServer 内只允许一个有效绑定;切换帐套必须重新验证目标帐套的 ERP 账号密码,并在同一业务事务中停用旧绑定、启用新绑定。snsapi_base 静默授权获取 code,服务端调用 auth/getuserinfo 换取 UserId。免登不依赖 ww.register。dd.requestAuthCode 获取免登码,服务端调用 topapi/v2/user/getuserinfo 换取 userid。该 API 无需先执行 dd.config。Web 端传入的 LoginId、CompNo、Dep、Usr 不能作为可信身份。
COMPNO 不参与首页路由和平台启动配置选择。首次绑定时它只是用户选择的 ERP 登录目标,必须经过 ERP 凭证校验;后续自动登录时只能来自服务端映射。ApiCommon.getHeadValue() 中加入隐式验签逻辑;使用独立 DelegatingHandler 或等价统一处理器,避免重复验签和隐藏副作用。以下事项未确认前,不进入正式免登开发:
CORP_AUTH 保存企业应用配置,USER_CORP_AUTH 保存平台用户与 ERP 用户映射:
平台免登:AUTH_TYPE + CorpId + PlatformUserId
↓ 查询所有帐套
USER_CORP_AUTH
├─ 唯一有效绑定 → CompNo + Usr → 自动登录
└─ 无有效绑定 → 返回一次性 BindingTicket
↓
选择帐套 + ERP账号 + ERP密码
↓
ERP凭证校验成功后写入绑定
实施规则:
COMPNO。完成平台身份验证后,以 (AUTH_TYPE, CORP_ID, PLATFORM_USER_ID, IS_ENABLE='T') 跨帐套查询当前 ErpServer 内的有效映射。COMPNO + USR,动态取得当前部门和用户状态,直接建立 Web LoginId 并签发 AuthToken。BindingTicket,只允许读取帐套列表和提交首次绑定。BindingTicket 获取可选帐套列表,用户选择帐套并输入 ERP 用户账号、密码。Manager.Business.Users.CheckUsrDataLogin() 验证帐套、用户、密码和有效期,再使用 Manager.Business.LoginInfo.Logon() 创建登录上下文。Manager.Business.UserCorpAuth → Manager.DataAccess.DbUserCorpAuth 写入 USER_CORP_AUTH,随后签发业务 AuthToken。COMPNO 排序自动选择;返回“绑定数据冲突”,由切换帐套流程或 ERP 管理页面修复。PLATFORM_USER_NAME 在 ERP 页面展示并辅助核对,不能据此自动绑定。userid 作为当前企业内的主要映射 ID,UNION_ID 用于辅助核对和后续跨应用场景。CorpId/UserId 格式时,必须按最终确认的兼容规则规范化后再查询。禁止默认假设平台 UserId 与 ERP Usr 相同。
切换帐套视为高风险重新绑定操作:必须重新输入目标帐套 ERP 账号密码,不能仅凭当前 AuthToken 修改 COMPNO/USR。
现有 OA 接口通过 ApiCommon.GetCompInfoByLoginId() 查询 LOGIN_INFO_MOBILE 中的登录上下文。必须确定 Web 免登成功后:
Manager.Business.LoginInfo.Logon() 创建 Web LoginId;或CompNo/Dep/Usr,逐步减少对 LoginId 的依赖。MVP 推荐复用现有 LoginInfo.Logon(),生成独立 Web LoginId,以最小改动兼容现有控制器。退出或 Token 失效后的 LoginId 清理策略需与现有登录记录生命周期一致。
企业内部应用首页不携带 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 类型前缀。SerialNo 和 VersionType,自动生成内部 LinkerSerialNo 及上述完整 URL。SerialNo 和完整 URL,并提供复制;不提供 LinkerSerialNo 输入框,也不要求客户手工拼接 URL。SerialNo,而是保证错误企业/错误应用即使使用该 URL,也无法通过平台身份校验、获取 BindingTicket、读取帐套或建立用户映射。corpid=$CORPID$,钉钉容器会替换为当前组织 CorpId;服务端仍须与 CORP_AUTH.CORP_ID 比较,不能直接信任该参数。SerialNo 的机制;其 OAuth code 只用于取得企业微信 UserId。requestAuthCode 返回的 code 同样不包含 ERP SerialNo。SerialNo 不重复保存到 CORP_AUTH。数据库已经属于确定的客户 ErpServer,重复保存会产生两处数据不一致风险。LinkerSerialNo 只负责选取客户隧道,不是认证凭据。COMPNO 的来源:
Manager.Business.SunSystem.GetCompData() 获取当前 ErpServer 的帐套列表,由用户在登录窗口选择。USER_CORP_AUTH.COMPNO 取得。COMPNO 并将其作为可信身份。由于 URL 不再提供 COMPNO,平台身份验证前必须能唯一确定当前客户的企微/钉钉应用配置。MVP 约束为:
AUTH_TYPE 只允许一个有效的企业内部应用配置。CORP_AUTH 因现有联合主键存在多条同平台记录,这些记录的 CORP_ID/AGENT_ID/APP_KEY/APP_SECRET 必须完全一致,由 ERP 配置页面同步维护。AUTH_TYPE 读取并校验有效配置;不存在返回“平台未配置”,出现不同凭证返回“平台配置冲突”,不能任取第一条。服务端必须验证:
LinkerSerialNo 对应当前在线 SSH 隧道。LinkerSerialNo 一致。AUTH_TYPE 的有效应用配置唯一且无冲突。CORP_AUTH 凭证在服务端兑换成功;客户端不得提交或覆盖 CORP_ID/AGENT_ID/APP_KEY/APP_SECRET。CORP_AUTH.CORP_ID 精确一致;若换号接口不返回 CorpId,则以“使用该企业内部应用凭证成功兑换该 code”为权威归属证明。COMPNO 存在于当前 ErpServer,并且 ERP 凭证验证成功。COMPNO 必须来自 USER_CORP_AUTH,不能来自浏览器参数。SerialNo 可能被看到或猜到,因此不得把它当作 Secret。系统采用下面的双重归属校验:
URL 中的 LinkerSerialNo
↓ 只用于路由
Linker 网关根据在线隧道登记关系转发
↓ 注入不可由浏览器伪造的可信路由标识
目标 ErpServer 校验:可信路由标识 == 本机实际 LinkerSerialNo
↓
只读取目标 ErpServer 本机数据库中的 CORP_AUTH
↓
使用本机 CORP_AUTH 的企业内部应用凭证兑换平台 code
↓
确认 code 属于该 CorpId + AgentId/AppKey 对应的企业内部应用
↓
成功后才允许查询 USER_CORP_AUTH 或签发 BindingTicket
必须遵循以下规则:
BootstrapConfig 只从路由到达的 ErpServer 本机 CORP_AUTH 返回必要公开标识,禁止从 URL、Query、Header 或登录请求接受平台凭证。CORP_ID + APP_SECRET 获取 AccessToken,并在本机配置的企业/应用上下文中兑换 OAuth code。其他企业成员不能凭自己的企业应用 code 换得本客户 UserId。$CORPID$ 的替换值与本机 CORP_AUTH.CORP_ID 比较,不一致时前端停止并由服务端再次拒绝;最终仍以本机 APP_KEY + APP_SECRET 对应的 AccessToken 能否成功兑换 requestAuthCode 为权威判断,不能仅信任 $CORPID$ Query。USER_CORP_AUTH、签发 BindingTicket/AuthToken、创建 LoginId。CompanyList 和 BindErpUser 只接受已完成上述双重校验后签发的一次性 BindingTicket。CORP_AUTH;访问客户甲路径时只会使用客户甲的应用凭证,客户乙的 code 将因企业/应用不匹配而失败。IS_ENABLE='F',由 Manager.Business 使用所填凭证调用平台接口验证凭证有效性及应用归属;验证成功后才允许启用。条件允许时增加一次真实端内免登验证,确认工作台运行时 CorpId、应用标识和本机配置一致。CORP_AUTH 的 Secret 即使验证成功也不得返回浏览器。公开的 CorpId、AgentId、AppKey 和 SerialNo 不能代替 Secret,也不能单独构成登录凭据。BootstrapConfig、平台登录和绑定接口按 LinkerSerialNo + AUTH_TYPE + IP 限流,错误响应统一使用“客户或平台配置不匹配”,避免通过详细错误枚举客户配置。典型冒用场景的结果:
客户乙在自己的企微/钉钉后台使用客户甲生成的完整 URL
↓
请求被正确路由到客户甲 ErpServer
↓
服务端只读取客户甲 CORP_AUTH
↓
客户乙的 CorpId/code 与客户甲企业内部应用上下文不匹配
↓
拒绝登录;不返回帐套;不签发 BindingTicket;不写 USER_CORP_AUTH
此方案不依赖 SerialNo 保密。即使 SerialNo 泄露,攻击者最多发起到目标客户公网入口的无效请求,不能形成目标客户的平台身份或 ERP 身份。仍需通过限流、审计和统一错误响应控制扫描与拒绝服务风险。
企业微信:
docs/wecom/server/网页授权登录-构造网页授权链接.md 明确 redirect_uri 由应用指定,授权后回跳该地址并附加 code 和 state。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。corpid=$CORPID$,钉钉容器会替换为当前组织 CorpId。docs/dingtalk/server/身份验证-网页应用(H5微应用)免登.md 明确应用首页、重定向 URL/端内免登地址与运行 JSAPI 的页面域名必须一致,并且免登必须在钉钉端内执行。SerialNo 的占位符;SerialNo 仍由每个客户固定首页 URL 的路径提供。钉钉 dd.requestAuthCode 需要 corpId 和 clientId;企业微信构造 OAuth URL 需要 CorpId、AgentId 和回调地址。因此增加只返回公开字段的启动配置接口:
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_SECRETTOKENENCODING_AES_KEY启动配置按当前 ErpServer 的唯一有效 AUTH_TYPE 配置读取,不依赖 COMPNO。如果同一平台存在多组不同应用凭证,返回配置冲突并停止免登。
工作台打开固定客户 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,避免刷新重复兑换。工作台打开固定客户 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 前缀。
平台身份已验证但不存在映射时:
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。ERP 登录要求:
Manager.Business.SunSystem.GetCompData(),只向未绑定页面返回 COMPNO + NAME 等必要公开字段,不能返回数据库服务器、数据库名、账号或连接密码。Manager.Business.Users.CheckUsrDataLogin(),不得在 Controller 或 Flutter 中复制密码校验规则。DEP 和姓名,并复用 Manager.Business.LoginInfo.Logon()。IS_ENABLE='T' 映射。Flutter Web
Authorization: Bearer <AuthToken>
↓
WebAuthHandler(统一验签、有效期、LinkerSerialNo、平台、CompNo)
↓
建立可信 LoginId / CompNo / Dep / Usr 上下文
↓
现有 OAController / FileController
AuthToken 至少包含:
LinkerSerialNo 或其不可逆摘要CompNoLoginIdUsr采用标准、可审计的 HMAC 签名方案,不自行组合不明确的“HMAC + AES”。签名密钥不得硬编码或提交 Git,应使用现有安全配置机制、外部安全配置或操作系统保护的密钥,并支持轮换。服务端验证 Token 时还必须确认 Token 客户路由与本机当前 LinkerSerialNo 一致,防止 Token 经另一客户路径重放。
不能在未升级原生认证链之前直接全局拒绝所有无 Bearer Token 的请求,否则会破坏现有 Android/iOS。
实施边界:
LoginId;该工作不纳入本次企微/钉钉集成,也不得与本次 Flutter Web 代码合并实施。正式对公网发布 H5 前,必须完成安全评审,确认 Legacy 模式不会成为绕过 Web AuthToken 的入口。不能仅依赖 Origin、User-Agent 或自定义客户端 Header 区分可信原生请求。
新增:
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 的有效绑定
}
要求:
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 *。DbCorpAuth.cs、DbUserCorpAuth.cs 加入 Manager.DataAccess.csproj。CORP_AUTH 仍由 ERP 配置页面维护;USER_CORP_AUTH 除 ERP 管理页面外,允许在“平台身份已验证 + ERP 凭证验证成功”的首次绑定流程中由服务端写入。所有读写继续遵循 RestApi → Business → DataAccess 调用链。
新增或按现有命名规范拆分:
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。APP_SECRET。IS_ENABLE 改为 T。SunSystem.GetCompData() 返回精简帐套列表。Users.CheckUsrDataLogin() 校验首次绑定或切换帐套的 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 为准,并预留安全刷新窗口。新增:
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"
}
登录接口不得接收 corpId、agentId、appKey 或 appSecret 并据此选择平台配置。平台配置只能来自实际路由到达的 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"
}
要求:
Manager.Business。USER_CORP_AUTH。sn Header 选择客户;客户路由由 URL 路径和 Linker 网关确定。LinkerSerialNo。LinkerTransMitUrl + sn Header,本次路径路由不得改变现有 Native 请求。WebAuthHandler 在路由执行前统一验证 Bearer Token,并将可信身份写入请求上下文。ApiCommon.getHeadValue() 的通用语义。SunGate.RestApi.csproj,并在 OwinStartup/AttributeRoutingConfig 中按现有方式注册处理器。企业微信:
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 调用统一要求:
web/index.html、图标和 manifest。requestAuthCode 所需的固定版本 JS-SDK。wecom-jssdk。wecom-jssdk-2.4.0.js 并实现 ww.register。Flutter Web 适配必须遵守以下边界:
入口隔离
web/index.html 和 Web 认证启动流程。dd.requestAuthCode 或读取浏览器 URL 参数。编译隔离
dart:io、MethodChannel 和原生插件只能出现在 Native 条件实现中。dart:html、dart:js_interop、dart:io 或具体宿主 SDK。kIsWeb 判断。协议兼容
LoginId、用户信息及路由参数保持兼容。platform、code、corpId 或 Web AuthToken。业务兼容
服务调用兼容
变更控制
目标结构:
公共业务页面与领域模型
↓
平台能力接口
┌────┴────┐
Native 实现 Web 实现
↓ ↓
现有宿主通道 企微/钉钉 H5 能力
新增:
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:io、dart:html。
平台能力至少覆盖:
requestAuthCode。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 使用的 sn、Connection: close、Accept-Encoding: identity。Native ApiClient 继续保留现有 Header 和 HostAppChannel 行为。
当前以下文件直接使用 dart:io、本地路径或 MultipartFile.fromFile,必须纳入改造:
lib/core/navigation/host_app_channel.dartlib/shared/models/attachment_file.dartlib/shared/widgets/attachment_picker.dartlib/features/expense/expense_create_page.dartlib/features/expense/expense_detail_page.dartlib/features/expense_apply/expense_apply_detail_page.dartlib/features/expense/expense_api.dartlib/features/expense_apply/expense_apply_api.dartAttachmentFile 不能再假定始终存在本地文件路径,应能保存:
Uint8List 或可延迟读取字节的跨平台对象该调整必须保持现有 Native 构造方式和路径访问能力兼容。推荐新增跨平台数据来源抽象或兼容构造函数,禁止直接删除原生路径字段或强制 Android/iOS 先把所有文件完整读入内存。
Web 上传使用:
MultipartFile.fromBytes(bytes, filename: fileName)
必须验证:
path_provider、open_filex。GET api/OA/GetAuditTrailAuditTrailDialogshared_preferences,需在 pubspec.yaml 增加依赖并验证其 Web 实现。index.html。采用统一公共域名下的客户路径方案:
https://www.linkerplus.com/oa/{LinkerSerialNo}/{platform}/
→ Flutter Web
https://www.linkerplus.com/oa/{LinkerSerialNo}/api/ext_erp/*
→ Linker 网关
→ SerialNo 对应的 SSH 反向隧道
→ 客户内网 SunGate.RestApi
Linker 网关必须实现:
LinkerSerialNo。SerialNo → SSH IP/动态端口 登记关系选择在线隧道。/oa/{LinkerSerialNo} 公网前缀,保持现有 SunGate.RestApi 路由不变。503 ERP_OFFLINE,不泄露 SSH IP、端口或客户内部信息。SerialNo 仅作为客户路由键,不能作为用户认证或接口授权依据。ERP 配置页面必须:
ErpSystem.SerialNo,按现有 ERP 类型前缀规则生成完整 LinkerSerialNo。SerialNo;LinkerSerialNo 仅供内部诊断,不作为输入项或要求客户理解的配置项。{LinkerSerialNo} 占位符,也不要求客户补 0/1 前缀。所有客户路径共用 www.linkerplus.com Origin,因此登录状态必须按客户隔离:
sessionStorage,Key 至少包含 LinkerSerialNo + AUTH_TYPE;实际帐套从 Token 可信声明取得。localStorage Key 保存登录状态。企业微信:
www.linkerplus.com,通过不同 /oa/{LinkerSerialNo}/... 路径区分。钉钉:
corpid=$CORPID$。requestAuthCode 的页面保持相同域名。两个平台均使用 HTTPS,禁止在生产环境混合内容。当前 ErpServer app.config 中 Linker 相关 URL 仍为 HTTP;H5 公网入口必须使用 HTTPS,Linker 的连接分配和端口上报接口也应升级为 HTTPS 并完成兼容验证。
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 不作为正常访问链路。
APP_SECRET 按 ERP 现有安全规范加密保存和解密使用。TOKEN、ENCODING_AES_KEY 只在后续回调能力中使用。ApiClient 的 LogInterceptor(requestBody: true) 必须对 BindErpUser/SwitchErpAccount 禁用或脱敏,任何构建模式均不得记录密码字段。AUTH_TYPE 的实际保存值。USER_CORP_AUTH,并核实实际字段和主键。/oa/{LinkerSerialNo}/{platform}/ 提供客户和平台上下文,COMPNO 不进入 URL。SerialNo;内部 LinkerSerialNo 由当前 ErpServer 根据 VersionType 自动派生,不新增到 CORP_AUTH,也不允许客户手工输入或补前缀。www.linkerplus.com 部署 Flutter Web,并为 /oa/ 配置 SPA 静态资源和路由回退。/oa/{LinkerSerialNo}/api/ext_erp/* 路由解析、隧道选择和前缀移除。LinkerSerialNo。503 ERP_OFFLINE、超时、上传大小、路径白名单和审计。/api/ext_erp/ + sn Header 路由保持不变。Manager.DataAccess/DbCorpAuth.cs,参数化读取 CORP_AUTH。Manager.DataAccess/DbUserCorpAuth.cs,实现跨帐套有效绑定查询及完整主键写入。Manager.Business/CorpAuth.cs,校验平台配置与启用状态。Manager.Business/UserCorpAuth.cs,校验用户绑定、启用状态和 ERP 用户状态。.csproj。BootstrapConfig,确保不泄露敏感字段。SerialNo 和 VersionType,自动生成并只读展示可直接复制的企微/钉钉完整 URL;客户不填写 LinkerSerialNo,数据库也不重复保存 SerialNo。AUTH_TYPE 的有效平台应用凭证唯一一致。UX_USER_CORP_AUTH_ACTIVE_PLATFORM 唯一筛选索引。IS_ENABLE='F' 保存,由 Business 验证 CorpId、AgentId/AppKey、Secret 和应用归属,成功后才允许启用。AUTH_TYPE + CORP_ID + AGENT_ID/APP_KEY 隔离的平台 AccessToken 缓存。USER_CORP_AUTH,实现唯一绑定、未绑定和绑定冲突三种结果。COMPNO + USR 动态解析当前部门和用户资料。WebAuthHandler,不改变 getHeadValue() 的通用行为。ExternalAuthController 及统一响应模型。SunGate.RestApi.csproj 和处理器注册。LinkerSerialNo 与本机当前 SerialNo,并将其绑定到 state 和 AuthToken。SunSystem.GetCompData() 提供精简帐套列表。Users.CheckUsrDataLogin() 校验 ERP 帐号密码,成功后写入 USER_CORP_AUTH。CompanyList、USER_CORP_AUTH、BindingTicket、LoginId 和 AuthToken。web/ 构建入口。dd.requestAuthCode。$CORPID$ 替换结果,与 Bootstrap 返回的 CorpId 交叉验证。GetAuditTrail 和 AuditTrailDialog。flutter analyze。flutter build web --release。Manager.DataAccess、Manager.Business、SunGate.RestApi 及宿主服务。www.linkerplus.com HTTPS 和 /oa/{LinkerSerialNo}/ 同源路径代理。corpid=$CORPID$ 和权限。flutter analyze 无错误。flutter build web --release 成功。SerialNo,并使用本机自动派生的内部 LinkerSerialNo 生成企微/钉钉完整 URL;客户无需填写前缀或修改 URL。SerialNo 不重复保存到 CORP_AUTH。ERP_OFFLINE。CORP_AUTH 凭证兑换。CORP_AUTH、USER_CORP_AUTH 只能通过 Manager.Business → Manager.DataAccess 访问。(AUTH_TYPE, CORP_ID, PLATFORM_USER_ID, IS_ENABLE='T') 跨帐套查询,绑定写入仍使用完整联合主键。USR 为空或无映射时进入首次 ERP 登录绑定流程,不直接签发业务 AuthToken。PLATFORM_USER_NAME 变化不会改变绑定身份。LinkerSerialNo/platform 路径,state 跨客户、过期或重放均被拒绝。dd.requestAuthCode 完成免登。$CORPID$ 替换结果与 CORP_AUTH.CORP_ID 不一致时拒绝登录。$CORPID$ Query,其 code 仍不能通过客户甲 AppKey/Secret 上下文兑换。LoginId/CompNo/Usr 不能改变服务端身份。LinkerSerialNo 只能到达对应客户隧道,不能复用原客户 state 或 AuthToken。CompanyList、BindErpUser 或任意业务接口均被拒绝,不泄露帐套、用户映射或 ERP 登录上下文。COMPNO 的 sessionStorage 登录状态不会串用。corpId 或 Web AuthToken。除非业务明确提出,第一阶段不实施:
ww.register 和 JSAPI Ticket。dd.config 和 JSAPI Ticket。TOKEN/ENCODING_AES_KEY 加解密。这些能力应在免登和现有 OA 全流程稳定后单独立项。