ERP接口对接实战:T+ 12.1鉴权、销售订单推送与幂等同步 简介这份压缩包面向畅捷通T12.1平台的初学者与二次开发人员聚焦开放接口的调用流程与系统集成方式帮助没有接口开发基础的读者理解如何在该平台上构建自定义业务逻辑、对接第三方应用。包内共40个文件约98KB以cs源码、csproj工程文件、exe演示程序、dll组件以及txt和json格式的配置与报文样例为主另含少量resx资源与缓存文件源码可对照查看接口封装、凭证与异常处理细节JSON样例便于掌握请求构造与响应解析命令清单则对应财务、库存等具体业务功能。资源已获得947人学习适合希望从示例调用过渡到真实项目的开发者按目录逐层研读快速建立接口开发的整体认识。1. 手工录单到凌晨两点这份演示程序就是那座桥上个月帮一个做零售的团队收拾局面门店每天两百多单全压在一个人身上在畅捷通 T 12.1 里手工敲销售订单敲错一个存货编码月末对账就要翻半天。他们不缺 ERP缺的是把外部系统里的订单塞进 T 12.1 的那条通道。这份 T 12.1 开发接口演示程序就是这条通道的最小可运行版本——它没有业务框架也不假装自己是中间件只干了四件事拿到 token、推一张销售订单、查一次存货档案、把错误原样吐回来。适合谁看做 ERP 对接的中间件开发者、被派去啃接口的内部 IT、以及想搞清楚“到底要传哪些字段”的实施顾问。下面按我拆它的顺序讲从目录一直讲到怎么把它改成能定时跑的服务。2. 从目录看骨架演示程序分了哪几层为什么这么分拿到一个接口 Demo我第一件事不是读代码是看目录。目录结构能暴露作者的取舍哪些东西被抽成了公共层哪些被刻意留在 Demo 里当示例。这个演示程序的切法很典型也很适合当模板抄。2.1 目录结构与模块职责TPlusApiDemo/ ├── config/ │ ├── appsettings.json # 生产/测试环境配置 │ └── appsettings.Development.json # 本地调试配置 ├── src/ │ ├── Auth/ │ │ └── TokenProvider.cs # token 获取、缓存、提前刷新 │ ├── Client/ │ │ ├── TPlusClient.cs # 统一请求管道签名/超时/日志 │ │ └── ApiResult.cs # 统一返回结构 │ ├── Models/ │ │ ├── SalesOrderDto.cs # 销售订单报文模型 │ │ ├── PartnerDto.cs # 客商档案模型 │ │ └── InventoryDto.cs # 存货档案模型 │ └── Demos/ │ ├── AuthDemo.cs # 拿 token 并打印 │ ├── SalesOrderDemo.cs # 推一张订单 │ └── InventoryQueryDemo.cs # 分页拉存货 └── README.md这个分法有三层含义。Auth单独成层是因为 token 是所有接口的前置条件把它和业务请求混在一起后面必然出现“每个方法里都写一遍登录”的重复代码。Client单独成层是因为签名、超时、日志、异常转换这些横切逻辑只应该存在一份改一次全局生效。Demos单独成层是因为示例代码是要被删掉的——真正上线时你把Demos整个目录删掉剩下的Auth Client Models就是一套可以直接搬进项目的 SDK 底座。我一般会把Models再拆成Request和Response两组。Demo 里合在一起是为了少写文件但真实对接时请求体是你控制的响应体是对方控制的两者的演进节奏完全不同对方加字段你不能崩你加字段对方可能报错。分开之后反序列化时给响应模型加[JsonProperty(Required Required.Default)]之类的宽松策略就不会因为对方多返回一个字段直接抛异常。这类事在演示程序里体现不出来但上线第一周就会遇到。还有一个小细节值得注意配置文件放在config/而不是项目根目录。这不是洁癖是为了让appsettings.Development.json能被.gitignore单独排除。密钥这种东西很容易在提交代码时因为路径不统一而漏掉。2.2 鉴权层appKey / appSecret 换 token 的完整写法T 12.1 的开放接口走的是一套很常见的模式用应用凭证换一个有时效的 token后续所有业务请求带上这个 token。演示程序把这段逻辑封在TokenProvider里核心是两个动作——获取和缓存。public class TokenProvider { private readonly HttpClient _http; private readonly TPlusOptions _opt; private string _token; private DateTime _expireAt DateTime.MinValue; private readonly SemaphoreSlim _lock new SemaphoreSlim(1, 1); public TokenProvider(HttpClient http, IOptionsTPlusOptions opt) { _http http; _opt opt.Value; } // 对外只暴露这一个方法调用方不关心 token 从哪来 public async Taskstring GetTokenAsync(CancellationToken ct default) { // 提前 5 分钟刷新避免请求发出瞬间刚好过期 if (!string.IsNullOrEmpty(_token) DateTime.Now _expireAt.AddMinutes(-5)) return _token; await _lock.WaitAsync(ct); try { if (!string.IsNullOrEmpty(_token) DateTime.Now _expireAt.AddMinutes(-5)) return _token; var body new { appKey _opt.AppKey, appSecret _opt.AppSecret, // 多数实现会带时间戳做防重放具体字段以环境开放的接口清单为准 timestamp DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; var resp await _http.PostAsJsonAsync(${_opt.BaseUrl}/auth/token, body, ct); var text await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) throw new TPlusApiException($取 token 失败: {(int)resp.StatusCode} {text}); var result JsonSerializer.DeserializeTokenResponse(text); _token result.AccessToken; // 服务端给的过期秒数兜底 7200 _expireAt DateTime.Now.AddSeconds(result.ExpiresIn 0 ? result.ExpiresIn : 7200); return _token; } finally { _lock.Release(); } } }这段代码有两处值得抄的细节。第一是双重检查加锁SemaphoreSlim外面先判断一次进来再判断一次避免十个并发请求同时发现 token 过期、同时去换取、把服务端的 token 顶掉。第二是提前 5 分钟刷新而不是等到_expireAt才刷新因为从判断“没过期”到请求真正到达服务端中间隔着网络和排队卡在边界上必然偶发 401这类问题排查起来很麻烦。参数上AppKey和AppSecret是应用注册时发的不对应具体账号所以一套凭证可以服务多账套账套信息一般在业务请求的 header 或参数里单独指定。ExpiresIn我在 Demo 里加了兜底值是因为见过服务端漏返回该字段的情况直接AddSeconds(0)会导致每次请求都重新登录把接口打成限流。提示token 不要写死进配置文件。Demo 里能配置是为了方便你第一次跑通真实项目里它应该只在内存和分布式缓存里存在落盘就等于把钥匙贴在门上。2.3 统一请求管道签名、超时、日志收在一处业务方法如果各自拼 URL、各自new HttpClient()代码活不过三个月。演示程序的TPlusClient是一个很薄但很完整的管道把所有请求都收口到SendAsync一个方法。public async TaskApiResultT PostAsyncT(string path, object payload, CancellationToken ct default) { var token await _tokenProvider.GetTokenAsync(ct); using var req new HttpRequestMessage(HttpMethod.Post, ${_opt.BaseUrl}{path}); req.Headers.Add(Authorization, $Bearer {token}); req.Headers.Add(X-Tenant, _opt.AccountSet); // 账套标识多账套必带 req.Content JsonContent.Create(payload); var sw Stopwatch.StartNew(); try { // 业务接口普遍慢超时给 30 秒批量推单要单独放大 using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(_opt.TimeoutSeconds)); var resp await _http.SendAsync(req, cts.Token); var text await resp.Content.ReadAsStringAsync(cts.Token); sw.Stop(); _logger.LogInformation(T {Path} 耗时 {Ms}ms 状态 {Status} 响应 {Body}, path, sw.ElapsedMilliseconds, (int)resp.StatusCode, text.Length 2000 ? text[..2000] : text); return JsonSerializer.DeserializeApiResultT(text) ?? new ApiResultT { Success false, Message 响应为空 }; } catch (TaskCanceledException) { _logger.LogError(T {Path} 超时 {Ms}ms, path, sw.ElapsedMilliseconds); // 超时不一定没落库交给上层用幂等键去确认 return new ApiResultT { Success false, Message 请求超时结果未知, Unknown true }; } }逻辑上有三个点。第一Authorization和X-Tenant都在管道里加业务方法只传 path 和报文账套切换通过配置改不用动业务代码。第二日志截断到 2000 字符因为单据报文的响应里可能带整张单据的回显全量打日志会把日志盘写满而排查问题真正需要的是报错信息通常在前面。第三超时被单独捕获并标记Unknown true这个标记非常关键——超时和“明确失败”是两回事明确失败可以安全重试超时则必须先查一遍再决定否则就是重复单据。TimeoutSeconds这个参数我建议按接口类型分开配鉴权给 10 秒单据保存给 30 秒批量查询给 60 秒。用一个全局值要么查询被卡死要么单据保存被误杀。演示程序里为了简单只有一个值改造时第一件事就是把它拆开。2.4 配置项地址、账套、密钥怎么放{ TPlus: { BaseUrl: http://192.168.1.10:8080/tplus, AccountSet: 001, AppKey: your-app-key, AppSecret: your-app-secret, TimeoutSeconds: 30, RetryCount: 2 } }BaseUrl带上账套之前的那一段AccountSet单独放是因为同一套代码经常要跑测试账套和生产账套改一个字段比改整条 URL 安全。RetryCount留在配置里而不是硬编码是因为不同客户的网络质量差别很大有的内网环境重试两次就够有的跨机房要五次。密钥通过环境变量覆盖appsettings.json里的同名配置是 .NET 配置系统的默认行为不需要额外写代码。3. 单据接口实战一张销售订单从内存对象到 T 里的单据鉴权通了之后最有价值的示例就是推一张单据。这是所有对接里最容易出错的部分字段多、必填项隐式、失败信息不友好。演示程序用销售订单做样本因为它同时包含表头、表体、自定义项三块结构学会了这一个采购订单、其他入库单基本是照葫芦画瓢。3.1 报文的三个层次表头、表体、自定义项{ externalCode: SO-20240521-0001, voucherDate: 2024-05-21, customerCode: C0001, warehouseCode: WH01, currencyCode: CNY, memo: 线上下单自动同步, details: [ { inventoryCode: 0101001, unitCode: 个, quantity: 12, price: 99.50, taxRate: 13, amount: 1194.00, memo: 门店自提 } ], customFields: { define1: TM-0001, define2: 线上渠道 } }表头是单据级信息外部单号、日期、客户、仓库、币种。表体是明细行一行一个存货。自定义项挂在customFields里对应 T 里预先配好的自定义字段。三个层次各有各的注意点。externalCode在演示程序里是必传的因为它要拿来做幂等键——真实接口不一定强制要求这个字段但你不传后面重复推送就只能靠“日期 客户 金额”去模糊匹配那是一种很不可靠的做法。warehouseCode很多人会漏结果单据能存但出库时找不到仓库。customFields里最常踩的坑是自定义项没在 T 里启用你在报文里传了define1但账套里没启用这个自定义项接口要么静默丢弃要么直接报字段不存在具体行为取决于版本和部署得实测。3.2 保存调用与返回解析public async TaskSaveResult PushSalesOrderAsync(SalesOrderDto order, CancellationToken ct) { // 1. 前置校验能本地拦住的绝不发给服务端 if (string.IsNullOrWhiteSpace(order.ExternalCode)) return SaveResult.Fail(externalCode 为空无法做幂等); if (order.Details null || order.Details.Count 0) return SaveResult.Fail(明细为空); foreach (var d in order.Details) { if (string.IsNullOrWhiteSpace(d.InventoryCode)) return SaveResult.Fail($明细缺少存货编码行号 {order.Details.IndexOf(d) 1}); if (d.Quantity 0) return SaveResult.Fail($存货 {d.InventoryCode} 数量必须大于 0); } // 2. 调用保存接口 var resp await _client.PostAsyncSalesOrderSaveResponse( /api/v1/salesorder/save, order, ct); if (!resp.Success) { // 3. 把服务端的错误码和消息原样带出去别自己翻译 return SaveResult.Fail($[{resp.Code}] {resp.Message}, resp.Unknown); } return SaveResult.Ok(new SaveResult { VoucherCode resp.Data.VoucherCode, // T 生成的单据编号 VoucherId resp.Data.VoucherId // 内码后续查询/审核要用 }); }这段代码的重点不在调用在三个前置校验。存货编码为空、数量小于等于零、明细为空这三类错误如果放给服务端返回信息通常是“保存失败”或者一串数据库层的提示你根本不知道是哪一行出的问题。本地拦住日志里能直接定位到行号能节省不少排查时间。返回里的VoucherId和VoucherCode都要存。VoucherCode是给业务看的单据号VoucherId是内码后续做审核、查询详情、反查关联单据都用它。演示程序把两个都打印出来就是这个原因。很多对接只存了单据号后来要做“根据订单反查发货单”时又得重新按单号查一次多一次请求也多一次失败可能。3.3 查询与增量拉取别用全量同步public async TaskListInventoryDto PullInventoryAsync(DateTime since, CancellationToken ct) { var all new ListInventoryDto(); var page 1; while (true) { // 增量条件尽量用服务端的“修改时间”不要用创建时间 var url $/api/v1/inventory/query?pageIndex{page}pageSize200 $modifyTimeFrom{since:yyyy-MM-dd HH:mm:ss}; var resp await _client.GetAsyncPageResultInventoryDto(url, ct); if (!resp.Success) throw new TPlusApiException(resp.Message); all.AddRange(resp.Data.Rows); if (all.Count resp.Data.Total || resp.Data.Rows.Count 0) break; page; } return all; }分页循环有两个终止条件累计数量达到总数或者本页返回空。只写一个是不够的——服务端总数统计和实际返回行数可能对不上并发修改时常见只判断Total会死循环只判断空页会在某些实现里少拉一页。增量用modifyTimeFrom而不是创建时间是因为存货档案的价格、税率、停用标记都可能被改用创建时间会漏掉所有修改。每次同步完把本次的最大修改时间存下来下次从那里开始。这个水位线要往前回退一两分钟防止边界上因为事务提交延迟漏数据——这是踩过坑之后固定下来的做法。3.4 字段映射表哪些字段必传哪些宁可不传T 侧字段业务含义本地字段建议externalCode外部单号order_no必传做幂等键voucherDate单据日期order_date必传格式按环境要求customerCode客户编码buyer_code必传先建档再推单warehouseCode仓库编码stock_code必传否则出库异常inventoryCode存货编码sku_code必传映射表维护quantity数量qty必传0注意精度taxRate税率tax_rate传百分比数值还是小数要实测amount金额total建议由服务端算只传价税合计做校验最后一行是血泪经验。金额、税额这类字段本地算一遍、服务端再算一遍一旦小数位处理规则不同四舍五入 vs 银行家舍入就会出现差一分钱的情况单据保存成功但对账永远对不平。稳妥做法是只把数量、单价、税率传过去让服务端算金额如果接口要求必须传那就在推送后回读一次用服务端的值覆盖本地的值。4. 基础档案同步与幂等把“重复推一次”变成安全操作单据接口能跑通只是及格线。真正让对接稳定的是两件事档案先对齐重复推送不出错。这两块在演示程序里各有一个小示例但真正的设计思路要自己补。4.1 客商与存货的编码对齐外部系统的商品 ID 是数字T 里的存货编码是字符串中间必须有一张映射表而且这张表不能只存在内存里。CREATE TABLE erp_code_mapping ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(20) NOT NULL COMMENT partner/inventory/warehouse, local_code VARCHAR(64) NOT NULL COMMENT 本地系统编码, erp_code VARCHAR(64) NOT NULL COMMENT T 侧编码, erp_name VARCHAR(128) DEFAULT NULL, synced_at DATETIME NOT NULL, UNIQUE KEY uk_type_local (biz_type, local_code), KEY idx_type_erp (biz_type, erp_code) );两个唯一索引的方向不同(biz_type, local_code)保证一个本地编码只映射一个 ERP 编码(biz_type, erp_code)是反查用的。少了第一个同一个商品可能被映射到两个存货上单据里的明细就会串。映射的建立时机很关键。我一般是在推单之前做一次惰性建档报文里带着本地编码过来先查映射表查不到就去 T 里按名称或规格搜一次搜到就写映射搜不到就调建档接口新建一个拿到返回的编码再写映射。这条链路里任何一步失败都要中断推单不能“先推过去再说”否则单据里存的是一个错的编码后面清理比重推麻烦十倍。4.2 幂等键与重试策略public async TaskSaveResult PushWithIdempotencyAsync(SalesOrderDto order, CancellationToken ct) { var key $so:{order.ExternalCode}; // 1. 先查本地幂等表已成功直接返回 var exist await _idempotencyRepo.GetAsync(key); if (exist?.Status success) return SaveResult.Ok(exist.VoucherCode, exist.VoucherId); // 2. 抢占式写入防止并发重复提交 if (!await _idempotencyRepo.TryLockAsync(key)) return SaveResult.Fail(同一单号正在处理中, unknown: true); try { var result await PushSalesOrderAsync(order, ct); await _idempotencyRepo.SaveAsync(key, result); return result; } finally { await _idempotencyRepo.UnlockAsync(key); } }externalCode做幂等键的前提是它在业务上真的唯一。见过有团队用“日期 递增序号”做外部单号结果系统重启后序号重置第二天就撞车了。稳妥做法是在本地生成全局唯一单号雪花 ID 或者带机器标识的序列T 侧只当字符串存。重试要区分三类结果明确失败参数错、档案不存在不重试改了再推超时和网络错误重试但要带同一个幂等键服务端 5xx 重试间隔指数退避。重试次数不用多三次足够超过三次说明对面有真问题继续重试只会把队列堵死。4.3 事务边界单张单据是原子的一批不是T 的单据保存接口对单张单据是原子的表头和表体要么一起进要么一起不进。但如果你循环推 100 张单这 100 张就不是一个事务。中间第 37 张失败前 36 张已经落库后面 63 张还没推。这个特性决定了批处理的写法每张单据独立记录状态不要用一个大的 try 包住整个循环。失败的那张进重试队列成功的更新状态整批结束后输出一份“成功 N 张、失败 M 张、失败单号列表”的结果。这样即使中途进程被杀重启后按状态续跑不会重复也不会漏。5. 常见问题排查接口调不通时先按这五条顺序过一遍接口对接的排查最怕没有顺序东看一处西看一处。下面这几条是我按出现频率排的从最高频往下走基本能覆盖八成问题。5.1 现象突然全部返回 401重启后又好了原因通常不是 token 逻辑写错而是 token 被别的进程顶掉了。同一套 appKey 在多处使用比如测试环境的服务连了生产账套或者开发本地跑了一份、服务器上也跑了一份服务端一般只保留最新签发的一个 token旧的立刻失效。两边互相顶表现就是“时好时坏”。解决办法是让 token 集中管理同一个 appKey 只允许一个进程去换 token其他进程从共享缓存Redis 之类里读。如果环境不允许就给每个环境申请独立的 appKey。另外检查一下ExpiresIn是不是被当成了毫秒——服务端给秒你按毫秒算token 会在拿到手的瞬间就“过期”然后疯狂重登日志里看起来像被限流。5.2 现象接口返回成功但在 T 里查不到这张单先确认返回的Success是真的成功还是 HTTP 200 但业务体里code非 0。不少接口的约定是 HTTP 层面永远 200业务结果放在响应体的状态字段里只看IsSuccessStatusCode会把失败当成功。如果确实是成功但查不到按这个顺序看单据是不是进了“未审核”状态很多列表默认只显示已审核日期是不是落在当前会计期间之外跨期单据会被挡操作员权限是不是只覆盖了部分仓库或部门用管理员账号查一次对比。还有一种情况是接口走的是暂存而不是保存单据在一个“草稿”表里需要在 T 界面里手动提交才会正式生成。5.3 现象报“档案不存在”但明明刚同步过档案最高频的原因是编码前导零。本地系统里商品编码是数字类型00101存成101推过去自然找不到。这类问题在 Excel 导入时代就存在接口时代换了个形式。解决办法是本地一律用字符串存编码映射表里也存字符串别在中间做类型转换。第二个原因是档案刚建档但还没生效。有的实现里建档和可用之间隔着一个审核动作或者缓存刷新周期建完立刻推单会失败。稳妥做法是建档成功后回查一次确认能查到再继续。第三个原因是账套选错了——X-Tenant传的是测试账套档案建在生产账套两边都“有”但不在一边。5.4 现象单据能存但金额和本地差几分钱这是精度问题不是接口问题。三个地方会出偏差单价的小数位被截断传了 99.567服务端按两位存成 99.57再乘数量就偏了税率传的是 13 还是 0.13 没对齐金额是本地算好传过去的和服务端的舍入规则不同。处理办法是单价按服务端的精度先做一次四舍五入再传税率字段在第一次对接时用一张小额单据实测看服务端是按百分比还是小数解析金额能不传就不传必须传的时候只作为校验值推送后回读服务端的金额差异超过一分就告警。对账口径统一了这类问题就不会反复出现。5.5 现象偶发重复单据日志里看不出谁推的重复的来源有两个超时后盲目重试以及上游重复投递。前者要靠Unknown标记加回查解决后者要靠幂等键。两个都做了还重复就要怀疑幂等键本身不稳定——比如键里带了时间戳每次重试生成的键都不一样等于没做幂等。日志这块我一般会强制打三个字段外部单号、请求唯一 ID、耗时。排查时按外部单号一搜能看到这张单被推了几次、每次的返回是什么、哪次超时了。没有这三个字段日志基本是个黑匣子只能靠猜。6. 二次开发改造把演示代码变成能定时跑的服务演示程序跑通之后下一步不是继续加功能是改结构。我的做法是保留Auth、Client、Models三层不动把Demos换成一个带状态机的同步任务用中间表记录每张单据的推送状态用定时器驱动。// 每 30 秒扫一次待推送队列失败按重试次数退避 public class OrderSyncJob { public async Task ExecuteAsync() { var batch await _repo.TakePendingAsync(limit: 50, maxRetry: 3); foreach (var item in batch) { var result await _pusher.PushWithIdempotencyAsync(item.ToDto(), CancellationToken.None); await _repo.UpdateAsync(item.Id, result); // 单张失败不影响后续状态落库后下次继续 } } }状态设计要简单pending / sending / success / failed / unknown五个就够。unknown单独留一个状态很重要它对应超时那种“不知道成没成”的情况处理方式是回查而不是重推。重试次数用一列retry_count记配合next_retry_at做退避别在内存里记进程一重启就丢了。验证方法我固定三步走先用演示程序里的单条 Demo 打通一张最小单据确认鉴权和必填字段没问题再灌 20 张带边界数据的单据小数单价、跨期日期、停用存货各来几张看失败的单据错误信息能不能定位到具体行最后跑一批 500 张压测看耗时分布和有没有重复。三步都过才敢接生产。从那以后我每次接新的 ERP 接口都强制先把鉴权和一张最小单据跑通再写任何业务代码——顺序反过来的项目没有一个不返工的。希望帮到你。本文还有配套的精品资源点击获取