当前位置: 首页 > news >正文

API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线”

API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线” 🚀


📚 目录

  • API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线” 🚀
    • 0. TL;DR 🧭
      • 合约基线鸟瞰
    • 1. 背景与目标 🎯
    • 2. 适用范围 📐
    • 3. 资源命名与版本治理(ABP 落地) 🧩
    • 4. 查询契约:分页 / 过滤 / 排序 / 投影 🔎
      • 4.1 分页(游标优先) 🧭
      • 4.2 排序(ThenBy 链式、字段白名单) 📑
      • 4.3 投影(fields) ✂️
      • 4.4 OData(可选,限幅一致) 🧱
    • 5. 缓存契约:ETag / 条件请求 / Cache-Control ⚡
      • 5.1 强/弱 ETag 与比较语义 🧠
      • 5.2 统一 ETag 生成与 `Vary` 合并 🧩
      • 5.3 条件 GET/HEAD(含 `*`)与并发写 🔒
      • 5.4 列表端点:弱 ETag(**本页摘要**)与 304 元数据、绝对 `Link` 🔁
    • 6. 错误与一致性:ProblemDetails + Trace Context 🆘
    • 7. 安全与最小暴露 🛡️
    • 8. 可观测与 SLO 🔭
    • 9. “契约校验器”与门禁(Spectral + 代码级) 🧪
      • 9.1 OpenAPI Lint(规则节选)
      • 9.2 CI/门禁流水线(Mermaid)
    • 10. 开发者门户(Swagger/Redoc) 📘
    • 11. 兼容性测试集(Newman/Playwright/BDN) 🧪🧪
    • 12. ABP vNext 工程化落地 🧱
    • 13. 路线图 🗺️
    • 常见误区 ✅
    • 可复现指南(最小步骤) 🧰


0. TL;DR 🧭

  • 契约基线:命名/版本/鉴权 → 分页/过滤/排序/投影缓存(ETag/条件请求/Cache-Control)错误(ProblemDetails)可观测/配额/变更管理

  • 关键语义

    • If-Match 强比较(强 ETag),不匹配 → 412
    • If-None-Match 弱比较(含 *),命中 → 304
    • 304 必带可变元数据(ETag/Cache-Control/Vary/...)。
  • 三件套:OpenAPI Lint(Spectral)🧪+ 门户 OperationFilter 📘 + 兼容性测试集(Newman/BDN)🧰。

  • 亮点:列表弱 ETag 以“本页 RowVersion 摘要”生成 📈;Vary 合并追加(含 Accept & Accept-Encoding);绝对 LinkLinkGenerator 生成(适配反向代理/CDN)。

合约基线鸟瞰

数据与性能
响应变瘦 💡
分页/排序/投影
304 命中率 ↑
ETag/条件请求
设计规范 📚
OpenAPI 契约 🧾
Spectral Lint 🧪
CI 门禁 ✅
运行时拦截 🔒
开发者门户 📘
调用方集成 🤝
可观测/告警 🔭

1. 背景与目标 🎯

  • 痛点:接口风格分裂、无上限分页、过取/欠取、弱缓存/无条件请求、错误契约不统一。
  • 目标:在 ABP vNext 体系沉淀可执行的 HTTP 合约基线(可被 Lint/拦截/文档化/测试守门);以游标分页+投影降带宽,以ETag+条件请求降尾延迟与负载。

2. 适用范围 📐

  • 适用:RESTful/HTTP API(Controller、Minimal API、OData 网关)。
  • 非目标:领域建模与 GraphQL;本文专注HTTP 契约层

3. 资源命名与版本治理(ABP 落地) 🧩

  • 命名:复数资源、层级:/orders/{id}/items;动作用副资源/作业资源/exports/jobs)。
  • 版本:ABP 集成 ASP.NET API Versioning 与 ApiExplorer/Swagger 分组。
// ApiContractBaselineModule.ConfigureServices(...)
context.Services.AddAbpApiVersioning(o =>
{o.ReportApiVersions = true;o.AssumeDefaultVersionWhenUnspecified = true;
});
  • 弃用与下线:文档与响应宣布 Deprecation/Sunset;门户提供时间线。 🗓️

4. 查询契约:分页 / 过滤 / 排序 / 投影 🔎

4.1 分页(游标优先) 🧭

ClientAPI ServiceGET /resources?limit=201200 data[20], nextCursor=abc2Link: https://api.example.com/...abc\nrel="next"GET /resources?cursor=abc&limit=203200 data[20], nextCursor=def4Link: https://api.example.com/...def\nrel="next"ClientAPI Service

输出 Link绝对 URI,由 LinkGenerator 生成,适配代理/CDN。

4.2 排序(ThenBy 链式、字段白名单) 📑

public static class QueryableSortExtensions
{private static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase){ "id", "name", "price", "createdAt" };public static IQueryable<Product> ApplySort(this IQueryable<Product> q, string? sort){if (string.IsNullOrWhiteSpace(sort)) return q.OrderByDescending(x => x.Id);IOrderedQueryable<Product>? ordered = null;foreach (var token in sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)){var desc = token.StartsWith("-");var key  = desc ? token[1..] : token;if (!Allowed.Contains(key)) continue;ordered = (ordered, key, desc) switch{(null, "price", false)     => q.OrderBy(x => x.Price),(null, "price", true)      => q.OrderByDescending(x => x.Price),(null, "createdAt", false) => q.OrderBy(x => x.CreatedAt),(null, "createdAt", true)  => q.OrderByDescending(x => x.CreatedAt),(null, "name", false)      => q.OrderBy(x => x.Name),(null, "name", true)       => q.OrderByDescending(x => x.Name),(null, _, false)           => q.OrderBy(x => x.Id),(null, _, true)            => q.OrderByDescending(x => x.Id),(not null, "price", false)     => ordered!.ThenBy(x => x.Price),(not null, "price", true)      => ordered!.ThenByDescending(x => x.Price),(not null, "createdAt", false) => ordered!.ThenBy(x => x.CreatedAt),(not null, "createdAt", true)  => ordered!.ThenByDescending(x => x.CreatedAt),(not null, "name", false)      => ordered!.ThenBy(x => x.Name),(not null, "name", true)       => ordered!.ThenByDescending(x => x.Name),_ => ordered!};}return ordered ?? q.OrderByDescending(x => x.Id);}
}

4.3 投影(fields) ✂️

public sealed record QuerySpec(string? Cursor = null,int     Limit  = 50,string? Sort   = null,string? Fields = null // "id,name,price"
)
{public int Take => Math.Clamp(Limit, 1, 200);
}

生产建议:在 IQueryable 层 Select瘦 DTO(或 AutoMapper ProjectTo),避免过取与二次分配。

4.4 OData(可选,限幅一致) 🧱

services.AddControllers().AddOData(opt => opt.Select().OrderBy().Count().SetMaxTop(200));[EnableQuery(MaxTop = 200,MaxExpansionDepth = 2,AllowedQueryOptions =AllowedQueryOptions.Select |AllowedQueryOptions.Filter |AllowedQueryOptions.OrderBy |AllowedQueryOptions.Top |AllowedQueryOptions.Skip |AllowedQueryOptions.Count)]
public IQueryable<ProductDto> Get() => _svc.Query();

5. 缓存契约:ETag / 条件请求 / Cache-Control ⚡

5.1 强/弱 ETag 与比较语义 🧠

含 *
标签列表
命中
未命中
存在且强比较不匹配
不存在/匹配
读取请求头
If-None-Match 存在?
资源存在?
304 + ETag/Cache-Control/Vary
继续处理
弱比较命中?
If-Match 存在?
412 ProblemDetails
执行 GET/PUT/PATCH
  • 强 ETagBase64(RowVersion),用于 If-Match强比较)。
  • 弱 ETagW/"...",建议用于列表/聚合,If-None-Match弱比较)可命中。
  • 304:必须携带可变元数据(如 ETag/Cache-Control/Vary/...)。

5.2 统一 ETag 生成与 Vary 合并 🧩

public interface IETagProvider
{string CreateStrongEtag(byte[] rowVersion);string CreateWeakEtag(string composite); // e.g., page RowVersions
}public sealed class DefaultETagProvider : IETagProvider
{public string CreateStrongEtag(byte[] rowVersion)=> $"\"{Convert.ToBase64String(rowVersion)}\""; // 强 ETag(无 W/)public string CreateWeakEtag(string composite){using var sha1 = System.Security.Cryptography.SHA1.Create();var hash = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(composite));return $"W/\"{Convert.ToBase64String(hash)}\"";}
}static void AddVary(HttpResponse res, params string[] tokens)
{var existing = res.Headers[HeaderNames.Vary].ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(StringComparer.OrdinalIgnoreCase);foreach (var t in tokens) existing.Add(t);res.Headers[HeaderNames.Vary] = string.Join(", ", existing);
}

5.3 条件 GET/HEAD(含 *)与并发写 🔒

using Microsoft.Net.Http.Headers;[ApiController, Route("api/products")]
public class ProductsController : ControllerBase
{private readonly AppDbContext _db;private readonly IETagProvider _etag;private readonly LinkGenerator _links;public ProductsController(AppDbContext db, IETagProvider etag, LinkGenerator links){ _db = db; _etag = etag; _links = links; }[HttpGet("{id:long}")][HttpHead("{id:long}")] // HEAD 支持:与 GET 共用条件判定与头public async Task<IActionResult> GetById(long id, CancellationToken ct){var e = await _db.Products.AsNoTracking().Select(x => new { x.Id, x.Name, x.Price, x.UpdatedAt, x.RowVersion }).FirstOrDefaultAsync(x => x.Id == id, ct);if (e is null) return NotFound();var strong = new EntityTagHeaderValue(_etag.CreateStrongEtag(e.RowVersion));// If-None-Match: * 或 标签列表(弱比较)if (Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var inm)){if (inm.Count == 1 && inm[0].Trim() == "*")return NotModifiedWithMetadata(strong); // 资源存在即命中if (EntityTagHeaderValue.TryParseList(inm, out var etags) &&etags.Any(tag => EntityTagHeaderValue.Compare(tag, strong, useStrongComparison: false)))return NotModifiedWithMetadata(strong);}WithCacheMetadata(Response, strong, maxAgeSeconds: 60);return Ok(e);}[HttpPut("{id:long}")]public async Task<IActionResult> Update(long id, [FromBody] UpdateProduct dto, CancellationToken ct){var entity = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);if (entity is null) return NotFound();var current = new EntityTagHeaderValue(_etag.CreateStrongEtag(entity.RowVersion));// If-Match(强比较)保护并发if (!Request.Headers.TryGetValue(HeaderNames.IfMatch, out var ifm) ||!EntityTagHeaderValue.TryParseList(ifm, out var candidates) ||!candidates.Any(tag => EntityTagHeaderValue.Compare(tag, current, useStrongComparison: true))){return StatusCode(StatusCodes.Status412PreconditionFailed,new ProblemDetails { Title = "Precondition Failed", Status = 412, Detail = "ETag mismatch." });}// ... mutate entity & SaveChangesawait _db.SaveChangesAsync(ct);return NoContent();}// ---- helpers ----IActionResult NotModifiedWithMetadata(EntityTagHeaderValue etag){WithCacheMetadata(Response, etag, maxAgeSeconds: 60);return StatusCode(StatusCodes.Status304NotModified);}static void WithCacheMetadata(HttpResponse res, EntityTagHeaderValue etag, int maxAgeSeconds){var headers = res.GetTypedHeaders();headers.ETag = etag;headers.CacheControl = new CacheControlHeaderValue { Private = true, MaxAge = TimeSpan.FromSeconds(maxAgeSeconds) };AddVary(res, "Accept", "Accept-Encoding"); // 合并追加// Date 由服务器自动写入}
}

反向代理提示:在 Program.cs 配置 app.UseForwardedHeaders() 以尊重 X-Forwarded-*,并在 Kestrel/反代层正确设置可信代理。

5.4 列表端点:弱 ETag(本页摘要)与 304 元数据、绝对 Link 🔁

[HttpGet]
public async Task<IActionResult> List([FromQuery] QuerySpec q, CancellationToken ct)
{var baseQuery = _db.Products.AsNoTracking();var items = await baseQuery.ApplySort(q.Sort).Take(q.Take + 1).ToListAsync(ct);var hasMore    = items.Count > q.Take;var page       = hasMore ? items.Take(q.Take).ToList() : items;var nextCursor = hasMore ? items.Last().Id.ToString() : null;// 弱 ETag:仅反映**本页** RowVersion 摘要(提升命中率)var rvConcat = page.Count == 0? "empty" // 空页稳定 ETag: string.Join('|', page.Select(p => Convert.ToBase64String(p.RowVersion)));var weak = new EntityTagHeaderValue(_etag.CreateWeakEtag(rvConcat));// If-None-Match: * 或 标签列表(弱比较)if (Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var inm)){if (inm.Count == 1 && inm[0].Trim() == "*"){WithCacheMetadata(Response, weak, 30);return StatusCode(StatusCodes.Status304NotModified);}if (EntityTagHeaderValue.TryParseList(inm, out var etags) &&etags.Any(tag => EntityTagHeaderValue.Compare(tag, weak, useStrongComparison: false))){WithCacheMetadata(Response, weak, 30);return StatusCode(StatusCodes.Status304NotModified);}}WithCacheMetadata(Response, weak, 30);if (nextCursor != null){// 使用 LinkGenerator 生成绝对 URL(适配反代)var nextUrl = _links.GetUriByAction(httpContext: HttpContext,action: nameof(List),controller: "Products",values: new { cursor = nextCursor, limit = q.Limit });Response.Headers.Append(HeaderNames.Link, $"<{nextUrl}>; rel=\"next\"");}return Ok(new { data = page, nextCursor });
}

6. 错误与一致性:ProblemDetails + Trace Context 🆘

ClientAPI GatewayServiceRequest1forward with traceparent2Exception thrown500 ProblemDetails (traceId)3500 application/problem+json + traceparent4ClientAPI GatewayService
builder.Services.AddProblemDetails();app.UseForwardedHeaders(); // 反向代理/负载均衡场景建议启用
app.Use(async (ctx, next) =>
{var id = System.Diagnostics.Activity.Current?.Id;if (!string.IsNullOrEmpty(id))ctx.Response.Headers["traceparent"] = id; // 进入时写await next();
});
app.UseExceptionHandler(); // 未处理异常 → problem+json

7. 安全与最小暴露 🛡️

  • 仅绑定 DTO 字段;输出投影最小化、敏感字段脱敏;
  • 速率限制头 RateLimit/RateLimit-Policy草案)可选,不做强制门禁;
  • OData:限制 AllowedQueryOptions/MaxTop/MaxExpansionDepth,必要时限制表达式复杂度。

8. 可观测与 SLO 🔭

  • 指标:304_ratioavg_body_bytesp95_latencyetag_missing_totalprojection_hit_ratio
  • 日志:记录分页/过滤/排序/投影与条件请求命中/落空原因;
  • 告警:分页无上限、投影缺失导致响应过大、ETag 漏配、条件请求无效。

9. “契约校验器”与门禁(Spectral + 代码级) 🧪

9.1 OpenAPI Lint(规则节选)

extends: "spectral:oas"
rules:get-must-document-etag:description: "GET 响应需声明 ETag 或 304 分支"given: "$.paths[*][get].responses"then:function: schemafunctionOptions:schema:type: objectanyOf:- required: ["304"]- properties:"200":properties:headers:type: objectpatternProperties:"(?i)^etag$": {}write-must-require-if-match:description: "PUT/PATCH/DELETE 需文档化 If-Match 请求头"given: "$.paths[*][put,patch,delete]"then:field: parametersfunction: truthyproblem-details-on-4xx-5xx:description: "4xx/5xx 必须提供 application/problem+json"given: "$.paths[*][*].responses[?(@key.match(/^[45]\\d\\d$/))].content"then:field: application/problem+jsonfunction: truthy

9.2 CI/门禁流水线(Mermaid)

pass
fail
pass
fail
Push/PR
Run Spectral Lint 🧪
dotnet build/test 🧱
阻断合并 ⛔
Newman 回放 🔁
允许合并 ✅

10. 开发者门户(Swagger/Redoc) 📘

  • OperationFilter:为 GET 增加 If-None-Match 参数、304 头;为 PUT/PATCH/DELETE 增加 If-Match 参数与 412 说明;统一 ProblemDetails Schema。
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;public class ETagOperationFilter : IOperationFilter
{public void Apply(OpenApiOperation op, OperationFilterContext ctx){var method = ctx.ApiDescription.HttpMethod?.ToUpperInvariant();op.Parameters ??= new List<OpenApiParameter>();if (method == "GET"){op.Parameters.Add(new OpenApiParameter{Name = "If-None-Match", In = ParameterLocation.Header,Schema = new OpenApiSchema{ Type = "string" },Description = "ETag for conditional GET (supports weak compare and *)"});op.Responses.TryAdd("304", new OpenApiResponse{Description = "Not Modified",Headers = new Dictionary<string, OpenApiHeader>{["ETag"]          = new(){ Schema = new() { Type = "string" } },["Cache-Control"] = new(){ Schema = new() { Type = "string" } },["Vary"]          = new(){ Schema = new() { Type = "string" } }}});}if (method is "PUT" or "PATCH" or "DELETE"){op.Parameters.Add(new OpenApiParameter{Name = "If-Match", In = ParameterLocation.Header, Required = true,Schema = new OpenApiSchema{ Type = "string" },Description = "Strong ETag required (strong comparison)"});op.Responses.TryAdd("412", new OpenApiResponse{Description = "Precondition Failed (ProblemDetails)",Content = new Dictionary<string, OpenApiMediaType>{["application/problem+json"] = new() {Schema = new OpenApiSchema {Reference = new OpenApiReference{ Id="ProblemDetails", Type=ReferenceType.Schema } } }}});}}
}

若需要在 Swagger 中显式 ProblemDetails Schema,可在 AddSwaggerGenMapType<ProblemDetails>(_ => new OpenApiSchema{ ... }) 或引入共享 Schema。


11. 兼容性测试集(Newman/Playwright/BDN) 🧪🧪

  • 条件 GET:首 GET 取 ETag → If-None-Match(含 *)→ 304;断言 ETag/Cache-Control/Vary
  • 并发写:用旧强 ETag412;用新强 ETag204/200
  • 游标分页:极值/去重/不回退;Link 绝对 URI 校验。
  • 投影fields 不含字段不返回且不报错。
  • ProblemDetails:4xx/5xx 均为 application/problem+json
# 条件 GET + 304
etag=$(curl -sI http://localhost:5000/api/products/1 | awk -F': ' '/^ETag:/{print $2}' | tr -d '\r')
curl -i -H "If-None-Match: $etag" http://localhost:5000/api/products/1
curl -i -H "If-None-Match: *"    http://localhost:5000/api/products/1   # 通配命中# 并发写 412
curl -i -X PUT -H "If-Match: \"stale-etag\"" -H "Content-Type: application/json" \-d '{"name":"N","price":1}' http://localhost:5000/api/products/1

12. ABP vNext 工程化落地 🧱

Module: Abp.ApiContractBaseline
Add Filters/Middlewares
Register IETagProvider
Swagger: ETagOperationFilter
OpenAPI 文档 📘
运行时拦截 🔒
Spectral 门禁 🧪
可观测/告警 🔭
  • 配置项appsettings.json):分页上限/字段白名单/缓存策略/OData QueryOptions;
  • CI 门禁:Spectral + 单测 + 回归集;
  • 门户:定制 Swagger UI 页签展示契约基线、弃用时间线与复制片段。
  • 反向代理UseForwardedHeaders + 可信代理列表 + LinkGenerator 生成绝对 URI。

13. 路线图 🗺️

2025-09-072025-09-142025-09-212025-09-282025-10-052025-10-12Phase 1:2–3 个接口试点 Phase 2:新接口强制、旧接口告警 Phase 3:齐纳线门禁与例外登记 试点推广门禁契约基线落地路线图

常见误区 ✅

  • If-None-Match: * 已覆盖到详情与列表(GET/HEAD 命中 304)。
  • 列表弱 ETag 使用“本页摘要”,命中率更高;空页稳定弱 ETag。
  • Vary 合并追加(默认含 Accept, Accept-Encoding)。
  • 绝对 LinkLinkGenerator 生成(适配反代)。
  • OData MaxTop 全局=局部一致
  • 坚持:If-Match 强比较、If-None-Match 弱比较、304 必带元数据。

可复现指南(最小步骤) 🧰

dotnet new webapi -n ApiBaselineDemo
cd ApiBaselineDemodotnet add package Swashbuckle.AspNetCore
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
dotnet add package Microsoft.AspNetCore.OData# 将本文中的:
# - IETagProvider/DefaultETagProvider/AddVary/WithCacheMetadata
# - QuerySpec/QueryableSortExtensions
# - ProductsController(含 If-None-Match:*、本页弱 ETag、LinkGenerator 绝对 Link、HEAD 支持)
# - OperationFilter/ProblemDetails 中间件
# 复制进项目,net8.0 编译运行
dotnet run

文章转载自:

http://Qa0qhRYo.rdkgw.cn
http://vX2CcsRh.rdkgw.cn
http://XG9csoyR.rdkgw.cn
http://HFnw5Boi.rdkgw.cn
http://8jsWiasH.rdkgw.cn
http://m1jFKZYV.rdkgw.cn
http://NDZlJSJr.rdkgw.cn
http://7Pn0IXmi.rdkgw.cn
http://BaizeumU.rdkgw.cn
http://vDcW5tx0.rdkgw.cn
http://APTNU0uN.rdkgw.cn
http://pUVE1IDS.rdkgw.cn
http://Spo5ueP4.rdkgw.cn
http://cUGEqfM1.rdkgw.cn
http://Xa88ScVL.rdkgw.cn
http://f3M8u1Aw.rdkgw.cn
http://U7PKiNL9.rdkgw.cn
http://UeGj36Bf.rdkgw.cn
http://9kSbLghR.rdkgw.cn
http://V9ZYg2RX.rdkgw.cn
http://mhPJRAjx.rdkgw.cn
http://KlKsM25G.rdkgw.cn
http://4mMUf2mU.rdkgw.cn
http://FELHgjKt.rdkgw.cn
http://PzwU9TKS.rdkgw.cn
http://duVs9cfV.rdkgw.cn
http://lobMyGvw.rdkgw.cn
http://EJ3uvSwx.rdkgw.cn
http://A88wx13X.rdkgw.cn
http://KUR0VFiS.rdkgw.cn
http://www.dtcms.com/a/381268.html

相关文章:

  • V少JS基础班之第八弹:this
  • Class52 双向循环神经网络
  • STM32HAL库_cubeMX
  • Class54 编码器-解码器
  • c++多设备并发运行且互相操作 上位机软件结构
  • PCDN双跑量系统
  • Altium Designer使用精通教程 第三章(原理图绘制及编译检查)
  • Docker技术解析
  • MySQL数据库(一)—— 数据库基础与MySQL安装管理指南
  • 京东商品详情 API 全解析:合规对接与 B2C 场景实战指南
  • 高德地图从零开始:Key 申请到项目初始化全流程教程(Vue3 + AMap 2.0)
  • 从跟跑到领跑:OBOO鸥柏触摸屏的军用信息化技术自主之路
  • LLM(三)
  • u盘 修复
  • C++异常处理设计与实践:主动抛出异常的处理策略
  • 嵌入式数据结构笔记三——单向链表Ⅲ
  • Ampace厦门新能安校招/社招Verify测评演绎数字推理行测真题题库及远程助攻
  • ORM框架SQLAlchemy工具:模型类(Model Class)和实体类(Entity Class)介绍
  • CSS布局 - 定位 -- 笔记4
  • 智能过滤器系统:基于实际数据的动态Admin过滤方案
  • 发挥nano banana的最大能力
  • Nvidia GPU 明细表、架构详解
  • 达梦数据库相关操作语句
  • 拓扑排序--算法题
  • transformer 相对位置编码详解
  • 【学习K230-例程20】GT6700-TCP-Server
  • 一文理清合同金额、已确认金额、累计开票金额、最大可开票金额、未票应收金额之间的关系
  • 复杂任务拆解艺术:如何通过多次对话与提示词工程高效解决难题
  • 函数(其实写文章是为了体验和练习LateX公式)
  • 盒子模型导读