Java REST 服务中实现 Gmail API 无人值守访问的策略与实践

java rest 服务中实现 gmail api 无人值守访问的策略与实践

本文深入探讨了Java REST服务在无需用户持续干预下访问Gmail API的多种策略。核心内容包括针对Google Workspace域账户的域范围委派(Domain-Wide Delegation)方案,以及针对标准Gmail账户的OAuth 2.0刷新令牌机制。文章详细阐述了每种方法的实现原理、适用场景、配置要点及Java代码示例,旨在帮助开发者构建高效、安全的自动化邮件通知服务。

在构建需要与Gmail API集成的Java REST服务时,尤其是在发送邮件通知给大量不同客户端的场景下,实现无需用户持续干预的自动化访问是一个核心挑战。与Microsoft Graph的客户端凭据流类似,开发者期望能够一次性配置或授权后,系统便能自主地进行邮件发送等操作。然而,Gmail API的授权机制相对复杂,针对不同类型的Google账户(标准Gmail账户 vs. Google Workspace域账户)有着不同的最佳实践。本文将详细介绍两种主要的实现策略,并探讨其适用性与具体实现细节。

策略一:基于Google Workspace域范围委派(Domain-Wide Delegation)的无人值守访问

原理与适用场景

对于企业或组织内部使用Google Workspace(原G Suite)的域账户,Google提供了域范围委派(Domain-Wide Delegation, DWD)机制。这种机制允许一个服务账户(Service Account)在没有最终用户直接参与的情况下,代表域中的任何用户调用Google API。这意味着,一旦配置完成,您的Java REST服务可以通过服务账户,以特定域用户的身份发送邮件,而无需该用户进行任何交互或显示同意屏幕。

DWD的适用场景非常明确:您的服务需要访问的是属于特定Google Workspace域的账户,并且该域的管理员已经为您的服务账户授予了相应的权限。这是实现完全无人值守访问Gmail API的唯一官方推荐方式。

立即学习“Java免费学习笔记(深入)”;

配置步骤

创建服务账户并生成密钥

登录Google Cloud Platform (GCP) 控制台。导航至“IAM 与管理” > “服务帐号”。创建新的服务账户,并为其分配必要的角色(例如,Service Account Token Creator,或更具体的Gmail相关角色,但通常DWD的权限由API范围控制)。创建并下载JSON格式的私钥文件(client_secrets.json或其他命名),此文件包含服务账户的凭据。

在Google Workspace管理控制台启用域范围委派

登录Google Workspace管理控制台(admin.google.com)。导航至“安全性” > “API 控件” > “域范围委派”。点击“添加新”或“管理 API 客户端访问权限”。在“客户端 ID”字段中输入您服务账户的唯一 ID(可在GCP服务账户详情页找到)。在“OAuth 范围”字段中输入您的服务需要访问的Gmail API范围。例如,要发送邮件,您可能需要https://www.googleapis.com/auth/gmail.send或https://www.googleapis.com/auth/gmail.compose。如果需要更广泛的权限,可以使用https://www.googleapis.com/auth/gmail.modify或https://www.googleapis.com/auth/gmail.readonly等。点击“授权”。

Java实现示例

在Java应用程序中,您需要使用Google API Java客户端库来构建GoogleCredential对象。关键在于通过setServiceAccountUser()方法指定要模拟的域内用户邮箱地址。

首先,确保您的pom.xml中包含了Google API客户端库的依赖,例如:

    com.google.api-client    google-api-client    1.32.1     com.google.oauth-client    google-oauth-client-jetty    1.32.1     com.google.apis    google-api-services-gmail    v1-rev20210604-1.32.1     com.google.http-client    google-http-client-jackson2    1.32.1 

以下是获取GoogleCredential的示例代码,它将使用服务账户密钥文件 (client_secrets.json) 并模拟指定的域用户:

腾讯智影-AI数字人 腾讯智影-AI数字人

基于AI数字人能力,实现7*24小时AI数字人直播带货,低成本实现直播业务快速增增,全天智能在线直播

腾讯智影-AI数字人 73 查看详情 腾讯智影-AI数字人

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;import com.google.api.client.http.HttpTransport;import com.google.api.client.http.javanet.NetHttpTransport;import com.google.api.client.json.JsonFactory;import com.google.api.client.json.jackson2.JacksonFactory;import com.google.api.services.gmail.GmailScopes; // 引入Gmail API的Scopesimport java.io.IOException;import java.io.InputStream;import java.util.Arrays;import java.util.List;public class GmailServiceAccountAuth {    private static final String APPLICATION_NAME = "Gmail API Java Quickstart";    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();    private static HttpTransport HTTP_TRANSPORT;    static {        try {            HTTP_TRANSPORT = new NetHttpTransport();        } catch (Throwable t) {            t.printStackTrace();            System.exit(1);        }    }    /**     * 构建并返回一个经过授权的GoogleCredential实例,用于通过域范围委派访问Gmail API。     *     * @param serviceAccountKeyPath 服务账户JSON密钥文件的路径(例如 "client_secrets.json")     * @param userToImpersonate   要模拟的域内用户邮箱地址     * @return 经过授权的GoogleCredential实例     * @throws IOException 如果无法读取密钥文件或发生其他I/O错误     */    public static GoogleCredential authorizeWithDomainWideDelegation(String serviceAccountKeyPath, String userToImpersonate) throws IOException {        // 定义Gmail API所需的权限范围        // 根据实际需求选择合适的Scope,例如:        // GmailScopes.GMAIL_SEND (仅发送邮件)        // GmailScopes.GMAIL_COMPOSE (撰写和发送邮件,包括草稿)        // GmailScopes.GMAIL_MODIFY (读写邮件,包括发送)        // GmailScopes.GMAIL_READONLY (仅读取邮件)        List scopes = Arrays.asList(GmailScopes.GMAIL_SEND); // 或其他您需要的范围        try (InputStream jsonFileStream = GmailServiceAccountAuth.class.getClassLoader().getResourceAsStream(serviceAccountKeyPath)) {            if (jsonFileStream == null) {                throw new IOException("Service account key file not found: " + serviceAccountKeyPath);            }            GoogleCredential credential = GoogleCredential.fromStream(jsonFileStream, HTTP_TRANSPORT, JSON_FACTORY)                    .createScoped(scopes) // 使用Gmail API的Scopes                    .createWithUser(userToImpersonate); // 关键:指定要模拟的用户            return credential;        }    }    // 示例用法    public static void main(String[] args) {        String serviceAccountKeyFile = "client_secrets.json"; // 确保此文件在classpath中        String targetUserEmail = "user@yourdomain.com"; // 替换为Google Workspace域中的实际用户邮箱        try {            GoogleCredential credential = authorizeWithDomainWideDelegation(serviceAccountKeyFile, targetUserEmail);            System.out.println("GoogleCredential obtained successfully for user: " + targetUserEmail);            // 此时,您可以使用此credential构建Gmail服务客户端并执行操作            // 例如:Gmail service = new Gmail.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential).setApplicationName(APPLICATION_NAME).build();            // service.users().messages().send("me", message).execute();        } catch (IOException e) {            System.err.println("Error during authorization: " + e.getMessage());            e.printStackTrace();        }    }}

优点与限制

优点:完全自动化,无需任何用户交互,非常适合后台服务或批量操作。限制:仅适用于Google Workspace域账户。对于标准Gmail账户(如@gmail.com),此方法不适用。

策略二:OAuth 2.0 刷新令牌机制

原理与适用场景

对于标准的Gmail账户(非Google Workspace域账户),或当您无法控制Google Workspace域的管理员权限时,域范围委派不再适用。此时,您需要依赖标准的OAuth 2.0授权流程。虽然首次授权时需要用户通过同意屏幕进行交互,但一旦用户授予权限并获得了刷新令牌(Refresh Token),您的服务就可以将该刷新令牌存储起来。之后,每当需要访问Gmail API时,您的服务可以使用存储的刷新令牌来请求新的访问令牌(Access Token),而无需用户再次手动授权。访问令牌通常在短时间内(例如1小时)过期,而刷新令牌则具有较长的有效期,甚至永不过期(除非用户撤销授权或Google强制过期)。

实现流程概述

引导用户进行首次授权:将用户重定向到Google的授权URL,用户登录并同意您的应用程序访问其Gmail数据。获取授权码:Google会将用户重定向回您的应用程序的重定向URI,并在URL参数中包含一个授权码(Authorization Code)。交换令牌:您的应用程序使用此授权码向Google的令牌端点发起请求,交换得到访问令牌和刷新令牌。安全存储刷新令牌:将获取到的刷新令牌安全地存储在您的数据库中,与对应的用户关联。使用刷新令牌获取新访问令牌:当需要访问Gmail API时,从数据库中取出用户的刷新令牌,向Google的令牌端点发起请求,获取一个新的有效访问令牌。使用访问令牌调用API:使用新获取的访问令牌来构建Gmail服务客户端并执行API调用。

Java实现示例(概念性)

由于涉及用户交互和Web重定向,完整的OAuth 2.0流程实现会比较复杂,通常结合Web框架(如Spring Boot)进行。这里仅提供获取和使用刷新令牌的核心概念代码:

import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;import com.google.api.client.http.HttpTransport;import com.google.api.client.http.javanet.NetHttpTransport;import com.google.api.client.json.JsonFactory;import com.google.api.client.json.jackson2.JacksonFactory;import com.google.api.client.util.store.FileDataStoreFactory;import com.google.api.services.gmail.GmailScopes;import java.io.File;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;import java.util.Arrays;import java.util.List;public class GmailOAuth2Auth {    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();    private static HttpTransport HTTP_TRANSPORT;    static {        try {            HTTP_TRANSPORT = new NetHttpTransport();        } catch (Throwable t) {            t.printStackTrace();            System.exit(1);        }    }    /**     * 从客户端密钥文件加载GoogleClientSecrets。     * @param clientSecretsPath 客户端密钥JSON文件的路径     * @return GoogleClientSecrets对象     * @throws IOException     */    public static GoogleClientSecrets loadClientSecrets(String clientSecretsPath) throws IOException {        InputStream in = GmailOAuth2Auth.class.getClassLoader().getResourceAsStream(clientSecretsPath);        if (in == null) {            throw new IOException("Client secrets file not found: " + clientSecretsPath);        }        return GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));    }    /**     * 首次授权流程:构建授权URL,并处理回调获取令牌。     * 实际应用中,这部分通常由Web控制器处理。     * @param clientSecrets GoogleClientSecrets对象     * @param redirectUri   您的应用程序的重定向URI     * @param scopes        所需的API范围     * @return 授权URL,用户需要访问此URL进行授权     * @throws IOException     */    public static String getAuthorizationUrl(GoogleClientSecrets clientSecrets, String redirectUri, List scopes) throws IOException {        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(                HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, scopes)                .setDataStoreFactory(new FileDataStoreFactory(new File("tokens"))) // 示例:将令牌存储在文件系统                .setAccessType("offline") // 关键:请求刷新令牌                .build();        return flow.newAuthorizationUrl().setRedirectUri(redirectUri).build();    }    /**     * 使用授权码交换访问令牌和刷新令牌。     * @param clientSecrets GoogleClientSecrets对象     * @param authorizationCode 从回调URL中获取的授权码     * @param redirectUri       您的应用程序的重定向URI     * @param scopes            所需的API范围     * @return GoogleTokenResponse,包含访问令牌和刷新令牌     * @throws IOException     */    public static GoogleTokenResponse exchangeCodeForTokens(GoogleClientSecrets clientSecrets, String authorizationCode, String redirectUri, List scopes) throws IOException {        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(                HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, scopes)                .setDataStoreFactory(new FileDataStoreFactory(new File("tokens"))) // 示例:将令牌存储在文件系统                .setAccessType("offline")                .build();        return flow.newTokenRequest(authorizationCode).setRedirectUri(redirectUri).execute();    }    /**     * 使用刷新令牌获取新的访问令牌。     * @param clientSecrets GoogleClientSecrets对象     * @param refreshToken  存储的刷新令牌     * @return GoogleTokenResponse,包含新的访问令牌     * @throws IOException     */    public static GoogleTokenResponse refreshAccessToken(GoogleClientSecrets clientSecrets, String refreshToken) throws IOException {        return new GoogleCredential.Builder()                .setTransport(HTTP_TRANSPORT)                .setJsonFactory(JSON_FACTORY)                .setClientSecrets(clientSecrets)                .build()                .setRefreshToken(refreshToken)                .refreshTokenResponse();    }    // 实际应用中,您会把 refreshToken 存储到数据库,然后从数据库加载    // 每次需要调用API时,使用 refreshAccessToken 方法获取新的 access token    // 然后用新的 access token 构建 Gmail 服务客户端}

优点与限制

优点:适用于所有类型的Gmail账户,包括标准@gmail.com账户。一旦获得刷新令牌,后续访问无需用户再次干预。限制:首次授权时必须有用户交互,需要用户在浏览器中点击同意。这对于完全无人值守的后台服务来说是一个挑战,通常需要一个一次性的设置流程。

策略三:应用密码(App Passwords)- 备选方案

原理与适用场景

应用密码是针对开启了两步验证(2FA)的Gmail账户提供的一种特殊密码。它允许用户为特定的非浏览器应用(如邮件客户端、第三方设备)生成一个一次性密码,用于通过传统的SMTP/IMAP协议访问Gmail。

局限性与安全考量

并非Gmail API:应用密码是用于传统的邮件协议(SMTP/IMAP),而不是直接访问Gmail API。这意味着您无法使用Gmail API提供的更高级功能,如标签管理、邮件线程操作、批量发送优化等。安全风险:将应用密码硬编码或存储在应用程序中,存在一定的安全风险。如果应用程序或其存储被攻破,应用密码可能被滥用。管理复杂性:对于大量用户,管理每个用户的应用密码非常不便。不推荐用于自动化服务:通常不推荐将此方法用于大规模的、需要高级API功能的自动化REST服务。

选择合适的方案

在决定采用哪种Gmail API访问策略时,请根据您的具体业务场景和客户端类型进行权衡:

如果您的所有客户都使用Google Workspace域账户,并且您可以协调域管理员进行配置强烈推荐使用域范围委派。这是实现完全自动化、无需任何用户交互的最佳方案。如果您的客户包含标准Gmail账户,或者您无法进行域范围委派的配置选择OAuth 2.0刷新令牌机制。您需要设计一个首次授权流程,引导用户完成一次性授权,然后安全地存储并利用刷新令牌进行后续的无人值守访问。避免使用应用密码:除非您仅需要非常基础的邮件发送功能,且对安全性和可扩展性要求不高,否则不建议采用应用密码方案。

注意事项与最佳实践

安全性服务账户密钥:服务账户的JSON密钥文件是高度敏感的。切勿将其暴露在公共仓库中,或直接硬编码在代码中。应将其存储在安全的位置,并通过环境变量、密钥管理服务(如Google Secret Manager、HashiCorp

以上就是Java REST 服务中实现 Gmail API 无人值守访问的策略与实践的详细内容,更多请关注创想鸟其它相关文章!

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 chuangxiangniao@163.com 举报,一经查实,本站将立刻删除。
发布者:程序猿,转转请注明出处:https://www.chuangxiangniao.com/p/241549.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2025年11月4日 01:46:55
下一篇 2025年11月4日 01:49:10

相关推荐

  • Uniapp 中如何不拉伸不裁剪地展示图片?

    灵活展示图片:如何不拉伸不裁剪 在界面设计中,常常需要以原尺寸展示用户上传的图片。本文将介绍一种在 uniapp 框架中实现该功能的简单方法。 对于不同尺寸的图片,可以采用以下处理方式: 极端宽高比:撑满屏幕宽度或高度,再等比缩放居中。非极端宽高比:居中显示,若能撑满则撑满。 然而,如果需要不拉伸不…

    2025年12月24日
    400
  • 如何让小说网站控制台显示乱码,同时网页内容正常显示?

    如何在不影响用户界面的情况下实现控制台乱码? 当在小说网站上下载小说时,大家可能会遇到一个问题:网站上的文本在网页内正常显示,但是在控制台中却是乱码。如何实现此类操作,从而在不影响用户界面(UI)的情况下保持控制台乱码呢? 答案在于使用自定义字体。网站可以通过在服务器端配置自定义字体,并通过在客户端…

    2025年12月24日
    800
  • 如何在地图上轻松创建气泡信息框?

    地图上气泡信息框的巧妙生成 地图上气泡信息框是一种常用的交互功能,它简便易用,能够为用户提供额外信息。本文将探讨如何借助地图库的功能轻松创建这一功能。 利用地图库的原生功能 大多数地图库,如高德地图,都提供了现成的信息窗体和右键菜单功能。这些功能可以通过以下途径实现: 高德地图 JS API 参考文…

    2025年12月24日
    400
  • 如何使用 scroll-behavior 属性实现元素scrollLeft变化时的平滑动画?

    如何实现元素scrollleft变化时的平滑动画效果? 在许多网页应用中,滚动容器的水平滚动条(scrollleft)需要频繁使用。为了让滚动动作更加自然,你希望给scrollleft的变化添加动画效果。 解决方案:scroll-behavior 属性 要实现scrollleft变化时的平滑动画效果…

    2025年12月24日
    000
  • 如何为滚动元素添加平滑过渡,使滚动条滑动时更自然流畅?

    给滚动元素平滑过渡 如何在滚动条属性(scrollleft)发生改变时为元素添加平滑的过渡效果? 解决方案:scroll-behavior 属性 为滚动容器设置 scroll-behavior 属性可以实现平滑滚动。 html 代码: click the button to slide right!…

    2025年12月24日
    500
  • 如何选择元素个数不固定的指定类名子元素?

    灵活选择元素个数不固定的指定类名子元素 在网页布局中,有时需要选择特定类名的子元素,但这些元素的数量并不固定。例如,下面这段 html 代码中,activebar 和 item 元素的数量均不固定: *n *n 如果需要选择第一个 item元素,可以使用 css 选择器 :nth-child()。该…

    2025年12月24日
    200
  • 使用 SVG 如何实现自定义宽度、间距和半径的虚线边框?

    使用 svg 实现自定义虚线边框 如何实现一个具有自定义宽度、间距和半径的虚线边框是一个常见的前端开发问题。传统的解决方案通常涉及使用 border-image 引入切片图片,但是这种方法存在引入外部资源、性能低下的缺点。 为了避免上述问题,可以使用 svg(可缩放矢量图形)来创建纯代码实现。一种方…

    2025年12月24日
    100
  • 如何让“元素跟随文本高度,而不是撑高父容器?

    如何让 元素跟随文本高度,而不是撑高父容器 在页面布局中,经常遇到父容器高度被子元素撑开的问题。在图例所示的案例中,父容器被较高的图片撑开,而文本的高度没有被考虑。本问答将提供纯css解决方案,让图片跟随文本高度,确保父容器的高度不会被图片影响。 解决方法 为了解决这个问题,需要将图片从文档流中脱离…

    2025年12月24日
    000
  • 为什么 CSS mask 属性未请求指定图片?

    解决 css mask 属性未请求图片的问题 在使用 css mask 属性时,指定了图片地址,但网络面板显示未请求获取该图片,这可能是由于浏览器兼容性问题造成的。 问题 如下代码所示: 立即学习“前端免费学习笔记(深入)”; icon [data-icon=”cloud”] { –icon-cl…

    2025年12月24日
    200
  • 如何利用 CSS 选中激活标签并影响相邻元素的样式?

    如何利用 css 选中激活标签并影响相邻元素? 为了实现激活标签影响相邻元素的样式需求,可以通过 :has 选择器来实现。以下是如何具体操作: 对于激活标签相邻后的元素,可以在 css 中使用以下代码进行设置: li:has(+li.active) { border-radius: 0 0 10px…

    2025年12月24日
    100
  • 如何模拟Windows 10 设置界面中的鼠标悬浮放大效果?

    win10设置界面的鼠标移动显示周边的样式(探照灯效果)的实现方式 在windows设置界面的鼠标悬浮效果中,光标周围会显示一个放大区域。在前端开发中,可以通过多种方式实现类似的效果。 使用css 使用css的transform和box-shadow属性。通过将transform: scale(1.…

    2025年12月24日
    200
  • 为什么我的 Safari 自定义样式表在百度页面上失效了?

    为什么在 Safari 中自定义样式表未能正常工作? 在 Safari 的偏好设置中设置自定义样式表后,您对其进行测试却发现效果不同。在您自己的网页中,样式有效,而在百度页面中却失效。 造成这种情况的原因是,第一个访问的项目使用了文件协议,可以访问本地目录中的图片文件。而第二个访问的百度使用了 ht…

    2025年12月24日
    000
  • 如何用前端实现 Windows 10 设置界面的鼠标移动探照灯效果?

    如何在前端实现 Windows 10 设置界面中的鼠标移动探照灯效果 想要在前端开发中实现 Windows 10 设置界面中类似的鼠标移动探照灯效果,可以通过以下途径: CSS 解决方案 DEMO 1: Windows 10 网格悬停效果:https://codepen.io/tr4553r7/pe…

    2025年12月24日
    000
  • 使用CSS mask属性指定图片URL时,为什么浏览器无法加载图片?

    css mask属性未能加载图片的解决方法 使用css mask属性指定图片url时,如示例中所示: mask: url(“https://api.iconify.design/mdi:apple-icloud.svg”) center / contain no-repeat; 但是,在网络面板中却…

    2025年12月24日
    000
  • 如何用CSS Paint API为网页元素添加时尚的斑马线边框?

    为元素添加时尚的斑马线边框 在网页设计中,有时我们需要添加时尚的边框来提升元素的视觉效果。其中,斑马线边框是一种既醒目又别致的设计元素。 实现斜向斑马线边框 要实现斜向斑马线间隔圆环,我们可以使用css paint api。该api提供了强大的功能,可以让我们在元素上绘制复杂的图形。 立即学习“前端…

    2025年12月24日
    000
  • 图片如何不撑高父容器?

    如何让图片不撑高父容器? 当父容器包含不同高度的子元素时,父容器的高度通常会被最高元素撑开。如果你希望父容器的高度由文本内容撑开,避免图片对其产生影响,可以通过以下 css 解决方法: 绝对定位元素: .child-image { position: absolute; top: 0; left: …

    2025年12月24日
    000
  • CSS 帮助

    我正在尝试将文本附加到棕色框的左侧。我不能。我不知道代码有什么问题。请帮助我。 css .hero { position: relative; bottom: 80px; display: flex; justify-content: left; align-items: start; color:…

    2025年12月24日 好文分享
    200
  • 前端代码辅助工具:如何选择最可靠的AI工具?

    前端代码辅助工具:可靠性探讨 对于前端工程师来说,在HTML、CSS和JavaScript开发中借助AI工具是司空见惯的事情。然而,并非所有工具都能提供同等的可靠性。 个性化需求 关于哪个AI工具最可靠,这个问题没有一刀切的答案。每个人的使用习惯和项目需求各不相同。以下是一些影响选择的重要因素: 立…

    2025年12月24日
    300
  • 如何用 CSS Paint API 实现倾斜的斑马线间隔圆环?

    实现斑马线边框样式:探究 css paint api 本文将探究如何使用 css paint api 实现倾斜的斑马线间隔圆环。 问题: 给定一个有多个圆圈组成的斑马线图案,如何使用 css 实现倾斜的斑马线间隔圆环? 答案: 立即学习“前端免费学习笔记(深入)”; 使用 css paint api…

    2025年12月24日
    000
  • 如何使用CSS Paint API实现倾斜斑马线间隔圆环边框?

    css实现斑马线边框样式 想定制一个带有倾斜斑马线间隔圆环的边框?现在使用css paint api,定制任何样式都轻而易举。 css paint api 这是一个新的css特性,允许开发人员创建自定义形状和图案,其中包括斑马线样式。 立即学习“前端免费学习笔记(深入)”; 实现倾斜斑马线间隔圆环 …

    2025年12月24日
    100

发表回复

登录后才能评论
关注微信