首页 / 资讯中心 / 文章详情

yshop多租户点餐系统:扫码下单、动态菜单与云打印全链路实践

yshop多租户点餐系统:扫码下单、动态菜单与云打印全链路实践 ★ FEATURED ARTICLE
简介yshop意象点餐系统是一套面向餐饮企业与开发者的新零售SaaS级扫码点餐解决方案支持外卖、自取、多门店及租户隔离部署适用于连锁餐饮、社区小店等场景的数字化升级与二次开发。资源包含2005个文件主体为1324个Java后端模块基于SpringBootMyBatisPlusSpring Security OAuth2JWTRedis、257个Vue3前端组件uniapp框架兼容H5与微信小程序及161个JS业务逻辑脚本辅以SQL建表语句、YAML配置、MD文档说明等结构完整、分层清晰便于理解全栈技术栈整合逻辑。目前已有149人学习下载。开发者可直接获取涵盖商品SKU管理、云小票打印、积分/优惠券/会员卡/拼团/分销等核心业务的可运行代码同时包含桌面扫码点餐、公众号对接、门店移动端等落地模块配套素材库与样式文件如ueditor、video-js等CSS资源也已内嵌显著降低商用化集成门槛。1. yshop意象点餐系统一套能直接跑通“扫码→下单→打印→分账”的SaaS化点餐底座不是Demo是可商用的多门店协同骨架你有没有遇到过这种场景一家连锁奶茶店想让顾客进店扫桌码点单同时开通小程序外卖还要支持加盟商独立管理库存和订单总部又能统一看数据、发优惠券、做会员积分——结果翻遍开源项目要么是单门店硬编码写死的VueSpringBoot小demo改个门店ID都要重编译要么是号称SaaS但租户隔离靠数据库前缀硬拼一加新租户就得手动建库、改配置、重启服务。yshop意象点餐系统就是冲着这个断层来的它用SpringBootMyBatis-Plus做后端服务层用Vue3uniapp打包出H5页和微信小程序双端关键在于它的多租户不是概念而是落地到数据源路由DynamicDataSource、租户上下文透传TenantContextHolder、菜单权限按租户动态加载、甚至云小票打印机也支持按门店绑定独立设备号。它不解决“怎么设计分布式事务”但把“一个商户开3家店、2个分销商、500个会员、每天3000单”这种真实业务流里90%的重复轮子——SKU规格组合、自取/外卖状态机、微信支付回调幂等、Redis缓存商品库存防超卖、优惠券核销与订单强绑定——全给你焊死在代码里。适合想快速上线本地生活服务系统的中小技术团队或者有定制能力的ISV服务商拿来改UI、接硬件、连ERP比从零搭Spring Security OAuth2JWTRedis缓存链路省至少三周。2. 多租户架构落地从数据库隔离到前端菜单动态加载的完整链路2.1 租户识别与上下文注入基于请求头Token解析的轻量级方案yshop没有采用ShardingSphere或Seata这类重型中间件做租户路由而是选择更贴近业务的轻量路径所有HTTP请求必须携带X-Tenant-ID请求头如X-Tenant-ID: shop_001后端通过TenantFilter拦截器提取该值并存入ThreadLocalTenantContext。关键点在于这个上下文不仅用于数据库路由还贯穿整个调用链——比如用户登录后生成的JWT Token中tenantId字段被显式写入payload后续每次API调用网关层先验签再从Token里解析出tenantId与请求头中的值做一致性校验防篡改。若不一致则拒绝访问。这种双重校验避免了仅依赖请求头可能被恶意伪造的风险。// TenantFilter.java 核心逻辑节选 public class TenantFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String tenantIdFromHeader httpRequest.getHeader(X-Tenant-ID); String tenantIdFromToken extractTenantIdFromJwt(httpRequest); if (!Objects.equals(tenantIdFromHeader, tenantIdFromToken)) { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); httpResponse.getWriter().write(Tenant ID mismatch); return; } TenantContextHolder.setTenantId(tenantIdFromHeader); // 注入ThreadLocal try { chain.doFilter(request, response); } finally { TenantContextHolder.clear(); // 必须清理防线程复用污染 } } }提示TenantContextHolder.clear()这行代码是血泪经验。某次压测发现部分请求读到了上一个请求残留的tenantId导致A门店的数据被写进了B门店的表。根源就是Tomcat线程池复用时ThreadLocal未清理。从此我们团队所有涉及ThreadLocal的Filter都强制加finally块清理。2.2 动态数据源路由MyBatis-Plus插件如何实现“一库多租户”与“一租户一库”双模式yshop支持两种物理隔离级别共享库模式所有租户共用一个MySQL库通过tenant_id字段做逻辑隔离和独立库模式每个租户拥有独立数据库实例。切换靠application.yml中yshop.tenant.mode: SHARED / SEPARATE控制。核心路由逻辑由自定义DynamicDataSource类实现它继承AbstractRoutingDataSource重写determineCurrentLookupKey()方法// DynamicDataSource.java public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String tenantId TenantContextHolder.getTenantId(); if (StringUtils.isBlank(tenantId)) { return master; // 默认主库 } // 若为SEPARATE模式返回slave_shop_001这样的动态key if (SEPARATE.equals(tenantMode)) { return slave_ tenantId; } // SHARED模式下所有租户走同一数据源但SQL自动注入tenant_id条件 return master; } }而SHARED模式下的SQL自动注入则由MyBatis-Plus的InnerInterceptor实现——TenantLineInnerInterceptor会在所有SELECT/UPDATE/DELETE语句末尾追加AND tenant_id ?且对INSERT语句自动填充tenant_id字段值。这意味着开发者写Mapper时完全不用关心租户字段lambdaQuery().eq(Product::getName, 珍珠奶茶)会自动变成WHERE name 珍珠奶茶 AND tenant_id shop_001。注意该拦截器默认排除sys_user、sys_role等系统表因为这些表属于平台级不归属任何租户。排除列表在TenantLineInnerInterceptor构造时传入若你新增了平台级配置表如platform_sms_template必须手动加入排除列表否则查询会报tenant_id字段不存在。2.3 前端租户菜单与权限uniapp中如何让不同门店看到不同的功能入口前端侧租户隔离不只体现在接口调用更体现在用户可见的功能界面上。yshop前端uniapp/Vue3在登录成功后会调用/api/v1/menu/list接口获取当前租户的菜单树。该接口返回JSON结构如下{ code: 200, data: [ { id: 101, name: 商品管理, path: /product/list, icon: goods, sort: 1, children: [ {id: 1011, name: SKU管理, path: /product/sku, permission: product:sku:list} ] }, { id: 201, name: 门店管理, path: /store/list, icon: store, sort: 2, visible: true // 控制是否显示在侧边栏 } ] }关键字段是visible和permissionvisible由后端根据租户类型决定——总部账号看到门店管理而普通门店店长账号该节点visible:false前端直接过滤掉permission用于按钮级权限控制例如只有拥有order:print:ticket权限的账号才能看到“打印小票”按钮。uniapp中通过自定义指令v-has-permissionorder:print:ticket实现。!-- ProductList.vue -- template view classbtn-group button v-has-permissionproduct:sku:add clickopenSkuDialog新增SKU/button button v-has-permissionproduct:sku:import clickimportSku批量导入/button /view /template script setup // v-has-permission 指令定义 const hasPermission (value) { const permissions uni.getStorageSync(userPermissions) || []; return permissions.includes(value); }; // 全局注册指令 uni.createApp(App).directive(has-permission, { mounted(el, binding) { if (!hasPermission(binding.value)) { el.style.display none; } } }); /script这套机制让“总部看全局、门店管自己、分销商盯业绩”成为可能且无需前端硬编码if-else判断角色。3. 扫码点餐核心流程从桌码生成、订单创建到云小票打印的端到端闭环3.1 桌码生成与绑定为什么用短链接而非直接二维码URL参数设计玄机yshop不直接生成指向/table/order?storeId1001tableNoA01的长二维码而是走短链接服务如https://yshop.io/t/abc123。原因有三一是微信小程序内嵌H5页对长URL兼容性差易截断二是便于后期运营——abc123可映射到任意目标地址未来要加AB测试50%流量导到新UI页只需改短链后台配置三是安全短链本身不暴露storeId等敏感参数真实参数经服务端解密后才注入页面。短链生成逻辑在TableQrCodeService中// TableQrCodeService.java public String generateTableShortUrl(Long storeId, String tableNo) { // 1. 构造原始参数storeId1001tableNoA01timestamp1712345678signxxx String rawParams String.format(storeId%dtableNo%stimestamp%d, storeId, tableNo, System.currentTimeMillis()); String sign DigestUtils.md5Hex(rawParams yshop_secret_key); // 签名防篡改 // 2. 将带签名的参数存入Redis设置1小时过期 String cacheKey table_qr: DigestUtils.md5Hex(rawParams); redisTemplate.opsForValue().set(cacheKey, rawParams, Duration.ofHours(1)); // 3. 生成6位随机短码避免碰撞 String shortCode RandomStringUtils.randomAlphanumeric(6).toLowerCase(); shortUrlMapper.insert(new ShortUrl(shortCode, cacheKey)); return https://yshop.io/t/ shortCode; }用户扫码后Nginx将/t/abc123请求转发至ShortUrlController后者查库拿到cacheKey再从Redis取原始参数校验sign有效性最后重定向至/h5/table-order?storeId1001tableNoA01tokenxxx——这个token是临时会话凭证有效期15分钟用于防止重复提交。3.2 订单状态机外卖与自取的差异不只是配送方式更是状态流转引擎yshop用状态机State Machine管理订单全生命周期而非简单status字段枚举。核心状态包括WAIT_PAY待支付、PAID已支付、PREPARING制作中、READY已备好、DELIVERING配送中、COMPLETED已完成、CANCELLED已取消。但关键区别在于触发条件状态外卖订单可触发动作自取订单可触发动作PAID→PREPARING商家确认接单→PREPARING同左PREPARING→READY制作完成→READY同左READY→DELIVERING骑手取货→COMPLETED顾客扫码核销自取订单的COMPLETED状态由顾客在门店扫码核销触发。核销接口/api/v1/order/verify接收orderNo和verifyCode小票右下角6位数字服务端校验该码是否在有效期内、是否未被使用、是否属于当前门店。一旦核销成功状态跳转为COMPLETED并同步更新会员积分、发放优惠券。// OrderVerifyService.java Transactional public Result verifyOrder(String orderNo, String verifyCode) { Order order orderMapper.selectByOrderNo(orderNo); if (!order.getStoreId().equals(getCurrentStoreId())) { return Result.fail(非本店订单无法核销); } if (!verifyCode.equals(order.getVerifyCode())) { return Result.fail(核销码错误); } if (order.getStatus() ! OrderStatus.READY) { return Result.fail(订单状态不可核销); } // 更新状态 order.setStatus(OrderStatus.COMPLETED); order.setVerifiedTime(LocalDateTime.now()); orderMapper.updateById(order); // 同步积分调用积分服务FeignClient pointsService.addPoints(order.getUserId(), order.getActualAmount().intValue()); return Result.success(); }提示verifyCode不是随机数而是MD5(orderNo salt timestamp)取后6位且timestamp精确到分钟。这样既保证唯一性又避免暴力穷举——攻击者需在一分钟内猜中6位码概率低于百万分之一。3.3 云小票打印如何让不同门店的打印机打出不同抬头和LOGOyshop的云小票不是简单调用浏览器window.print()而是对接硬件厂商SDK如易联云、商米的HTTP API。每台打印机在系统中注册时需填写打印机SN码唯一标识绑定门店IDstore_id自定义抬头文本如“XX茶饮·解放路店”LOGO图片URL上传至OSS返回CDN地址打印模板JSON格式定义字段位置、字体大小、是否打印二维码等当订单状态变为READY后端自动调用PrintService生成小票内容// PrintService.java public void printTicket(Order order) { Store store storeMapper.selectById(order.getStoreId()); Printer printer printerMapper.selectByStoreId(store.getId()); // 构建小票JSON数据 MapString, Object ticketData new HashMap(); ticketData.put(header, store.getReceiptHeader()); // “XX茶饮·解放路店” ticketData.put(logoUrl, store.getLogoUrl()); ticketData.put(orderNo, order.getOrderNo()); ticketData.put(items, order.getItems().stream() .map(item - Map.of(name, item.getName(), qty, item.getQuantity())) .collect(Collectors.toList())); ticketData.put(total, order.getActualAmount()); // 调用易联云APIPOST https://api.elepay.cn/print String payload JSON.toJSONString(ticketData); String sign HmacSHA256.sign(payload, printer.getApiKey()); HttpHeaders headers new HttpHeaders(); headers.set(Authorization, Bearer printer.getAccessToken()); headers.set(X-Signature, sign); HttpEntityString entity new HttpEntity(payload, headers); restTemplate.postForObject(https://api.elepay.cn/print, entity, String.class); }这意味着解放路店的小票抬头是“XX茶饮·解放路店”而中山路店自动显示“XX茶饮·中山路店”连LOGO都是各自上传的无需前端区分。4. 避坑指南部署与二次开发中踩过的5个真实坑4.1 现象微信小程序登录失败控制台报errCode: 40029, errMsg: invalid code原因yshop后端调用微信jscode2session接口时appid和secret填的是公众号的而非小程序的。虽然同属一个主体但小程序和公众号的appid完全不同secret也独立申请。解决登录微信公众平台 → 小程序管理后台 → 开发管理 → 开发设置 → 查看“AppID”和“AppSecret”替换application.yml中wechat.miniapp.appid和wechat.miniapp.secret的值。注意secret重置后旧值立即失效务必同步更新。4.2 现象多门店模式下A门店的商品在B门店的小程序里也能搜到原因前端搜索接口/api/v1/product/search未透传storeId参数后端查询时未加tenant_id条件导致跨租户数据泄露。解决检查ProductController.search()方法确认其调用的productService.search()内部是否使用了TenantLineInnerInterceptor。若未启用需在MybatisPlusConfig中注册该拦截器并确保search()方法的Mapper XML中未手动写死WHERE条件应全部走LambdaQueryWrapper。4.3 现象Redis缓存击穿大促期间商品详情页大量DB查询CPU飙升原因yshop对商品详情使用Cacheable(key product: #id)注解但未设置空值缓存null值不缓存。当某个ID不存在的商品被高频查询如恶意爬虫刷/product/999999每次都会穿透到DB。解决修改缓存注解添加unless#result null并配合空值缓存策略Cacheable(value product, key product: #id, unless #result null) public Product getProductById(Long id) { ... } // 同时在CacheConfig中配置空值缓存 Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 关键允许缓存null值且过期时间设为2分钟 config config.computePrefixWith(cacheName - cache: cacheName :); return RedisCacheManager.builder(factory).cacheDefaults(config).build(); }4.4 现象H5端扫码点餐iOS微信内置浏览器点击“立即支付”无反应原因iOS微信对WeixinJSBridge调用有严格限制必须在用户手势如click触发的同步回调中执行不能在异步setTimeout或Promise.then中调用。yshop支付模块中wx.chooseImage()后调用wx.previewImage()再调wx.openProductView()中间有异步链。解决将支付调用收束到最外层click事件处理器中移除所有中间setTimeout。参考修复后代码// 支付按钮click事件 onPayClick() { // 1. 先调起微信支付必须在此处 this.invokeWechatPay(); // 2. 其他异步操作放后面 this.logPaymentEvent(); } invokeWechatPay() { // 直接调用WeixinJSBridge.invoke不包裹在Promise里 WeixinJSBridge.invoke(getBrandWCPayRequest, payParam, (res) { if (res.err_msg get_brand_wcpay_request:ok) { uni.showToast({title: 支付成功}); } }); }4.5 现象分销商后台看不到自己发展的下级会员原因分销关系表dist_relation中parent_id字段存储的是上级分销商的user_id但查询时未关联sys_user表获取nick_name导致前端渲染为空。更严重的是dist_relation表未建联合索引(parent_id, status)大数据量时查询缓慢。解决在DistRelationMapper.xml中selectByParentId查询语句改为LEFT JOINsys_user获取昵称对dist_relation表执行SQL添加索引ALTER TABLE dist_relation ADD INDEX idx_parent_status (parent_id, status);检查DistRelationService.listDownlines()方法确认分页参数page和size已正确传递给Mapper避免全表扫描。5. 积分与优惠券深度联动如何让“满100减20”真正影响会员成长值计算5.1 积分规则引擎不是静态配置而是可编程的Groovy脚本yshop的积分发放不靠if-else硬编码而是引入Groovy脚本引擎。管理员在后台“积分规则”模块中可编写类似以下脚本// 订单支付成功后发放积分 if (event.type ORDER_PAID) { def amount event.order.actualAmount def basePoints (amount * 10).intValue() // 每元10积分 // 如果使用了优惠券按实付金额计算而非原价 if (event.order.couponId ! null) { basePoints (event.order.payAmount * 10).intValue() } // 黄金会员额外加赠20% if (event.user.vipLevel GOLD) { basePoints (basePoints * 1.2).intValue() } return basePoints }该脚本在PointsService.calculatePoints()中执行// PointsService.java public Integer calculatePoints(Event event) { String script ruleMapper.selectScriptByEvent(event.getType()); Binding binding new Binding(); binding.setVariable(event, event); GroovyShell shell new GroovyShell(); try { Object result shell.evaluate(script, binding); return ((Number) result).intValue(); } catch (Exception e) { log.error(Groovy script execution failed for event {}, event.getType(), e); return 0; // 脚本异常时返回0不阻断主流程 } }提示Groovy脚本执行有性能开销因此yshop对脚本做了两级缓存一级是ConcurrentHashMapString, CompiledScript缓存编译后的字节码二级是Caffeine缓存脚本执行结果按event.typeuser.vipLevel组合为key过期时间5分钟。这样既保证灵活性又避免每次支付都编译脚本。5.2 优惠券核销与订单强绑定为什么不能“先发券再下单”yshop坚持“下单即锁券”原则杜绝“用户领券后不使用券长期闲置”的资源浪费。具体流程是用户在结算页选择优惠券 → 前端将couponId传入/api/v1/order/create→ 后端在创建订单事务中同步执行couponService.lockCoupon(couponId, userId, orderId)。该方法包含三个原子操作检查优惠券状态status USABLE且valid_start_time now valid_end_time检查使用门槛order.totalAmount coupon.minAmount执行Redis扣减DECR coupon:stock:{couponId}若返回值 0则回滚事务。// CouponService.java Transactional public void lockCoupon(Long couponId, Long userId, String orderId) { Coupon coupon couponMapper.selectById(couponId); if (!USABLE.equals(coupon.getStatus()) || LocalDateTime.now().isBefore(coupon.getValidStartTime()) || LocalDateTime.now().isAfter(coupon.getValidEndTime())) { throw new BusinessException(优惠券不可用); } // Lua脚本保证原子性检查库存 扣减 String luaScript local stock redis.call(GET, coupon:stock: .. KEYS[1]) if tonumber(stock) 0 then redis.call(DECR, coupon:stock: .. KEYS[1]) redis.call(HSET, coupon:used: .. KEYS[1], ARGV[1], ARGV[2]) return 1 else return 0 end; Long result (Long) redisTemplate.execute( new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(String.valueOf(couponId)), String.valueOf(userId), orderId ); if (result 0) { throw new BusinessException(优惠券库存不足); } }此设计确保每张优惠券只能被一个订单锁定且锁定后立即生效不会出现“超卖”。5.3 会员等级与分销体系融合一张表如何支撑“消费升星”与“拉新返佣”双逻辑yshop用一张member_level_rule表统一管理会员成长体系字段包括level_codeVIP_SILVER、min_consumption累计消费≥500、min_referrals直推≥3人、points_per_yuan每元积10分、commission_rate分销佣金5%。关键设计在于min_consumption和min_referrals是OR关系而非AND用户满足任一条件即可升级。-- member_level_rule 表结构示意 CREATE TABLE member_level_rule ( id BIGINT PRIMARY KEY, level_code VARCHAR(20) NOT NULL, -- VIP_SILVER, VIP_GOLD min_consumption DECIMAL(10,2) DEFAULT 0.00, -- 累计消费门槛 min_referrals INT DEFAULT 0, -- 直推人数门槛 points_per_yuan INT DEFAULT 10, -- 每元积分数 commission_rate DECIMAL(5,4) DEFAULT 0.0500, -- 分销佣金比例 upgrade_desc VARCHAR(100) -- 升级说明“消费满500元或直推3人” );升级检测逻辑在MemberService.checkUpgrade()中public void checkUpgrade(Long userId) { Member member memberMapper.selectById(userId); ListMemberLevelRule rules ruleMapper.selectAll(); for (MemberLevelRule rule : rules) { boolean meetConsumption member.getTotalConsumption().compareTo(rule.getMinConsumption()) 0; boolean meetReferrals member.getDirectReferrals() rule.getMinReferrals(); if (meetConsumption || meetReferrals) { // OR逻辑 if (!member.getLevelCode().equals(rule.getLevelCode())) { // 升级 member.setLevelCode(rule.getLevelCode()); member.setUpgradeTime(LocalDateTime.now()); memberMapper.updateById(member); // 发送升级通知站内信短信 notifyService.sendLevelUpNotice(userId, rule.getLevelCode()); } break; // 找到第一个匹配规则即停止避免降级 } } }这种设计让“老客多消费”和“新客拉好友”两条增长路径在同一套规则引擎下自然交汇无需维护两套独立的等级表。6. 从那以后我每次部署yshop都强制走一遍这3个验证步骤6.1 验证租户数据隔离用一条SQL揪出所有越权漏洞部署完多租户环境第一件事不是测下单而是直连数据库执行这条SQLSELECT t1.id AS order_id, t1.store_id AS order_store_id, t2.id AS product_id, t2.store_id AS product_store_id, t3.id AS user_id, t3.store_id AS user_store_id FROM yshop_order t1 JOIN yshop_product t2 ON t1.product_id t2.id JOIN yshop_user t3 ON t1.user_id t3.id WHERE t1.store_id ! t2.store_id OR t1.store_id ! t3.store_id;如果返回任何记录说明存在跨租户数据关联——比如订单绑定了其他门店的商品或用户属于其他租户。这通常源于Mapper中写了LEFT JOIN却忘了加AND t1.store_id t2.store_id条件。yshop的TenantLineInnerInterceptor只对WHERE条件生效对JOIN条件无效必须人工审查所有涉及多表关联的XML文件。6.2 验证优惠券幂等性模拟并发抢券的终极压力测试用JMeter配置100线程循环执行以下步骤调用/api/v1/coupon/receive?couponId1001领取一张限量10张的券调用/api/v1/order/create用刚领的券下单检查响应码是否全为200且最终coupon:stock:1001的Redis值等于0。若出现500错误或库存未归零说明Lua脚本未覆盖所有边界条件。此时需检查coupon:used:{couponId}哈希表中是否有多条记录对应同一个userId即同一用户多次领取这违反了“一人一券”规则。修复方案是在Lua脚本中增加HEXISTS检查-- 修正后的Lua脚本片段 if redis.call(HEXISTS, coupon:used: .. KEYS[1], ARGV[1]) 1 then return -1 -- 已领取过 end6.3 验证云小票容错拔掉打印机网线后订单状态是否卡死这是最容易被忽略的生产隐患。在测试环境启动订单服务后立刻拔掉易联云打印机的网线然后创建一笔自取订单。观察订单状态是否仍能正常流转到READY小票打印是异步任务不应阻塞主流程日志中是否出现ConnectException堆栈且被正确捕获而非抛出RuntimeException导致事务回滚10分钟后是否自动重试yshop默认配置print.retry.count3间隔print.retry.interval60000毫秒。若状态卡住说明PrintService.printTicket()方法缺少Async注解或事务传播行为设为REQUIRED导致打印失败回滚整个订单创建事务。正确做法是将打印逻辑抽离为独立Service用Async异步执行并在方法内try-catch所有异常仅记录告警日志绝不抛出。从那以后我每次部署yshop都强制走一遍这3个验证步骤——不是因为信不过代码而是信不过自己没看清文档里那行小字“print.asynctrue默认关闭”。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站