From 8a4b1c7ae857d5f0df23602d7c838050e4ff648d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=AF=9A?= <1009578407@qq.com> Date: Thu, 5 Nov 2026 23:19:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E7=AD=BE=E5=88=B0?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../budwk/app/zhgh/map/TMapController.java | 40 ++ .../zhgh/map/TencentLocationAsyncSupport.java | 76 ++++ .../app/zhgh/map/TencentLocationService.java | 357 ++++++++++++++++++ .../activity/trainSignUp/scan/index.html | 27 +- 4 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/budwk/app/zhgh/map/TencentLocationAsyncSupport.java create mode 100644 src/main/java/com/budwk/app/zhgh/map/TencentLocationService.java diff --git a/src/main/java/com/budwk/app/zhgh/map/TMapController.java b/src/main/java/com/budwk/app/zhgh/map/TMapController.java index 5328912..103568f 100644 --- a/src/main/java/com/budwk/app/zhgh/map/TMapController.java +++ b/src/main/java/com/budwk/app/zhgh/map/TMapController.java @@ -2,6 +2,7 @@ package com.budwk.app.zhgh.map; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; @@ -15,9 +16,13 @@ import io.swagger.annotations.ApiOperation; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.GET; import org.nutz.mvc.annotation.Ok; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import java.io.IOException; /** * @ClassName TMapController @@ -35,6 +40,9 @@ public class TMapController { @Inject private SysConfigService configService; + @Inject + private TencentLocationService tencentLocationService; + @At @SaCheckLogin @ApiOperation("关键词输入提示") @@ -49,4 +57,36 @@ public class TMapController { String response = HttpUtil.get(url); return Result.success(JSONUtil.parse(response)); } + + /** + * 代理腾讯定位组件的坐标转换请求,异步排队领取共享调用名额。 + * + * @param request 腾讯组件的单点坐标及 JSONP 回调请求 + * @param response 腾讯协议的异步响应 + * @throws IOException 即时错误响应写入失败时抛出 + */ + @At("/ws/coord/v1/translate") + @GET + @Ok("void") + @SaCheckLogin + @SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR) + public void translate(HttpServletRequest request, HttpServletResponse response) throws IOException { + tencentLocationService.proxy(true, KEY, request, response); + } + + /** + * 代理腾讯定位组件的逆地址解析请求,保留腾讯返回状态及请求编号。 + * + * @param request 腾讯组件的 GCJ-02 坐标及 JSONP 回调请求 + * @param response 腾讯协议的异步响应 + * @throws IOException 即时错误响应写入失败时抛出 + */ + @At("/ws/geocoder/v1/") + @GET + @Ok("void") + @SaCheckLogin + @SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR) + public void geocoder(HttpServletRequest request, HttpServletResponse response) throws IOException { + tencentLocationService.proxy(false, KEY, request, response); + } } diff --git a/src/main/java/com/budwk/app/zhgh/map/TencentLocationAsyncSupport.java b/src/main/java/com/budwk/app/zhgh/map/TencentLocationAsyncSupport.java new file mode 100644 index 0000000..ea7885b --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/map/TencentLocationAsyncSupport.java @@ -0,0 +1,76 @@ +package com.budwk.app.zhgh.map; + +import org.nutz.boot.starter.WebFilterFace; +import org.nutz.ioc.loader.annotation.IocBean; + +import javax.servlet.DispatcherType; +import javax.servlet.Filter; +import javax.servlet.FilterRegistration; +import javax.servlet.ServletContext; +import java.util.EnumSet; +import java.util.Map; + +/** + * 为定位代理经过的过滤器开启 Servlet 异步支持,保留原有过滤器顺序及鉴权流程。 + * NutzBoot 的 WebFilterFace 没有异步配置项,需在过滤器注册完成后补充该能力。 + */ +@IocBean +public class TencentLocationAsyncSupport implements WebFilterFace { + /** @return 定位异步支持的注册名称 */ + @Override + public String getName() { + return "tencentLocationAsyncSupport"; + } + + /** @return 定位代理所在的业务路径 */ + @Override + public String getPathSpec() { + return "/platform/TMap/ws/*"; + } + + /** @return 仅处理原始请求,不重新分派鉴权链 */ + @Override + public EnumSet getDispatches() { + return EnumSet.of(DispatcherType.REQUEST); + } + + /** @return null,仅补充注册配置,不增加或替换业务过滤器 */ + @Override + public Filter getFilter() { + return null; + } + + /** @return 无额外过滤器参数 */ + @Override + public Map getInitParameters() { + return Map.of(); + } + + /** @return 最后执行,确保 Nutz 和登录过滤器均已完成注册 */ + @Override + public int getOrder() { + return Integer.MAX_VALUE; + } + + /** + * 在容器启动阶段补齐定位代理过滤器链的异步标记;其他入口仍按原有同步流程执行。 + * + * @param context 当前应用 Servlet 上下文 + */ + @Override + public void setServletContext(ServletContext context) { + String[] paths = {"/platform/TMap/ws/coord/v1/translate", "/platform/TMap/ws/geocoder/v1/"}; + for (FilterRegistration registration : context.getFilterRegistrations().values()) { + boolean matches = registration.getServletNameMappings().contains("*"); + for (String pattern : registration.getUrlPatternMappings()) { + for (String path : paths) { + matches |= pattern.equals(path) || (pattern.endsWith("/*") + && path.startsWith(pattern.substring(0, pattern.length() - 1))); + } + } + if (matches && registration instanceof FilterRegistration.Dynamic) { + ((FilterRegistration.Dynamic) registration).setAsyncSupported(true); + } + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/map/TencentLocationService.java b/src/main/java/com/budwk/app/zhgh/map/TencentLocationService.java new file mode 100644 index 0000000..84432dd --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/map/TencentLocationService.java @@ -0,0 +1,357 @@ +package com.budwk.app.zhgh.map; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; + +import javax.servlet.AsyncContext; +import javax.servlet.AsyncEvent; +import javax.servlet.AsyncListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * 腾讯定位组件的业务代理:按 Key 和接口共享滑动窗口名额,异步等待并调用腾讯服务。 + * 定位期间不访问业务数据库,只有页面取得有效位置后才进入现有签到事务。 + */ +@Slf4j +@IocBean(create = "init", depose = "close") +public class TencentLocationService { + private static final int MAX_PENDING = 2000; + private static final long REQUEST_TIMEOUT_MS = 20000; + // Redis 时间统一多实例计时;完成请求不归还名额,避免将每秒限额误当作在途并发数。 + // Redis 3.0 禁止 TIME 后在同一 Lua 内写入,因此先读取服务端时间,再原子维护窗口。 + private static final String ACQUIRE = "local now=tonumber(ARGV[2]); " + + "local last=redis.call('ZREVRANGE',KEYS[1],0,0,'WITHSCORES'); " + + "if #last>0 then now=math.max(now,tonumber(last[2])); end; " + + "redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',now-1000); " + + "if redis.call('ZCARD',KEYS[1])<80 then " + + "redis.call('ZADD',KEYS[1],now,ARGV[1]); redis.call('PEXPIRE',KEYS[1],2000); return 0; end; " + + "local first=redis.call('ZRANGE',KEYS[1],0,0,'WITHSCORES'); " + + "return math.max(1,1000-now+tonumber(first[2]));"; + + @Inject + private RedisService redisService; + + private final Map lanes = new ConcurrentHashMap<>(); + private final Set pending = ConcurrentHashMap.newKeySet(); + private final Semaphore capacity = new Semaphore(MAX_PENDING); + private final Object lifecycle = new Object(); + private volatile boolean closed; + private ScheduledExecutorService dispatcher; + private ExecutorService httpExecutor; + private HttpClient client; + + /** 初始化共享 HTTP 客户端和队列调度器,不为每个等待请求创建线程。 */ + public void init() { + httpExecutor = Executors.newFixedThreadPool(8, runnable -> { + Thread thread = new Thread(runnable, "tencent-location-http"); + thread.setDaemon(true); + return thread; + }); + client = HttpClient.newBuilder().executor(httpExecutor) + .connectTimeout(Duration.ofSeconds(3)).build(); + dispatcher = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "tencent-location-dispatch"); + thread.setDaemon(true); + return thread; + }); + dispatcher.scheduleWithFixedDelay(this::drain, 0, 10, TimeUnit.MILLISECONDS); + } + + /** + * 接收腾讯组件的 JSONP 请求,校验后异步排队;队满或超时返回组件可识别的失败结果。 + * + * @param translate true 为坐标转换,false 为逆地址解析,仅允许这两个固定接口 + * @param key 服务端配置的腾讯 Key,不接受客户端替换 + * @param request 已通过登录及签到权限检查的请求 + * @param response 输出腾讯原始 JSON/JSONP 协议,不包装业务 Result + * @throws IOException 无法写入即时错误响应时抛出 + */ + public void proxy(boolean translate, String key, HttpServletRequest request, + HttpServletResponse response) throws IOException { + String callback = request.getParameter("callback"); + if (callback != null && !callback.matches("[A-Za-z_$][A-Za-z0-9_$]{0,127}")) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + write(response, null, error(400, "定位回调参数不合法")); + return; + } + String coordinate = request.getParameter(translate ? "locations" : "location"); + String type = request.getParameter("type"); + if (!validCoordinate(coordinate) || (translate && (type == null || !type.matches("[1-6]")))) { + write(response, callback, error(400, "定位坐标或坐标类型不合法")); + return; + } + String endpoint = translate ? "/ws/coord/v1/translate" : "/ws/geocoder/v1/"; + // 只转发 SDK 实际使用的单点坐标;固定主机、服务端 Key 和 JSON 输出,禁止开放代理。 + String query = "?output=json&key=" + URLEncoder.encode(key, StandardCharsets.UTF_8) + + (translate ? "&type=" + type + "&locations=" : "&location=") + + URLEncoder.encode(coordinate, StandardCharsets.UTF_8); + URI uri = URI.create("https://apis.map.qq.com" + endpoint + query); + String rateKey = "tencent:location:rate:" + DigestUtil.sha256Hex(key) + ":" + endpoint; + synchronized (lifecycle) { + if (closed || !request.isAsyncSupported()) { + write(response, callback, error(503, "定位代理暂不可用")); + return; + } + if (!capacity.tryAcquire()) { + write(response, callback, error(429, "定位请求排队已满,请稍后重试")); + return; + } + AsyncContext context; + try { + context = request.startAsync(request, response); + } catch (RuntimeException e) { + capacity.release(); + write(response, callback, error(503, "定位异步请求无法启动")); + return; + } + Lane lane = lanes.computeIfAbsent(rateKey, Lane::new); + Job job = new Job(context, lane, endpoint, uri, callback); + pending.add(job); + try { + context.setTimeout(REQUEST_TIMEOUT_MS); + context.addListener(new AsyncListener() { + /** + * 容器提前完成时释放队列名额,禁止继续调用上游。 + * @param event 容器完成事件 + */ + @Override + public void onComplete(AsyncEvent event) { + finish(job, error(499, "定位请求已结束"), false); + } + + /** + * 超时同时取消未执行任务及在途 HTTP 请求。 + * @param event 容器超时事件 + */ + @Override + public void onTimeout(AsyncEvent event) { + finish(job, error(504, "定位代理等待超时,请重新定位"), true); + } + + /** + * 客户端连接错误后不继续消耗腾讯调用额度。 + * @param event 客户端连接错误事件 + */ + @Override + public void onError(AsyncEvent event) { + finish(job, error(499, "定位连接已断开"), false); + } + + /** + * 本代理直接完成响应,不执行二次异步分派。 + * @param event 异步分派事件 + */ + @Override + public void onStartAsync(AsyncEvent event) { + } + }); + if (!lane.queue.offer(job)) { + finish(job, error(429, "定位请求排队已满,请稍后重试"), true); + } + } catch (RuntimeException e) { + finish(job, error(503, "定位请求排队失败"), true); + } + } + } + + private boolean validCoordinate(String value) { + if (value == null || value.length() > 80) return false; + String[] parts = value.split(",", -1); + if (parts.length != 2) return false; + try { + double lat = Double.parseDouble(parts[0]); + double lng = Double.parseDouble(parts[1]); + return Double.isFinite(lat) && Double.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180; + } catch (NumberFormatException e) { + return false; + } + } + + private void drain() { + if (closed) return; + for (Lane lane : lanes.values()) { + if (System.nanoTime() < lane.nextAttempt) continue; + // 每个接口只由队头申请名额,避免数百个任务同时轮询 Redis。 + for (int i = 0; i < 80 && !closed; i++) { + Job job = lane.queue.peek(); + if (job == null) break; + if (job.done) { + lane.queue.remove(job); + continue; + } + if (elapsedMillis(job.createdAt) >= REQUEST_TIMEOUT_MS) { + finish(job, error(504, "定位代理等待超时,请重新定位"), true); + continue; + } + try { + List time = redisService.time(); + long now = Long.parseLong(time.get(0)) * 1000 + Long.parseLong(time.get(1)) / 1000; + long wait = ((Number) redisService.eval(ACQUIRE, List.of(lane.rateKey), + List.of(job.id, Long.toString(now)))).longValue(); + if (wait > 0) { + lane.nextAttempt = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(wait); + break; + } + lane.queue.remove(job); + send(job); + } catch (Exception e) { + // Redis 故障不能退化为直连,否则多个实例会共同击穿腾讯每秒额度。 + log.warn("腾讯定位调度失败 requestId={} endpoint={} errorType={}", + job.id, job.endpoint, e.getClass().getSimpleName()); + finish(job, error(503, "定位调度暂不可用,请稍后重试"), true); + break; + } + } + } + } + + private void send(Job job) { + synchronized (job) { + if (job.done) return; + long remaining = REQUEST_TIMEOUT_MS - elapsedMillis(job.createdAt); + if (closed || remaining <= 0) { + finish(job, error(504, "定位代理等待超时,请重新定位"), true); + return; + } + job.startedAt = System.nanoTime(); + HttpRequest request = HttpRequest.newBuilder(job.uri) + .timeout(Duration.ofMillis(Math.min(5000, remaining))).GET().build(); + job.future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + job.future.whenComplete((response, failure) -> { + if (failure != null) { + finish(job, error(502, "腾讯定位接口调用失败或超时"), true); + return; + } + try { + if (response.statusCode() != 200) { + finish(job, error(502, "腾讯定位接口响应异常"), true); + return; + } + JSONObject body = JSONUtil.parseObj(response.body()); + if (body.getInt("status") == null) throw new IllegalArgumentException("Missing status"); + finish(job, body, true); + } catch (RuntimeException e) { + finish(job, error(502, "腾讯定位接口返回格式异常"), true); + } + }); + } + } + + private void finish(Job job, JSONObject body, boolean reply) { + CompletableFuture future; + synchronized (job) { + if (job.done) return; + job.done = true; + future = job.future; + } + if (future != null && !future.isDone()) future.cancel(true); + job.lane.queue.remove(job); + try { + long queueMs = job.startedAt == 0 ? elapsedMillis(job.createdAt) + : TimeUnit.NANOSECONDS.toMillis(job.startedAt - job.createdAt); + // 不记录 Key、请求 URL 或经纬度;腾讯 request_id 可与浏览器定位结果关联。 + log.info("腾讯定位代理 requestId={} endpoint={} queueMs={} apiMs={} totalMs={} status={} tencentRequestId={}", + job.id, job.endpoint, queueMs, job.startedAt == 0 ? 0 : elapsedMillis(job.startedAt), + elapsedMillis(job.createdAt), body.getInt("status"), body.getStr("request_id", "")); + if (reply) write((HttpServletResponse) job.context.getResponse(), job.callback, body); + } catch (IOException | IllegalStateException e) { + log.debug("腾讯定位响应连接已结束 requestId={}", job.id); + } finally { + pending.remove(job); + capacity.release(); + try { + job.context.complete(); + } catch (IllegalStateException ignored) { + // 容器的超时或连接关闭回调可能已完成同一请求。 + } + } + } + + private static long elapsedMillis(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + private static JSONObject error(int status, String message) { + return new JSONObject().set("status", status).set("message", message); + } + + private static void write(HttpServletResponse response, String callback, JSONObject body) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(callback == null ? "application/json;charset=UTF-8" : "application/javascript;charset=UTF-8"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + String json = JSONUtil.toJsonStr(body); + byte[] bytes = (callback == null ? json : callback + "(" + json + ");").getBytes(StandardCharsets.UTF_8); + response.setContentLength(bytes.length); + response.getOutputStream().write(bytes); + } + + /** 应用关闭时取消排队及在途请求,释放异步响应和线程资源。 */ + public void close() { + Job[] jobs; + synchronized (lifecycle) { + closed = true; + if (dispatcher != null) dispatcher.shutdownNow(); + jobs = pending.toArray(new Job[0]); + } + for (Job job : jobs) finish(job, error(503, "定位服务正在关闭"), true); + if (httpExecutor != null) httpExecutor.shutdownNow(); + } + + private static class Lane { + private final String rateKey; + private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(MAX_PENDING); + private long nextAttempt; + + private Lane(String rateKey) { + this.rateKey = rateKey; + } + } + + private static class Job { + private final String id = UUID.randomUUID().toString(); + private final long createdAt = System.nanoTime(); + private final AsyncContext context; + private final Lane lane; + private final String endpoint; + private final URI uri; + private final String callback; + private volatile boolean done; + private long startedAt; + private CompletableFuture> future; + + private Job(AsyncContext context, Lane lane, String endpoint, URI uri, String callback) { + this.context = context; + this.lane = lane; + this.endpoint = endpoint; + this.uri = uri; + this.callback = callback; + } + } +} diff --git a/src/main/resources/views/platform/zhghh5/activity/trainSignUp/scan/index.html b/src/main/resources/views/platform/zhghh5/activity/trainSignUp/scan/index.html index 83aec8b..eceda8d 100644 --- a/src/main/resources/views/platform/zhghh5/activity/trainSignUp/scan/index.html +++ b/src/main/resources/views/platform/zhghh5/activity/trainSignUp/scan/index.html @@ -594,7 +594,9 @@ layout("/layouts/platform_h5.html"){ }) scanRuntime.circle = new TMap.MultiCircle({map: scanRuntime.map, geometries: [{id: "range", center: center, radius: this.signRadius}]}) - scanRuntime.locator = new LBS.WebComponent.Geolocation({key: key, referer: "zhgh_zjipc-trainSignUp"}) + // 腾讯组件仍负责真实定位,两步 WebService 请求经业务代理统一排队,避免直连突破共享额度。 + scanRuntime.locator = new LBS.WebComponent.Geolocation({key: key, referer: "zhgh_zjipc-trainSignUp", + domain: window.location.origin + "/platform/TMap"}) this.$set(this, "mapReady", true) return this.refreshPosition() }).catch(error => { @@ -647,6 +649,7 @@ layout("/layouts/platform_h5.html"){ this.$set(this, "locationMessage", "正在获取您的当前位置") scanRuntime.markers.remove(["current"]) const requestId = ++scanRuntime.requestId + const locationStarted = performance.now() return new Promise((resolve, reject) => { let completed = false let timer @@ -671,6 +674,10 @@ layout("/layouts/platform_h5.html"){ this.$set(this, "distance", distance) this.$set(this, "locationMessage", distance <= this.signRadius ? "已进入签到范围" : "尚未进入签到范围") this.fitMap(coords) + // 定位阶段包含设备取位、坐标转换和逆地址解析,不记录本人经纬度。 + console.info("签到阶段耗时", {stage: "location", requestId: requestId, + elapsedMs: Math.round(performance.now() - locationStarted), success: true, + tencentRequestId: result.gcoder_request_id}) resolve(coords) } catch (err) { reject(err) } } @@ -678,7 +685,8 @@ layout("/layouts/platform_h5.html"){ if (window.isSecureContext === false) { finish(new Error("当前页面不是安全环境,请使用HTTPS地址定位")); return } - timer = window.setTimeout(() => finish(new Error("定位超时,请检查网络和定位权限后重试")), 12000) + // 为共享额度排队预留时间;外层兜底晚于 SDK 截止,保留其具体失败原因。 + timer = window.setTimeout(() => finish(new Error("定位超时,请检查网络和定位权限后重试")), 30000) try { scanRuntime.locator.getLocation(result => finish(null, result), detail => { const status = Number(detail && detail.status) @@ -688,9 +696,11 @@ layout("/layouts/platform_h5.html"){ // 只记录错误诊断,不记录本人经纬度。 console.warn("腾讯签到定位失败", {status: status, message: detail && detail.message, logs: detail && detail.logs}) finish(new Error(message)) - }, {timeout: 10000, highAccuracy: true, maximumAge: 0}) + }, {timeout: 25000, highAccuracy: true, maximumAge: 0}) } catch (error) { finish(new Error("腾讯定位调用失败,请重新加载地图")) } }).catch(error => { + console.warn("签到阶段耗时", {stage: "location", requestId: requestId, + elapsedMs: Math.round(performance.now() - locationStarted), success: false}) if (!scanRuntime.destroyed) { this.$set(this, "locationFailed", true) this.$set(this, "locationMessage", error.message) @@ -762,7 +772,18 @@ layout("/layouts/platform_h5.html"){ return this.getUserLocation().then((coords) => { if (scanRuntime.destroyed) return null if (this.distance > this.signRadius) throw new Error("尚未进入签到范围,请到达地点后重试") + const signStarted = performance.now() + const requestId = scanRuntime.requestId return this.$axios.post("/platform/trainSignUp/mine/drivingScan", {courseId: this.courseId, lat: coords.lat, lng: coords.lng}) + .then(resp => { + console.info("签到阶段耗时", {stage: "sign", requestId: requestId, + elapsedMs: Math.round(performance.now() - signStarted), code: resp.code}) + return resp + }, error => { + console.warn("签到阶段耗时", {stage: "sign", requestId: requestId, + elapsedMs: Math.round(performance.now() - signStarted), success: false}) + throw error + }) }).then((resp) => { if (!resp || scanRuntime.destroyed) return if (!this.extensionEnabled) { this.$set(this, "legacyResult", resp); return }