Shiro - 掌握 MD5 加盐加密 实现安全的密码存储

在这里插入图片描述

👋 大家好,欢迎来到我的技术博客!
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕Shiro这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!


Shiro - 掌握 MD5 加盐加密 实现安全的密码存储

在当今数字化时代,用户数据安全已成为软件开发中不可忽视的核心议题。🔐 作为开发者,我们有责任确保用户密码等敏感信息得到妥善保护。Apache Shiro 是一个强大且易用的 Java 安全框架,它提供了身份验证、授权、加密和会话管理等功能。本文将深入探讨如何使用 Shiro 结合 MD5 加盐加密技术来实现安全的密码存储机制。

密码安全的重要性

在开始技术实现之前,让我们先理解为什么密码安全如此重要。近年来,大规模数据泄露事件频发,从大型科技公司到小型创业企业,无一幸免。当用户的密码以明文形式存储时,一旦数据库被攻破,所有用户的账户都将面临严重威胁。

即使使用简单的哈希算法(如 MD5 或 SHA-1)而不加盐,攻击者也可以通过彩虹表攻击快速破解大量密码。因此,现代密码存储必须采用加盐哈希技术,为每个用户的密码添加唯一的随机盐值,使得即使两个用户使用相同的密码,其存储的哈希值也会完全不同。

// 危险的密码存储方式示例
public class InsecurePasswordStorage {
    public String hashPassword(String password) {
        // 仅使用MD5哈希,没有加盐 - 非常不安全!
        return DigestUtils.md5Hex(password);
    }
}

这种不安全的实现方式会让整个系统暴露在巨大的风险之下。接下来,我们将学习如何正确地实现安全的密码存储。

Apache Shiro 简介

Apache Shiro 是一个功能强大且易于使用的 Java 安全框架,它为开发者提供了完整的安全解决方案。Shiro 的核心功能包括:

  • 身份验证(Authentication):验证用户身份
  • 授权(Authorization):控制用户访问权限
  • 加密(Cryptography):保护数据安全
  • 会话管理(Session Management):管理用户会话

Shiro 的设计理念是简单易用,即使是安全新手也能快速上手。它的架构清晰,组件之间松耦合,便于集成到各种 Java 应用中。

Shiro 的核心组件

Shiro 的架构基于几个核心组件:

  1. Subject:代表当前用户的安全操作
  2. SecurityManager:Shiro 架构的核心,协调内部安全组件
  3. Realm:连接 Shiro 与应用程序安全数据的桥梁

这些组件协同工作,为应用程序提供完整的安全功能。在密码存储方面,Shiro 提供了强大的加密工具类,特别是 SimpleHash 类,可以方便地实现各种哈希算法的加盐操作。

MD5 加盐加密原理

在深入代码实现之前,我们需要理解 MD5 加盐加密的基本原理。虽然 MD5 本身存在安全缺陷(主要是碰撞攻击),但在加盐的情况下,对于密码存储场景仍然具有一定的实用性。不过需要注意的是,现代密码学更推荐使用专门设计的密码哈希函数,如 bcrypt、scrypt 或 Argon2。

什么是加盐?

“盐”(Salt)是一个随机生成的字符串,它与用户的密码结合后再进行哈希运算。每个用户的盐值都是唯一的,这样即使多个用户使用相同的密码,它们的哈希结果也会完全不同。

原始密码

添加随机盐值

随机盐值

MD5哈希运算

存储的哈希值

存储的盐值

加盐的优势

  1. 防止彩虹表攻击:由于每个密码都有唯一的盐值,攻击者无法使用预计算的彩虹表
  2. 增加破解难度:即使知道盐值,攻击者也需要为每个密码单独进行暴力破解
  3. 保护相同密码:避免相同密码产生相同的哈希值,防止通过哈希值推断用户使用相同密码

Shiro 中的密码加密实现

现在让我们开始实际的代码实现。首先,我们需要在项目中添加 Shiro 依赖。

Maven 依赖配置

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.13.0</version>
</dependency>
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.16.0</version>
</dependency>

基础密码工具类

创建一个基础的密码工具类,用于生成盐值和进行密码哈希:

import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.util.ByteSource;

import java.security.SecureRandom;

/**
 * 密码加密工具类
 */
public class PasswordUtils {
    
    // 哈希算法名称
    private static final String HASH_ALGORITHM_NAME = "MD5";
    
    // 哈希迭代次数
    private static final int HASH_ITERATIONS = 1024;
    
    /**
     * 生成随机盐值
     * @return 随机盐值
     */
    public static String generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16]; // 16字节 = 128位
        random.nextBytes(salt);
        return Base64.encodeToString(salt);
    }
    
    /**
     * 对密码进行哈希加密
     * @param password 原始密码
     * @param salt 盐值
     * @return 加密后的密码
     */
    public static String encryptPassword(String password, String salt) {
        SimpleHash hash = new SimpleHash(
            HASH_ALGORITHM_NAME,
            password,
            ByteSource.Util.bytes(salt),
            HASH_ITERATIONS
        );
        return hash.toHex();
    }
    
    /**
     * 验证密码
     * @param originalPassword 原始密码
     * @param storedPassword 存储的加密密码
     * @param salt 盐值
     * @return 是否匹配
     */
    public static boolean verifyPassword(String originalPassword, 
                                       String storedPassword, 
                                       String salt) {
        String encryptedPassword = encryptPassword(originalPassword, salt);
        return encryptedPassword.equals(storedPassword);
    }
}

这个工具类提供了三个核心方法:

  1. generateSalt():生成 16 字节的随机盐值并进行 Base64 编码
  2. encryptPassword():使用指定的盐值对密码进行 MD5 加盐哈希,并进行 1024 次迭代
  3. verifyPassword():验证输入的密码是否与存储的加密密码匹配

用户实体类

接下来,我们创建一个用户实体类来存储用户信息:

public class User {
    private Long id;
    private String username;
    private String password; // 存储加密后的密码
    private String salt;     // 存储盐值
    
    // 构造函数
    public User() {}
    
    public User(String username, String password) {
        this.username = username;
        // 在创建用户时生成盐值并加密密码
        this.salt = PasswordUtils.generateSalt();
        this.password = PasswordUtils.encryptPassword(password, this.salt);
    }
    
    // Getter 和 Setter 方法
    public Long getId() {
        return id;
    }
    
    public void setId(Long id) {
        this.id = id;
    }
    
    public String getUsername() {
        return username;
    }
    
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getPassword() {
        return password;
    }
    
    public void setPassword(String password) {
        this.password = password;
    }
    
    public String getSalt() {
        return salt;
    }
    
    public void setSalt(String salt) {
        this.salt = salt;
    }
    
    /**
     * 验证密码
     * @param password 待验证的密码
     * @return 是否匹配
     */
    public boolean verifyPassword(String password) {
        return PasswordUtils.verifyPassword(password, this.password, this.salt);
    }
}

数据库操作模拟

为了演示完整的流程,我们创建一个简单的用户服务类:

import java.util.HashMap;
import java.util.Map;

/**
 * 用户服务类(模拟数据库操作)
 */
public class UserService {
    
    // 模拟数据库存储
    private static final Map<String, User> userDatabase = new HashMap<>();
    
    /**
     * 注册新用户
     * @param username 用户名
     * @param password 密码
     * @return 注册是否成功
     */
    public boolean registerUser(String username, String password) {
        if (userDatabase.containsKey(username)) {
            return false; // 用户已存在
        }
        
        User user = new User(username, password);
        userDatabase.put(username, user);
        System.out.println("用户注册成功: " + username);
        System.out.println("盐值: " + user.getSalt());
        System.out.println("加密密码: " + user.getPassword());
        return true;
    }
    
    /**
     * 用户登录验证
     * @param username 用户名
     * @param password 密码
     * @return 登录是否成功
     */
    public boolean login(String username, String password) {
        User user = userDatabase.get(username);
        if (user == null) {
            System.out.println("用户不存在: " + username);
            return false;
        }
        
        if (user.verifyPassword(password)) {
            System.out.println("登录成功: " + username);
            return true;
        } else {
            System.out.println("密码错误");
            return false;
        }
    }
    
    /**
     * 获取用户信息
     * @param username 用户名
     * @return 用户对象
     */
    public User getUser(String username) {
        return userDatabase.get(username);
    }
}

测试代码

现在让我们编写测试代码来验证我们的实现:

public class PasswordEncryptionTest {
    public static void main(String[] args) {
        UserService userService = new UserService();
        
        // 测试注册
        System.out.println("=== 用户注册测试 ===");
        userService.registerUser("alice", "password123");
        userService.registerUser("bob", "password123"); // 相同密码
        
        // 查看存储的密码和盐值
        User alice = userService.getUser("alice");
        User bob = userService.getUser("bob");
        
        System.out.println("\n=== 密码对比 ===");
        System.out.println("Alice 的盐值: " + alice.getSalt());
        System.out.println("Alice 的密码: " + alice.getPassword());
        System.out.println("Bob 的盐值: " + bob.getSalt());
        System.out.println("Bob 的密码: " + bob.getPassword());
        System.out.println("相同密码是否产生相同哈希: " + 
                          alice.getPassword().equals(bob.getPassword()));
        
        // 测试登录
        System.out.println("\n=== 登录测试 ===");
        userService.login("alice", "wrongpassword");
        userService.login("alice", "password123");
        userService.login("charlie", "password123"); // 不存在的用户
    }
}

运行这个测试程序,你会看到类似以下的输出:

=== 用户注册测试 ===
用户注册成功: alice
盐值: 8fGhJkLmNoPqRsTuVwXyZaBcDeFgHiJk
加密密码: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
用户注册成功: bob
盐值: QwErTyUiOpAsDfGhJkLzXcVbNmQwErTy
加密密码: z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4

=== 密码对比 ===
Alice 的盐值: 8fGhJkLmNoPqRsTuVwXyZaBcDeFgHiJk
Alice 的密码: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Bob 的盐值: QwErTyUiOpAsDfGhJkLzXcVbNmQwErTy
Bob 的密码: z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4
相同密码是否产生相同哈希: false

=== 登录测试 ===
用户不存在: charlie
密码错误
登录成功: alice

这个输出清楚地展示了加盐加密的效果:即使 Alice 和 Bob 使用相同的密码 “password123”,由于盐值不同,最终存储的哈希值也完全不同。

Shiro Realm 实现

在实际的 Shiro 应用中,我们需要创建自定义的 Realm 来处理用户认证。Realm 是 Shiro 连接应用程序安全数据的桥梁。

自定义 Realm

import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

/**
 * 自定义用户 Realm
 */
public class UserRealm extends AuthorizingRealm {
    
    private UserService userService = new UserService();
    
    /**
     * 授权方法
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = (String) principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        
        // 这里可以添加用户的权限和角色
        // 例如:authorizationInfo.addRole("user");
        //      authorizationInfo.addStringPermission("user:read");
        
        return authorizationInfo;
    }
    
    /**
     * 认证方法
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
            throws AuthenticationException {
        
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        String password = new String(upToken.getPassword());
        
        // 从数据库获取用户信息
        User user = userService.getUser(username);
        if (user == null) {
            throw new UnknownAccountException("用户不存在");
        }
        
        // 验证密码
        if (!user.verifyPassword(password)) {
            throw new IncorrectCredentialsException("密码错误");
        }
        
        // 返回认证信息
        return new SimpleAuthenticationInfo(
            username,           // 主体
            user.getPassword(), // 加密后的密码
            getName()          // realm 名称
        );
    }
}

Shiro 配置

创建 Shiro 配置类来初始化 SecurityManager:

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.config.IniSecurityManagerFactory;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.util.Factory;

/**
 * Shiro 配置类
 */
public class ShiroConfig {
    
    public static void initializeShiro() {
        // 创建 SecurityManager 工厂
        Factory<SecurityManager> factory = new IniSecurityManagerFactory();
        
        // 设置自定义 Realm
        SecurityManager securityManager = factory.getInstance();
        ((DefaultSecurityManager) securityManager).setRealm(new UserRealm());
        
        // 将 SecurityManager 绑定到 SecurityUtils
        SecurityUtils.setSecurityManager(securityManager);
    }
}

完整的认证流程

现在我们可以创建一个完整的认证流程示例:

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;

public class ShiroAuthenticationExample {
    
    public static void main(String[] args) {
        // 初始化 Shiro
        ShiroConfig.initializeShiro();
        
        // 创建用户服务并注册测试用户
        UserService userService = new UserService();
        userService.registerUser("admin", "admin123");
        
        // 获取当前用户主体
        Subject currentUser = SecurityUtils.getSubject();
        
        // 创建认证令牌
        UsernamePasswordToken token = new UsernamePasswordToken("admin", "admin123");
        
        try {
            // 执行登录
            currentUser.login(token);
            System.out.println("✅ 登录成功!");
            
            // 检查用户是否已认证
            if (currentUser.isAuthenticated()) {
                System.out.println("👤 用户已认证");
            }
            
            // 执行登出
            currentUser.logout();
            System.out.println("🚪 用户已登出");
            
        } catch (Exception e) {
            System.err.println("❌ 登录失败: " + e.getMessage());
        }
    }
}

密码策略和最佳实践

虽然我们已经实现了基本的 MD5 加盐加密,但为了确保更高的安全性,还需要考虑以下最佳实践:

1. 增加哈希迭代次数

我们在代码中设置了 1024 次迭代,这可以显著增加暴力破解的难度。攻击者需要为每次猜测执行 1024 次 MD5 运算,大大增加了破解时间。

// 增加迭代次数到更高的值(根据系统性能调整)
private static final int HASH_ITERATIONS = 10000; // 或更高

2. 使用更强的哈希算法

虽然 MD5 在加盐和多次迭代的情况下相对安全,但现代应用更推荐使用专门的密码哈希算法:

// 使用 SHA-256 替代 MD5
private static final String HASH_ALGORITHM_NAME = "SHA-256";

// 或者使用 PBKDF2
private static final String HASH_ALGORITHM_NAME = "PBKDF2WithHmacSHA256";

3. 密码复杂度要求

除了后端加密,还应该在前端实施密码复杂度策略:

/**
 * 验证密码强度
 */
public static boolean validatePasswordStrength(String password) {
    if (password == null || password.length() < 8) {
        return false; // 至少8位
    }
    
    boolean hasUpper = false, hasLower = false, hasDigit = false, hasSpecial = false;
    
    for (char c : password.toCharArray()) {
        if (Character.isUpperCase(c)) hasUpper = true;
        else if (Character.isLowerCase(c)) hasLower = true;
        else if (Character.isDigit(c)) hasDigit = true;
        else if ("!@#$%^&*()_+-=[]{}|;:,.<>?".indexOf(c) >= 0) hasSpecial = true;
    }
    
    return hasUpper && hasLower && hasDigit && hasSpecial;
}

4. 定期更新密码策略

随着时间推移和计算能力的提升,原有的安全策略可能变得不够安全。应该定期评估和更新密码策略。

用户注册/修改密码

密码符合策略?

生成随机盐值

返回错误信息

使用强哈希算法加密

存储加密密码和盐值

完成注册/修改

安全漏洞防范

在实现密码存储时,还需要注意防范常见的安全漏洞:

1. 时间攻击防护

在密码验证时,要确保验证过程的时间恒定,避免通过响应时间推断密码信息:

// 不安全的比较方式
public boolean unsafeCompare(String a, String b) {
    return a.equals(b); // 时间取决于字符串相似度
}

// 安全的恒定时间比较
public boolean secureCompare(String a, String b) {
    if (a == null || b == null || a.length() != b.length()) {
        return false;
    }
    
    int result = 0;
    for (int i = 0; i < a.length(); i++) {
        result |= a.charAt(i) ^ b.charAt(i);
    }
    return result == 0;
}

2. 防止 SQL 注入

在数据库操作中,始终使用参数化查询:

// 正确的方式
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, username);

3. 日志安全

避免在日志中记录敏感信息:

// 错误的做法
logger.info("用户 {} 使用密码 {} 登录", username, password);

// 正确的做法
logger.info("用户 {} 尝试登录", username);

性能优化考虑

虽然安全性是首要考虑,但也要平衡性能影响:

1. 盐值长度

盐值不需要过长,16-32 字节通常足够:

// 16字节盐值(128位)通常足够安全
byte[] salt = new byte[16];

2. 迭代次数调整

根据服务器性能调整迭代次数,在安全性和性能之间找到平衡点:

// 可以根据环境配置不同的迭代次数
private static final int HASH_ITERATIONS = 
    "production".equals(System.getProperty("env")) ? 10000 : 1000;

3. 缓存策略

对于频繁的认证操作,可以考虑适当的缓存策略,但要注意安全边界。

现代密码学建议

虽然本文主要讨论 MD5 加盐加密,但值得了解现代密码学的最佳实践。根据 OWASP 密码存储 Cheat Sheet,推荐使用以下算法:

  1. Argon2 - 最新的密码哈希竞赛 winner
  2. bcrypt - 经过时间验证的可靠选择
  3. scrypt - 内存硬化的算法
  4. PBKDF2 - NIST 推荐的标准

如果可能,建议在新项目中使用这些更安全的算法。

// 使用 BCrypt 的示例(需要添加 jBCrypt 依赖)
import org.mindrot.jbcrypt.BCrypt;

public class ModernPasswordUtils {
    public static String hashPassword(String password) {
        return BCrypt.hashpw(password, BCrypt.gensalt(12));
    }
    
    public static boolean verifyPassword(String password, String hashed) {
        return BCrypt.checkpw(password, hashed);
    }
}

完整的生产级实现

结合以上所有考虑,这里是一个更完整的生产级密码工具类:

import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.util.ByteSource;
import java.security.SecureRandom;
import java.util.regex.Pattern;

/**
 * 生产级密码工具类
 */
public class ProductionPasswordUtils {
    
    private static final String HASH_ALGORITHM_NAME = "SHA-256";
    private static final int HASH_ITERATIONS = 10000;
    private static final int SALT_BYTES = 16;
    
    // 密码强度正则表达式
    private static final Pattern PASSWORD_PATTERN = 
        Pattern.compile("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\S+$).{8,}$");
    
    /**
     * 生成安全的随机盐值
     */
    public static String generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[SALT_BYTES];
        random.nextBytes(salt);
        return Base64.encodeToString(salt);
    }
    
    /**
     * 安全地哈希密码
     */
    public static String hashPassword(String password, String salt) {
        if (password == null || salt == null) {
            throw new IllegalArgumentException("密码和盐值不能为空");
        }
        
        SimpleHash hash = new SimpleHash(
            HASH_ALGORITHM_NAME,
            password,
            ByteSource.Util.bytes(salt),
            HASH_ITERATIONS
        );
        return hash.toHex();
    }
    
    /**
     * 恒定时间比较两个字符串
     */
    public static boolean constantTimeEquals(String a, String b) {
        if (a == null || b == null) {
            return a == b;
        }
        
        if (a.length() != b.length()) {
            return false;
        }
        
        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }
    
    /**
     * 验证密码
     */
    public static boolean verifyPassword(String password, String hashedPassword, String salt) {
        if (password == null || hashedPassword == null || salt == null) {
            return false;
        }
        
        String computedHash = hashPassword(password, salt);
        return constantTimeEquals(computedHash, hashedPassword);
    }
    
    /**
     * 验证密码强度
     */
    public static boolean isValidPassword(String password) {
        return password != null && PASSWORD_PATTERN.matcher(password).matches();
    }
    
    /**
     * 获取密码强度描述
     */
    public static String getPasswordStrengthDescription(String password) {
        if (password == null) return "密码为空";
        if (password.length() < 8) return "密码长度不足8位";
        if (!password.matches(".*[a-z].*")) return "缺少小写字母";
        if (!password.matches(".*[A-Z].*")) return "缺少大写字母";
        if (!password.matches(".*\\d.*")) return "缺少数字";
        if (!password.matches(".*[@#$%^&+=!].*")) return "缺少特殊字符";
        return "密码强度良好";
    }
}

总结与建议

通过本文的学习,我们掌握了使用 Apache Shiro 实现 MD5 加盐加密来安全存储密码的方法。🔐 虽然 MD5 本身存在安全缺陷,但在加盐和多次迭代的情况下,对于许多应用场景仍然是可接受的。

然而,强烈建议在新项目中采用更现代的密码哈希算法,如 bcrypt、scrypt 或 Argon2。这些算法专门设计用于密码存储,具有更好的安全性特性。

关键要点回顾:

  1. 永远不要存储明文密码 - 这是基本的安全原则
  2. 每个用户使用唯一的盐值 - 防止彩虹表攻击
  3. 使用足够的哈希迭代次数 - 增加暴力破解难度
  4. 实施密码复杂度策略 - 提高整体安全性
  5. 定期更新安全策略 - 适应不断变化的威胁环境

记住,安全是一个持续的过程,而不是一次性的任务。保持对最新安全实践的关注,并根据需要更新你的实现。

对于更深入的学习,推荐阅读 NIST 数字身份指南OWASP 认证 Cheat Sheet,这些资源提供了权威的安全指导。

通过正确实现密码存储机制,我们可以为用户提供更好的安全保障,建立用户对我们应用的信任。💪


🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

知远漫谈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值