【Grails4+spring security】

描述

本文档将实现单用户登录,实际效果是:当一个用户在一个地方登录了之后,另一个地方也用该用户登录,前一个登录被迫下线,每次登录都会用新的session替换旧的session。

1、新建项目目录结构如图所示

在这里插入图片描述

2、打开根目录下的build.gradle文件,dependencies中添加spring-security依赖

compile "org.grails.plugins:spring-security-core:4.0.0"

3、创建用户、角色的domain

也可用命令快速生成域类:

grails s2-quickstart com.system UserInfo RoleInfo

在这里插入图片描述

  • 3.1 用户(UserInfo)
package com.system

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString

@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class UserInfo implements Serializable {

    transient springSecurityService

    private static final long serialVersionUID = 1

    String username
    String password
    boolean enabled = true
    boolean accountExpired
    boolean accountLocked
    boolean passwordExpired
    String nickname

    Set<RoleInfo> getAuthorities() {
        (UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<RoleInfo>
    }

    static constraints = {
        password blank: false, password: true
        username blank: false, unique: true
        nickname nullable: true, maxSize: 15
    }

    static mapping = {
        password column: '`password`'
    }

    def beforeInsert() {
        encodePassword()
    }

    def beforeUpdate() {
        if (isDirty('password')) {
            encodePassword()
        }
    }

    protected void encodePassword() {
        password = springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
    }

}
  • 3.2 RoleInfo(角色)
package com.system

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString

@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class RoleInfo implements Serializable {

    private static final long serialVersionUID = 1

    String authority
    String remark

    static constraints = {
        authority blank: false, unique: true
        remark blank: false
    }

    static mapping = {
        cache true
    }

}
  • 3.3 用户-角色关联(UserRole)
package com.system

import grails.gorm.DetachedCriteria
import groovy.transform.ToString
import org.codehaus.groovy.util.HashCodeHelper

@ToString(cache=true, includeNames=true, includePackage=false)
class UserRole implements Serializable {

    private static final long serialVersionUID = 1

    UserInfo user
    RoleInfo role

    @Override
    boolean equals(other) {
        if (other instanceof UserRole) {
            other.userId == user?.id && other.roleId == role?.id
        }
    }

    @Override
    int hashCode() {
        int hashCode = HashCodeHelper.initHash()
        if (user) {
            hashCode = HashCodeHelper.updateHash(hashCode, user.id)
        }
        if (role) {
            hashCode = HashCodeHelper.updateHash(hashCode, role.id)
        }
        hashCode
    }

    static UserRole get(long userId, long roleId) {
        criteriaFor(userId, roleId).get()
    }

    static boolean exists(long userId, long roleId) {
        criteriaFor(userId, roleId).count()
    }

    private static DetachedCriteria criteriaFor(long userId, long roleId) {
        UserRole.where {
            user == UserInfo.load(userId) &&
                    role == RoleInfo.load(roleId)
        }
    }

    static UserRole create(UserInfo user, RoleInfo role, boolean flush = false) {
        def instance = new UserRole(user: user, role: role)
        instance.save(flush: flush)
        instance
    }

    static boolean remove(UserInfo u, RoleInfo r) {
        if (u != null && r != null) {
            UserRole.where { user == u && role == r }.deleteAll()
        }
    }

    static int removeAll(UserInfo u) {
        u == null ? 0 : UserRole.where { user == u }.deleteAll() as int
    }

    static int removeAll(RoleInfo r) {
        r == null ? 0 : UserRole.where { role == r }.deleteAll() as int
    }

    static constraints = {
        role validator: { RoleInfo r, UserRole ur ->
            if (ur.user?.id) {
                UserRole.withNewSession {
                    if (UserRole.exists(ur.user.id, r.id)) {
                        return ['userRole.exists']
                    }
                }
            }
        }
    }

    static mapping = {
        id composite: ['user', 'role']
        version false
    }
}

4、创建登录控制器LoginController

package com.system

import grails.converters.JSON
import grails.plugin.springsecurity.SpringSecurityUtils
import org.springframework.context.MessageSource
import org.springframework.security.access.annotation.Secured
import org.springframework.security.authentication.AccountExpiredException
import org.springframework.security.authentication.AuthenticationTrustResolver
import org.springframework.security.authentication.CredentialsExpiredException
import org.springframework.security.authentication.DisabledException
import org.springframework.security.authentication.LockedException
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.WebAttributes

import javax.servlet.http.HttpServletResponse

@Secured('permitAll')
class LoginController {

    /** 依赖注入认证接口authenticationTrustResolver. */
    AuthenticationTrustResolver authenticationTrustResolver

    /** 依赖注入springSecurityService. */
    def springSecurityService

    /** 依赖注入messageSource. */
    MessageSource messageSource

    /** 若登录成功,直接跳转到首页,否则跳转到auth页面登录 */
    def index() {

        if (springSecurityService.isLoggedIn()) {
            redirect uri: conf.successHandler.defaultTargetUrl
        }
        else {
            redirect action: 'auth', params: params
        }
    }

    /**登录页面*/
    def auth() {

        def conf = getConf()

        if (springSecurityService.isLoggedIn()) {
            redirect uri: conf.successHandler.defaultTargetUrl
            return
        }

        String postUrl = request.contextPath + conf.apf.filterProcessesUrl
        render view: 'auth', model: [postUrl: postUrl,
                                     rememberMeParameter: conf.rememberMe.parameter,
                                     usernameParameter: conf.apf.usernameParameter,
                                     passwordParameter: conf.apf.passwordParameter,
                                     gspLayout: conf.gsp.layoutAuth]
    }

    /** The redirect action for Ajax requests. */
    def authAjax() {
        response.setHeader 'Location', conf.auth.ajaxLoginFormUrl
        render(status: HttpServletResponse.SC_UNAUTHORIZED, text: 'Unauthorized')
    }

    /** 普通请求拒绝访问 */
    def denied() {
        if (springSecurityService.isLoggedIn() && authenticationTrustResolver.isRememberMe(authentication)) {
            // have cookie but the page is guarded with IS_AUTHENTICATED_FULLY (or the equivalent expression)
            redirect action: 'full', params: params
            return
        }

        [gspLayout: conf.gsp.layoutDenied]
    }

    /** Login page for users with a remember-me cookie but accessing a IS_AUTHENTICATED_FULLY page. */
    def full() {
        def conf = getConf()
        render view: 'auth', params: params,
                model: [hasCookie: authenticationTrustResolver.isRememberMe(authentication),
                        postUrl: request.contextPath + conf.apf.filterProcessesUrl,
                        rememberMeParameter: conf.rememberMe.parameter,
                        usernameParameter: conf.apf.usernameParameter,
                        passwordParameter: conf.apf.passwordParameter,
                        gspLayout: conf.gsp.layoutAuth]
    }

    /** ajax登录认证失败信息提示 */
    def authfail() {

        String msg = ''
        def exception = session[WebAttributes.AUTHENTICATION_EXCEPTION]
        if (exception) {
            if (exception instanceof AccountExpiredException) {
                msg = messageSource.getMessage('springSecurity.errors.login.expired', null, "Account Expired", request.locale)
            }
            else if (exception instanceof CredentialsExpiredException) {
                msg = messageSource.getMessage('springSecurity.errors.login.passwordExpired', null, "Password Expired", request.locale)
            }
            else if (exception instanceof DisabledException) {
                msg = messageSource.getMessage('springSecurity.errors.login.disabled', null, "Account Disabled", request.locale)
            }
            else if (exception instanceof LockedException) {
                msg = messageSource.getMessage('springSecurity.errors.login.locked', null, "Account Locked", request.locale)
            }
            else {
                msg = messageSource.getMessage('springSecurity.errors.login.fail', null, "Authentication Failure", request.locale)
            }
        }

        if (springSecurityService.isAjax(request)) {
            render([error: msg] as JSON)
        }
        else {
            flash.message = msg
            redirect action: 'auth', params: params
        }
    }

    /** ajax登录成功 */
    def ajaxSuccess() {
        render([success: true, username: authentication.name] as JSON)
    }

    /** ajaax拒绝访问 */
    def ajaxDenied() {
        render([error: 'access denied'] as JSON)
    }

    protected Authentication getAuthentication() {

        SecurityContextHolder.context?.authentication
    }

    protected ConfigObject getConf() {
        SpringSecurityUtils.securityConfig
    }

    /** 单用户登录(已登录返回给用户提示) */
    def already() {
        render view: "already"
    }
}

5、创建注销控制器 LogoutController

package com.system

import grails.plugin.springsecurity.SpringSecurityUtils
import org.springframework.security.access.annotation.Secured
import org.springframework.security.web.RedirectStrategy

@Secured('permitAll')
class LogoutController {

    /** 依赖注入RedirectStrategy. */
    RedirectStrategy redirectStrategy

    /**
     * 注销方法
     */
    def index() {

//        if (!request.post && SpringSecurityUtils.getSecurityConfig().logout.postOnly) {
//            response.sendError HttpServletResponse.SC_METHOD_NOT_ALLOWED // 405
//            return
//        }

        // TODO put any pre-logout code here
        redirectStrategy.sendRedirect request, response, SpringSecurityUtils.securityConfig.logout.filterProcessesUrl // '/logoff'
        response.flushBuffer()
    }
}

6、自定义一个ConcurrentSingleSessionAuthenticationStrategy类实现SessionAuthenticationStrategy接口覆盖默认方法

package com.session

import org.springframework.security.core.Authentication
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy
import org.springframework.util.Assert

import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * 会话管理类
 */
class ConcurrentSingleSessionAuthenticationStrategy implements SessionAuthenticationStrategy {

    private SessionRegistry sessionRegistry

    /**
     * @param 将新的会话赋值给sessionRegistry
     */
    public ConcurrentSingleSessionAuthenticationStrategy(SessionRegistry sessionRegistry) {
        Assert.notNull(sessionRegistry, "SessionRegistry cannot be null")
        this.sessionRegistry = sessionRegistry
    }
    /**
     * 覆盖父类的onAuthentication方法
     * 用新的session替换就的session
     */
    public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {

        def sessions = sessionRegistry.getAllSessions(authentication.getPrincipal(), false)
        def principals = sessionRegistry.getAllPrincipals()
        sessions.each {
            if (it.principal == authentication.getPrincipal()) {
                it.expireNow()
            }
        }


    }
}

(注:此类我是在src/main/groovy里面创建的,你也可以在其他地方创建)

7、打开grails-app/conf/spring/resource.groovy,配置DSL

在这里插入图片描述

import com.session.ConcurrentSingleSessionAuthenticationStrategy
import org.springframework.security.core.session.SessionRegistryImpl
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy
import org.springframework.security.web.session.ConcurrentSessionFilter

// Place your Spring DSL code here
beans = {

    sessionRegistry(SessionRegistryImpl)
    //很重要
    sessionFixationProtectionStrategy(SessionFixationProtectionStrategy){
        migrateSessionAttributes = true
        alwaysCreateSession = true
    }
    // "/login/already"为重定向请求
    concurrentSingleSessionAuthenticationStrategy(ConcurrentSingleSessionAuthenticationStrategy,ref('sessionRegistry'))
    registerSessionAuthenticationStrategy(RegisterSessionAuthenticationStrategy,ref('sessionRegistry'))
    sessionAuthenticationStrategy(CompositeSessionAuthenticationStrategy,[ref('concurrentSingleSessionAuthenticationStrategy'), ref('sessionFixationProtectionStrategy'), ref('registerSessionAuthenticationStrategy')])
    concurrentSessionFilter(ConcurrentSessionFilter, ref('sessionRegistry'), "/login/already")
}

8、在grails-app/conf目录下创建application.groovy类

在这里插入图片描述

//grails.plugin.springsecurity.successHandler.alwaysUseDefault = true
//grails.plugin.springsecurity.successHandler.defaultTargetUrl = '/your-url' //登录成功后跳转地址
grails.plugin.springsecurity.userLookup.usernamePropertyName ="username"
grails.plugin.springsecurity.userLookup.passwordPropertyName ="password"
grails.plugin.springsecurity.authority.className="com.system.RoleInfo"
grails.plugin.springsecurity.userLookup.userDomainClassName="com.system.UserInfo"
grails.plugin.springsecurity.userLookup.authorityJoinClassName="com.system.UserRole"
grails.plugin.springsecurity.controllerAnnotations.staticRules = [
        [pattern: '/',                 access: ['permitAll']],
        [pattern: '/error',            access: ['permitAll']],
        [pattern: '/index',            access: ['permitAll']],
        [pattern: '/index.gsp',        access: ['permitAll']],
        [pattern: '/shutdown',         access: ['permitAll']],
        [pattern: '/assets/**',        access: ['permitAll']],
        [pattern: '/**/js/**',         access: ['permitAll']],
        [pattern: '/**/css/**',        access: ['permitAll']],
        [pattern: '/**/images/**',     access: ['permitAll']],
        [pattern: '/**/favicon.ico',   access: ['permitAll']],
        [pattern: '/login/already.gsp',access: ['permitAll']],

        [pattern: '/user/**',        access: 'ROLE_USER'],
        [pattern: '/admin/**',       access: ['ROLE_ADMIN', 'isFullyAuthenticated()']]
]
grails.plugin.springsecurity.interceptUrlMap = [
        [pattern: '/',               access: ['permitAll']],
        [pattern: '/error',          access: ['permitAll']],
        [pattern: '/index',          access: ['permitAll']],
        [pattern: '/index.gsp',      access: ['permitAll']],
        [pattern: '/shutdown',       access: ['permitAll']],
        [pattern: '/assets/**',      access: ['permitAll']],
        [pattern: '/**/js/**',       access: ['permitAll']],
        [pattern: '/**/css/**',      access: ['permitAll']],
        [pattern: '/**/images/**',   access: ['permitAll']],
        [pattern: '/**/favicon.ico', access: ['permitAll']],
        [pattern: '/login/**',       access: ['permitAll']],
        [pattern: '/login/already',  access: ['permitAll']],
        [pattern: '/logout/**',      access: ['permitAll']]
]

grails.plugin.springsecurity.filterChain.filterNames = [ 'securityContextPersistenceFilter', 'logoutFilter', 'concurrentSessionFilter', 'rememberMeAuthenticationFilter', 'anonymousAuthenticationFilter', 'exceptionTranslationFilter', 'filterInvocationInterceptor' ]

9、在grails-app/conf目录下创建application.yml类

在这里插入图片描述

---
grails:
    profile: web
    codegen:
        defaultPackage: longiweb
    gorm:
        reactor:
            # Whether to translate GORM events into Reactor events
            # Disabled by default for performance reasons
            events: false
    controllers:
        upload:
            maxFileSize: 2000000000
            maxRequestSize: 2000000000
myParams:
    name: 'MrWt'
    age: '23'
    post: 'PM'
    NxLgg:
        appid: 'wx171071cfc94cb83a'
        appsecret: 'e86e2e3d51facec23vr27269b6d79bc6'
        merchantid: '1528776352'
        merchantkey: '0D0tsOh0wFyjvAppL06idNE29hedecac'
    RemoveIMEIPlat: '142,143,165,166,241,242,243,342,343'
    logFilePath: 'd:\\log'
    meterType: '1'
    unreported: '0'
    # temporaryTask / unreported: 1为开启,0为关闭
    temporaryTask: '0'
    maxTime: 20

info:
    app:
        name: '@info.app.name@'
        version: '@info.app.version@'
        grailsVersion: '@info.app.grailsVersion@'
spring:
    jmx:
        unique-names: true
    main:
        banner-mode: "off"
    groovy:
        template:
            check-template-location: false
    devtools:
        restart:
            additional-exclude:
                - '*.gsp'
                - '**/*.gsp'
                - '*.gson'
                - '**/*.gson'
                - 'logback.groovy'
                - '*.properties'
management:
    endpoints:
        enabled-by-default: false
---
grails:
    mime:
        disable:
            accept:
                header:
                    userAgents:
                        - Gecko
                        - WebKit
                        - Presto
                        - Trident
        types:
            all: '*/*'
            atom: application/atom+xml
            css: text/css
            csv: text/csv
            form: application/x-www-form-urlencoded
            html:
                - text/html
                - application/xhtml+xml
            js: text/javascript
            json:
                - application/json
                - text/json
            multipartForm: multipart/form-data
            pdf: application/pdf
            rss: application/rss+xml
            text: text/plain
            hal:
                - application/hal+json
                - application/hal+xml
            xml:
                - text/xml
                - application/xml
    urlmapping:
        cache:
            maxsize: 1000
    controllers:
        defaultScope: singleton
    converters:
        encoding: UTF-8
    views:
        default:
            codec: html
        gsp:
            encoding: UTF-8
            htmlcodec: xml
            codecs:
                expression: none
                scriptlet: html
                taglib: none
                staticparts: none
    resources:
        pattern:'/**'
management:
    endpoints:
        jmx:
            unique-names: true
---
hibernate:
    cache:
        queries: false
        use_second_level_cache: false
        use_query_cache: false
dataSource:
    pooled: true
    jmxExport: true
    driverClassName: org.h2.Driver
    username: sa
    password: ''
#redis:
#    poolConfig:
#        maxIdle: 10
#        doesnotexist: true
#    host: 192.168.0.86
#    port: 6379
#    timeout: 5000
#    password: ''
#    jedis:
#        pool:
#            # 最大空闲连接数
#            max-idle: 500
#            # 最小空闲连接数
#            min-idle: 50
#            # 等待可用连接的最大时间,负数为不限制
#            max-wait: -1
#            # 最大活跃连接数,负数为不限制
#            max-active: -1

environments:
#    开发库连接
    development:
        dataSource:
            bySearch:
                testWhileIdle: true
                validationQuery: SELECT 1
                timeBestweenEvictionRunsMillis: 3600000 #每个小时确认连接是否可用
            dbCreate: none
            driverClassName: com.mysql.cj.jdbc.Driver
            username: root
            password: MrWt5678
            url: jdbc:mysql://192.168.0.81:3306/longistation?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false&tinyInt1isBit=false
            properties:
                jmxEnabled: true
                initialSize: 10
                maxActive: 50
                maxIdle: 20
                minIdle: 10
                maxWait: 30000
                removeAbandoned: true
                removeAbandonedTimeout: 60000
                logAbandoned: false
                testOnBorrow: false
                testOnReturn: false
                testWhileIdle: true
                validationQuery: select 1
                #validationQueryTimeout: 5
                timeBetweenEvictionRunsMillis: 3600000
                minEvictableIdleTimeMillis: 3600000
                numTestsPerEvictionRun: 100
                jdbcInterceptors: ConnectionState
                defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED
#   测试库连接
    test:
        dataSource:
            dbCreate: none
            driverClassName: com.mysql.cj.jdbc.Driver
            username: root
            password: 123456
            url: jdbc:mysql://192.168.0.173:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&tinyInt1isBit=false
# 生产库链接,tomcat发布版
    production:
        dataSource:
            dbCreate: none
            driverClassName: com.mysql.cj.jdbc.Driver
            username: longi
            password:MrWt5678
            url: jdbc:mysql://192.168.0.8:3306/longistation?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false&tinyInt1isBit=false
            properties:
                jmxEnabled: true
                initialSize: 10
                maxActive: 50  #最大连接数
                maxIdle: 20   #最大空闲连接
                minIdle: 10   #最小空闲连接
                maxWait: 30000   #超时等待时间以毫秒为单位
                removeAbandoned: true    # 当active连接快到maxActive连接的时候,是否回收无效的连接回收
                removeAbandonedTimeout: 60000  # 超时时间(以秒数为单位)
                logAbandoned: false      # 是否在log中打印出回收Connection的错误信息,包括在哪个地方用了Connection却忘记关闭了
                testOnBorrow: false
                testOnReturn: false
                testWhileIdle: true
                validationQuery: select 1
                #validationQueryTimeout: 5
                timeBetweenEvictionRunsMillis: 3600000 # 连接空闲了多久会被空闲连接回收器回收
                minEvictableIdleTimeMillis: 3600000    # 每timeBetweenEvictionRunsMills运行一次空闲连接回收器(独立线程)
                numTestsPerEvictionRun: 50            # 每次检查numTestsPerEvictionRun个连接
                jdbcInterceptors: ConnectionState
                defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED
        dataSources:
            mysqlTest:
                dbCreate: none
                dialect: org.hibernate.dialect.MySQLInnoDBDialect
                driverClassName: com.mysql.cj.jdbc.Driver
                username: root
                password: MrWt123456
                url: jdbc:mysql://192.168.0.8:3306/mysql?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false
                properties:
                    jmxEnabled: true
                    initialSize: 10
                    maxActive: 50
                    maxIdle: 20
                    minIdle: 10
                    maxWait: 30000
                    removeAbandoned: true
                    removeAbandonedTimeout: 60000
                    logAbandoned: false
                    testOnBorrow: false
                    testOnReturn: false
                    testWhileIdle: true
                    validationQuery: select 1
                    timeBetweenEvictionRunsMillis: 3600000
                    minEvictableIdleTimeMillis: 3600000
                    numTestsPerEvictionRun: 100
                    jdbcInterceptors: ConnectionState
                    defaultTransactionIsolation: 2
            oracleTest:
                dbCreate: none
                dialect: org.hibernate.dialect.OracleDialect
                driverClassName: oracle.jdbc.driver.OracleDriver
                username: lggmrhot
                password: MrWt6666
                url: jdbc:oracle:thin:@192.168.0.89:1521:orcl
            sqlServerTest:
                dbCreate: none
                pooled: true
                jmxExport: true
                driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
                username: oa
                password: MrWt5678
                url: jdbc:sqlserver://192.168.0.3:1433;database=zkteco_database;
            sqlServerTest43:
                dbCreate: none
                pooled: true
                jmxExport: true
                driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
                username: oa
                password: MrWt5678
                url: jdbc:sqlserver://192.168.0.3:1433;database=STCard;
            sqlServerTest14:
                dbCreate: none
                pooled: true
                jmxExport: true
                driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
                username: u8
                password: MrWt5678
                url: jdbc:sqlserver://192.168.0.1:1433;database=UFDATA_333_2017;

10、打开grails-app/init/BootStrap.groovy

在这里插入图片描述

  • 10.1 保存用户、角色、用户-角色信息
import com.system.RoleInfo
import com.system.UserInfo
import com.system.UserRole

class BootStrap {

    def init = { servletContext ->

        //创建角色
        def role1 = new RoleInfo(authority: "ROLE_ADMIN", remark: "管理员").save()
        def role2 = new RoleInfo(authority: "ROLE_SUPSYS", remark: "超级管理员").save()
        def role3 = new RoleInfo(authority: "ROLE_USER", remark: "普通用户").save()

        //创建用户
        def user1 = new UserInfo(username: "admin", password: "admin").save()
        def user2 = new UserInfo(username: "super", password: "super").save()
        def user3 = new UserInfo(username: "user", password: "user").save()

        //用户角色关联
        UserRole.create user1, role1, true
        UserRole.create user2, role2, true
        UserRole.create user3, role3, true

    }

    def destroy = {
    }
}

最后到这里就完成了,可以启动项目进行测试了。快去创建自己的controller和gsp吧。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值