PAYATHON 2026

Angular 如何避免路由拦截 ASP.NET Core API 请求?

支付老李

结论

这不是 Angular Router 要解决的问题,也不需要改用 HashLocationStrategy。

浏览器出现以下错误:

Cannot match any routes. URL Segment: 'api/jccpayment/JCCResponse'

说明服务器把 /api/jccpayment/JCCResponse 的响应错误地处理成了 Angular 的 index.html。Angular 加载后读取当前地址,把它当作前端路由解析,随后抛出路由无法匹配的错误。

正确的处理方式是:

  1. 先让 ASP.NET Core 匹配 API 路由。
  2. /api/* 请求无论成功、返回 404 或 405,还是发生异常,都不能回退到 index.html。
  3. SPA fallback 只处理前端页面的 GET/HEAD 请求。
  4. 检查 IIS 的 URL Rewrite 规则,避免它提前把 /api/* 重写到 Angular。
  5. 记录支付平台实际发送的 HTTP method、状态码和响应内容,确认大约 30 秒后,请求方式是否发生变化,或者后端是否触发了超时逻辑。

为什么错误看起来来自 Angular

Angular Router 只能处理已经加载到浏览器中的 Angular 应用。外部支付系统发给服务器的 HTTP POST 请求,不会先经过浏览器里的 Angular Router。

实际过程通常是:

支付平台 POST /api/jccpayment/JCCResponse
    ↓
ASP.NET Core 或 IIS 没有匹配到该请求
    ↓
SPA fallback 返回 index.html
    ↓
浏览器加载 Angular
    ↓
Angular 尝试解析 /api/jccpayment/JCCResponse
    ↓
Cannot match any routes

所以,Angular 报错只是结果,根因仍在服务器端。无论是在 Angular 路由表中添加 /api、配置通配路由,还是改用 #,都无法修复服务器端的路由问题。

推荐的 ASP.NET Core 管道配置

以下示例适用于使用 endpoint routing 的 ASP.NET Core 项目。先映射 controller,最后再配置 SPA fallback。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (!env.IsDevelopment())
    {
        app.UseExceptionHandler("/Error");
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseDefaultFiles();
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();

        // 如果项目还使用传统 controller/action 路由,可以保留。
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller}/{action=Index}/{id?}");

        // 必须最后注册,只用于 Angular 页面地址。
        endpoints.MapFallbackToFile("index.html");
    });
}

API controller 需要明确声明路由和 HTTP method:

[ApiController]
[Route("api/[controller]")]
public class JccPaymentController : ControllerBase
{
    [HttpPost("JCCResponse")]
    public IActionResult JCCResponse([FromForm] JccPaymentResponse model)
    {
        // 验证支付平台签名,并以订单号实现幂等处理。

        return Ok();
    }
}

如果支付平台发送 JSON,应改用 [FromBody]:

[HttpPost("JCCResponse")]
public IActionResult JCCResponse([FromBody] JccPaymentResponse model)
{
    return Ok();
}

选择 [FromForm] 还是 [FromBody],要以支付平台实际发送的 Content-Type 为准,不能只根据本地测试结果判断。

不要用 404 中间件重写 API 请求

问题中的自定义中间件会先等待后续组件执行。当响应为 404 时,它会修改请求路径,再次调用 next():

await next();

if (context.Response.StatusCode == 404)
{
    context.Request.Path = "/index.html";
    await next();
}

这种写法容易引发以下问题:

  • API 路由没有匹配时,服务器可能返回 Angular 页面;
  • POST 请求也可能进入 SPA fallback;
  • 再次调用后续管道时,部分中间件或 endpoint routing 未必会按预期重新匹配;
  • 真实的 404、405 或认证错误可能被 Angular 页面掩盖;
  • 日志中可能只剩下最终的 200 text/html,看不到 API 最初返回的错误。

建议删除这段手动回退逻辑,改用 MapFallbackToFile("index.html")。

如果旧版项目结构必须保留自定义 fallback,至少要同时限制请求方法和路径:

app.Use(async (context, next) =>
{
    await next();

    bool isPageRequest =
        HttpMethods.IsGet(context.Request.Method) ||
        HttpMethods.IsHead(context.Request.Method);

    bool isApiRequest =
        context.Request.Path.StartsWithSegments(
            "/api",
            StringComparison.OrdinalIgnoreCase);

    bool hasExtension =
        Path.HasExtension(context.Request.Path.Value);

    if (context.Response.StatusCode == StatusCodes.Status404NotFound &&
        isPageRequest &&
        !isApiRequest &&
        !hasExtension &&
        !context.Response.HasStarted)
    {
        context.Request.Path = "/index.html";
        await next();
    }
});

即使加上这些限制,自定义中间件仍不如 endpoint fallback 清楚可靠。

不要使用:

context.Request.Path.Value.Contains("/api/")

判断路径时应使用 StartsWithSegments,同时处理大小写:

context.Request.Path.StartsWithSegments(
    "/api",
    StringComparison.OrdinalIgnoreCase)

UseSpa 也必须放在 API 路由之后

旧式项目可能使用:

app.UseSpa(spa =>
{
    spa.Options.SourcePath = "ClientApp";
});

UseSpa 通常是末端中间件,因此必须放在 controller 路由之后:

app.UseRouting();

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();

    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller}/{action=Index}/{id?}");
});

app.UseSpa(spa =>
{
    spa.Options.SourcePath = "ClientApp";

    if (env.IsDevelopment())
    {
        spa.UseAngularCliServer(npmScript: "start");
    }
});

不要等到 UseSpa 之后才注册 API controller。没有匹配到路由的 API 请求,也不应由 SPA 返回 index.html。

检查 IIS 的 URL Rewrite 配置

如果 ASP.NET Core 管道已经配置正确,但 production 环境仍然返回 Angular 页面,需要检查部署后的 web.config。IIS 可能在请求到达 ASP.NET Core 之前,就把所有不带扩展名的地址重写到了 index.html。

如果项目确实需要通过 IIS Rewrite 处理前端路由,规则必须排除 /api,并且最好只处理 GET 请求:

<rewrite>
  <rules>
    <rule name="Angular Routes" stopProcessing="true">
      <match url=".*" />

      <conditions logicalGrouping="MatchAll">
        <add input="{REQUEST_METHOD}" pattern="^GET$" />
        <add input="{REQUEST_URI}" pattern="^/api(?:/|$)" negate="true" />
        <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
        <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
      </conditions>

      <action type="Rewrite" url="/" />
    </rule>
  </rules>
</rewrite>

如果 Angular fallback 已经由 ASP.NET Core 的 MapFallbackToFile 或 UseSpa 处理,通常不必再配置一套 IIS SPA rewrite。两层 fallback 同时存在,会让路由行为更难判断。

还需要确认 /api/* 最终交给 ASP.NET Core Module 处理,而不是被 IIS 静态文件处理器、代理规则或其他站点规则截获。

为什么大约 30 秒后才失败

Angular Router 没有等待 30 秒后接管 API 请求的机制。这个时间规律更可能来自以下环节:

  • 支付平台的支付会话或返回令牌过期;
  • 支付平台在超时前后使用了不同的 HTTP method;
  • 超时后的请求缺少某个表单字段、Cookie、签名或状态参数;
  • controller 因状态校验失败返回 404;
  • 接口只定义了 [HttpPost],但支付平台在超时后改用 GET,导致服务器返回 405;
  • IIS、反向代理或应用中的超时规则改变了请求处理结果;
  • controller 抛出异常后,错误处理中间件将响应转成了 SPA fallback;
  • 应用依赖临时内存、Session 或浏览器 Cookie,而这些状态在回调时已经失效。

排查时不要只看浏览器控制台。应在 IIS 和 ASP.NET Core 的入口处记录请求:

app.Use(async (context, next) =>
{
    var startedAt = DateTimeOffset.UtcNow;

    await next();

    var elapsed = DateTimeOffset.UtcNow - startedAt;

    logger.LogInformation(
        "Payment callback request: {Method} {Path}{QueryString}, " +
        "Status={StatusCode}, ContentType={ContentType}, ElapsedMs={ElapsedMs}",
        context.Request.Method,
        context.Request.Path,
        context.Request.QueryString,
        context.Response.StatusCode,
        context.Response.ContentType,
        elapsed.TotalMilliseconds);
});

对比 30 秒前后的日志时,重点检查:

  • Request.Method
  • Request.Path
  • Content-Type
  • HTTP 状态码
  • 响应 Content-Type
  • controller 是否执行
  • 支付平台提交的字段名称
  • 签名、订单号或状态参数是否存在
  • IIS 日志中的 sc-status、sc-substatus 和 sc-win32-status

如果失败响应是:

HTTP/1.1 200 OK
Content-Type: text/html

并且响应正文是 Angular 的 index.html,基本可以确认请求进入了错误的 fallback。

如果响应是 404,说明服务器没有匹配到 endpoint,或者 controller 主动返回了 NotFound()。

如果响应是 405 Method Not Allowed,需要核对支付平台实际使用的 method 是否与 [HttpPost] 一致。

浏览器返回地址和服务器通知地址应尽量分开

不少支付平台会区分两类 URL:

  1. 浏览器返回地址:支付完成后,将用户导航回网站。
  2. 服务器通知地址:支付平台的服务器向商户服务器发送支付结果。

如果支付平台支持这种配置,建议分别设置:

浏览器返回:
https://www.mysite.example/jcc-complete

服务器回调:
https://www.mysite.example/api/jccpayment/JCCResponse

/jcc-complete 是 Angular 页面,只负责显示“正在确认支付”或最终结果。/api/jccpayment/JCCResponse 是后端接口,负责验证签名并更新订单状态。

不要只依靠浏览器返回请求确认付款。用户可能关闭页面,网络也可能中断,返回请求还可能被重复提交。更新支付状态时应处理:

  • 服务端签名验证;
  • 金额和币种校验;
  • 订单号校验;
  • 幂等处理;
  • 重复通知处理;
  • 必要时主动向支付平台查询最终状态。

Angular 端不需要添加 /api 路由

以下配置都无法解决这个问题:

RouterModule.forRoot(routes, { useHash: true })
{ provide: LocationStrategy, useClass: HashLocationStrategy }
{
  path: 'api/**',
  component: SomeComponent
}

Angular 路由只负责页面导航。/api 必须在 IIS、反向代理和 ASP.NET Core 请求管道中与前端路由隔离。

可以继续使用:

{ provide: LocationStrategy, useClass: PathLocationStrategy }

前提是服务器只对真正的前端页面执行 SPA fallback。

最终检查清单

部署后,先验证支付回调:

curl -i -X POST \
  https://www.mysite.example/api/jccpayment/JCCResponse \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "orderId=test&status=cancelled"

然后验证一个不存在的 API:

curl -i https://www.mysite.example/api/not-existing

第二个请求应返回真正的 404,不能返回 Angular 的 index.html。

最后验证 Angular 页面:

curl -i https://www.mysite.example/jcc-complete

只有这个请求才应该返回:

Content-Type: text/html

处理原则是:controller 路由优先,SPA fallback 放在最后;API 永远不能回退到 Angular;IIS 不应提前重写 /api/*。满足这三点后,可以继续使用 PathLocationStrategy,支付回调也不会再因加载 index.html 而进入 Angular Router。

备注:内容仅供参考。