简介:这份压缩包面向畅捷通T+12.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, IOptions<TPlusOptions> opt) { _http = http; _opt = opt.Value; } // 对外只暴露这一个方法,调用方不关心 token 从哪来 public async Task<string> 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.Deserialize<TokenResponse>(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 Task<ApiResult<T>> PostAsync<T>(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.Deserialize<ApiResult<T>>(text) ?? new ApiResult<T> { Success = false, Message = "响应为空" }; } catch (TaskCanceledException) { _logger.LogError("T+ {Path} 超时 {Ms}ms", path, sw.ElapsedMilliseconds); // 超时不一定没落库,交给上层用幂等键去确认 return new ApiResult<T> { 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 Task<SaveResult> 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.PostAsync<SalesOrderSaveResponse>( "/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 Task<List<InventoryDto>> PullInventoryAsync(DateTime since, CancellationToken ct) { var all = new List<InventoryDto>(); var page = 1; while (true) { // 增量条件尽量用服务端的“修改时间”,不要用创建时间 var url = $"/api/v1/inventory/query?pageIndex={page}&pageSize=200" + $"&modifyTimeFrom={since:yyyy-MM-dd HH:mm:ss}"; var resp = await _client.GetAsync<PageResult<InventoryDto>>(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 Task<SaveResult> 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 接口,都强制先把鉴权和一张最小单据跑通,再写任何业务代码——顺序反过来的项目,没有一个不返工的。希望帮到你。
本文还有配套的精品资源,点击获取