实战JPA,如何优雅解决实际开发问题?

1 篇文章 0 订阅

一、入门JPA

1. 定义实体类

**相关注解: ** @Entity@Table@Id@IdClass@GeneratedValue@Basic@Transient@Column@Temporal@Enumerated@Lob

注解说明
@Entity定义对象会成为被JPA管理的实体
@Table指定数据库的表名(jdk、jpa自带的)
@Id声明数据库的主键列
@IdClass主要用于联合主键使用
@GeneratedValue主键的生成策略
@Basic表示属性是到数据库表的字段的映射(默认有的)
@Transient表示该属性不是数据库中的字段的映射
@Column定义该属性对应数据库中的列信息(名、数据类型等)
@Temporal属性映射对应的精度的字段(针对java中的Data类型)
@Enumerated直接映射enum枚举类型的字段
@Lob属性映射成数据库支持的大对象类型,(Clob、Blob类型)

以上都是对于单表操作的相关注解

多表相关的注解:@JoinColum@OneToOne@OneToMany@ManyToOne@ManyToMany@JoinTable@OrderBy

注解说明
@JoinColum定义外键的关联的字段名称
@OneToOne关联关系(一对一)、常与@JoinColum结合使用
@OneToMany关联关系(一对多),一般定义mappedBy 属性
@ManyToOne关联关系(多对一),常与@JoinColum结合使用
@ManyToMany关联关系(多对多)
@JoinTable关联时排序问题
@OrderBy关联关系表

注意:联表的关键在于多表之间的主键或者候选键关系维护(例如:一对一、任意一方都可以维护这个关系,当然也可以新创建一张表进行维护,在开发中需要衡量SQL查询的效率问题来决定)。

2. 定义Repository

常见的Repository接口: RepositoryCrudRepositoryPagingAndSortingRepositoryQueryByExampleRepositoryJpaRepositoryJpaSpecificationRepositoryQueryDslPredicateExecutor


二、实战开发

1. 简单案例

(1) 定义实体Entity

@Entity
@Table(name = File.TABLE)
@Data
public class File {

    public final static String TABLE = "file";
    private static final long serialVersionUID = 408359656184651337L;

/**
     * 主键
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Getter
    @Setter
    protected Long id;

    /**
     * 记录创建时间
     */
    @Getter
    @Setter
    @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME default current_timestamp comment '创建时间'")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    protected LocalDateTime createTime;

    /**
     * 记录修改时间
     */
    @Getter
    @Setter
    @Column(name = "update_time", nullable = false, columnDefinition = "DATETIME default current_timestamp comment '最后修改时间'")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @LastModifiedDate
    protected LocalDateTime updateTime;

    /**
     * 创建者
     */
    @Getter
    @Setter
    @Column(name = "created_by", columnDefinition = "varchar(64) default 'system' comment '创建人'")
    @CreatedBy
    protected String createdBy = "system";

    /**
     * 修改者
     */
    @Getter
    @Setter
    @Column(name = "last_modified_by", columnDefinition = "varchar(64) default 'system' comment '最后修改人'")
    protected String lastModifiedBy = "system";

    /**
     * 是否删除
     * 0-未删除,1-已删除
     */
    @Getter
    @Setter
    @Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT(1) default 0 comment '是否被删除(0、否  1、是)'")
    protected Integer deleted = 0;

    /**
     * 文件名
     */
    @Column(name = "name", columnDefinition = "varchar(128) comment '点名称'")
    private String name;

    @Column(name = "no", columnDefinition = "varchar(64) comment '文件编号'")
    private String no;

    /**
     * 文件地址
     */
    @Column(name = "address", nullable = false, columnDefinition = "varchar(128) comment '文件地址'")
    private String address;

    /**
     * 文件夹
     */
    @Column(name = "folder_no", columnDefinition = "varchar(64) comment '文件夹'")
    private String folderNo;

    /**
     * 是否生效(FileType.MAP、FileType.IMAGE isTakeEffect 默认为 true,FileType.EXCEL isTakeEffect 默认为 false)
     */
    @Column(name = "is_take_effect", nullable = false, columnDefinition = "bit default true comment '是否生效(type=1、type=2 isTakeEffect 默认为 true,type=3、type=4 isTakeEffect 默认为 false)'")
    private Boolean isTakeEffect;
}

(2) 定义实体Repository

@Repository
public interface FileRepository extends JpaRepository<File, Long>, JpaSpecificationExecutor<File> {

}

2. 库表设计问题

根据阿里mysql开发规范,可以得到:设计库表时,必须含有创建时间更新时间创建人更新人是否被删除(只能逻辑删除)

问题: 每次插入数据或更新数据,这类数据很难得到有效的管理,管理不好,则会出现错误数据(更新人没有更该;每次查询都需要加上是否呗删除了)。这类情况都会影响开发效率。


三、Auditing配置

1. Auditing介绍

Auditing翻译是审计和审核。这个技术可以解决的一张表的插入数据修改数据时,同时也要记录创建者修改者创建时间修改时间(主要通过四个注解实现),并且能让我们方便记录操作日志(主要是通过监听器Listener功能实现)。

2. 解决方案 — 4个注解

. 注解. 说明
@CreatedBy创建的用户
@CreatedDate创建的时间
@LastModifiedBy最后一次修改实体的用户
@LastModifiedDate最后一次修改实体的时间

3. Auditing 如何配置?

步骤一:在Entity里添加上@EntityListeners(AuditingEntityListener.class)注解

@Entity
@Table(name = "user")
@EntityListeners(AuditingEntityListener.class)
public class User{
	@Id
	private Long id;
	
	@CreateBy
	@Column(name="create_user")
	private String createUser;

	@CreateDate
	@Column(name="create_time")
	private Date createTime;
	
	@LastModifiedDate
	@Column(name="last_modified_time")
	private Data lastModifiedTime;

}

步骤二:实现AuditingAware接口,指定创建[修改]用户是谁。

public class MyAuditingAware implements AuditingAware<Stirng>{
	
	public String getCurrentAuditor(){
		// 应该根据当前使用的安全框架中获取用户名
		return "system";
	}
}

步骤三:启动类或配置了上,添加注解@EnableJpaAuditing

@EnableJpaAuditing
@SpringBootApplication
public class Main{
	public static void main(String[] args){
		SpringApplication.run(Main.class,args);
	}
	
	public AuditingAware<String> auditingAware(){
		return new MyAuditingAware<>();
	}
}

4. @MappedSupperclass的介绍

上述,刚提到了阿里规范,设计数据库表时,需要某些公共的字段(创建人、修改人、id等等)。在实际开发过程中,我们会将这类字段抽象出来,让其他数据库表去继承这个类,从而实现既可以达到要求并且简化了开发。

结论@MappedSupperclass可以解决实体Entity(以及Repository)的继承问题。

实战案例

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity implements Serializable {

    private static final long serialVersionUID = 8328293151203544834L;

    /**
     * 主键
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Getter
    @Setter
    protected Long id;

    /**
     * 记录创建时间
     */
    @Getter
    @Setter
    @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME default current_timestamp comment '创建时间'")
    @CreatedDate
    protected LocalDateTime createTime;

    /**
     * 记录修改时间
     */
    @Getter
    @Setter
    @Column(name = "update_time", nullable = false, columnDefinition = "DATETIME default current_timestamp comment '最后修改时间'")
//    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @LastModifiedDate
    protected LocalDateTime updateTime;

    /**
     * 创建者
     */
    @Getter
    @Setter
    @Column(name = "created_by", columnDefinition = "varchar(64) default 'system' comment '创建人'")
    @CreatedBy
    protected String createdBy = "system";

    /**
     * 修改者
     */
    @Getter
    @Setter
    @Column(name = "last_modified_by", columnDefinition = "varchar(64) default 'system' comment '最后修改人'")
    @LastModifiedBy
    protected String lastModifiedBy = "system";

    /**
     * 是否删除
     * 0-未删除,1-已删除
     */
    @Getter
    @Setter
    @Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT(1) default 0 comment '是否被删除(0、否  1、是)'")
    protected Integer isDeleted = 0;
}

这样的话,其他的数据库表实体Entity就可以继承该类,同时也拥有上述的数据库字段。

5. Listener事件的扩展

实战问题:操作记录功能实现。例如,记录每个用户对实体数据的添加或者修改动作。

常见解决方案:

  1. 如果放在service层,代码耦合高,不易修改;
  2. 如果通过AOP实现,实现比较复杂,并且只能使用环绕通知实现。

JPA提供了一种更简洁的解决方案:自定义Listener

.注解.说明
@PostPersist更新时,出发的动作
@PostRemove删除时,触发的动作
@PostUpdate更新时,触发的动作

使用案例

public class MyLogAuditingListener{
	@PostPersist
	public void postPersist(Object entity) {
	// 逻辑处理
	}
	@PostRemove
	public void postRemove(Object entity) {
	// 逻辑处理
	}
	@PostUpdate
	public void postUpdate(Object entity) {
	// 逻辑处理
	}
}

@Entity
@Table(name = "user")
@EntityListeners({AuditingEntityListener.class,MyLogAuditingListener.class})
public class User{
	//..定义一些属性字段信息
}

6、逻辑删除的实现

背景描述
(1)根据阿里开发手册MySQL的描述可以得知:针对数据的删除,不能从物理上删除,而是应该用一个字段表示逻辑的删除(例如:用is_deleted=1表示数据被删除)
(2) 为什么要这么做?理由:

  1. 保留原有的数据,方便后续使用;
  2. 物理删除数据时,磁盘中的数据也会删除该数据,也就可能会导致,磁盘块的页分裂页合并以及索引结构改变

(3)在使用JPA进行数据查询时,需要添加上where is_deleted = 0 ;。如果所有的查询都自己添加上这段,这样的话,就体现不了JPA的优势了。

实战如何解决@Where@SqlDelete@SqlUpdate,这是更新的SQL,根据需求定义)

.注解.说明
@Where所有的SQL语句都添加上后续定义的sql语句(例如:where is_deleted=0
@SqlDelete自定义删除语句
@SqlUpdate自定义更新语句

实战案例

@Entity
@Table(name = File.TABLE)
@Table(appliesTo = File.TABLE, comment = "文件表")
@EqualsAndHashCode(callSuper = false)
@Accessors
@Where(clause = " is_deleted = 0 ")
@SQLDelete(sql = " update " + File.TABLE + " set is_deleted = 1 where id = ? ; ")
@Data
public class File extends BaseEntity {

    public final static String TABLE = "file";
    private static final long serialVersionUID = 408359656184651337L;
    // 自定义属性
}



注意:在实际开发中可能,调用删除相关接口时,可能会报错,这是,需要在删除的接口上添加@Transactional@Modifying两个注解(前者表示事物 必须有,后者是表示更新或删除操作需要的注解)

7. 枚举类型的使用

背景描述
(1) 在实战开发过程中,避免不了使用枚举类型。在使用枚举数据类型时,如何映射到 mysql中的数据类型?
(2) 一般情况下,我们都有是一种(key-value)形式的存在表示枚举类型。默认情况下,数据库存储的值,是枚举型类型中的顺序值(从0开始)。这样的话,自己当初设计的key-value存储结构就难以匹配上了。
(3)问题所在:默认情况,java枚举类型映射到数据库中的值是枚举值的顺序。但我们期望自己可以指定存储的值。

JPA提供的解决方案:实现EnumValueConverter接口解析枚举类型的值问题。

实战案例

步骤一:自定义注解:@EnumValue

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValue {
}

步骤二:自定义注解解析器

public class OrdinalEnumValueConverter<E extends Enum> implements EnumValueConverter<E, Integer>, Serializable {
    private static final Logger log = Logger.getLogger(OrdinalEnumValueConverter.class);
    private static final long serialVersionUID = -4467561563537482266L;

    private final EnumJavaTypeDescriptor<E> enumJavaDescriptor;

    public OrdinalEnumValueConverter(EnumJavaTypeDescriptor<E> enumJavaDescriptor) {
        this.enumJavaDescriptor = enumJavaDescriptor;
    }

    @Override
    public E toDomainValue(Integer relationalForm) {
        return enumJavaDescriptor.fromOrdinal(relationalForm);
    }

    @Override
    public Integer toRelationalValue(E domainForm) {
        // TODO 修改为Object 看能否支持String
        Class<E> e = enumJavaDescriptor.getJavaType();
        Field[] fields = e.getDeclaredFields();
        Field enumField = null;
        for (Field field : fields) {
            EnumValue enumValue = field.getAnnotation(EnumValue.class);
            if (enumValue != null) {
                enumField = field;
                break;
            }
        }
        if (enumField != null) {
            try {
                enumField.setAccessible(true);
                return (Integer) enumField.get(domainForm);
            } catch (IllegalAccessException ex) {
                throw new RuntimeException(ex);
            }
        }
        return enumJavaDescriptor.toOrdinal(domainForm);
    }

    @Override
    public int getJdbcTypeCode() {
        return Types.INTEGER;
    }

    @Override
    public EnumJavaTypeDescriptor<E> getJavaDescriptor() {
        return enumJavaDescriptor;
    }

    @Override
    public E readValue(ResultSet resultSet, String name) throws SQLException {
        final int ordinal = resultSet.getInt(name);
        final boolean traceEnabled = log.isTraceEnabled();
        if (resultSet.wasNull()) {
            if (traceEnabled) {
                log.trace(String.format("Returning null as column [%s]", name));
            }
            return null;
        }

        Class<E> e = enumJavaDescriptor.getJavaType();
        Field[] fields = e.getDeclaredFields();
        Field enumField = null;
        for (Field field : fields) {
            EnumValue enumValue = field.getAnnotation(EnumValue.class);
            if (enumValue != null) {
                enumField = field;
                break;
            }
        }
        if (enumField != null) {
            Object[] objects = e.getEnumConstants();
            try {
                for (Object obj : objects) {
                    enumField.setAccessible(true);
                    Object enumFieldValue = enumField.get(obj);
                    // TODO ordinal 改名
                    if (enumFieldValue.equals(ordinal)) {
                        return (E) obj;
                    }
                }
                throw new SQLException("EnumValue not match:" + ordinal);
            } catch (IllegalAccessException ex) {
                log.error("", ex);
                throw new SQLException(ex);
            }

        }
        final E enumValue = enumJavaDescriptor.fromOrdinal(ordinal);
        if (traceEnabled) {
            log.trace(String.format("Returning [%s] as column [%s]", enumValue, name));
        }
        return enumValue;
    }

    @Override
    public void writeValue(PreparedStatement statement, E value, int position) throws SQLException {
        final Integer jdbcValue = value == null ? null : toRelationalValue(value);

        final boolean traceEnabled = log.isTraceEnabled();
        if (jdbcValue == null) {
            if (traceEnabled) {
                log.tracef("Binding null to parameter: [%s]", position);
            }
            statement.setNull(position, getJdbcTypeCode());
            return;
        }

        if (traceEnabled) {
            log.tracef("Binding [%s] to parameter: [%s]", jdbcValue.intValue(), position);
        }

        statement.setInt(position, jdbcValue);
    }

    @Override
    public String toSqlLiteral(Object value) {
        return Integer.toString(((E) value).ordinal());
    }
}

步骤三:定义枚举类型、实体类

// 定义自己的枚举类型
@Getter
@AllArgsConstructor
public enum FileType {
    IMAGE(1, "image", "图片"),
    MAP(2, "map", "地图文件"),
    UNKNOWN(3, "unknown", "未知"),
    EXCEL(4, "excel", "Excel 表格");

    @EnumValue
    private final Integer code;

    private final String name;

    private final String desc;
}

// 定义自己的 entity 实体
@Entity
@Table(name = File.TABLE,)
@org.hibernate.annotations.Table(appliesTo = File.TABLE, comment = "文件表")
@EqualsAndHashCode(callSuper = false)
@Accessors
@Where(clause = "deleted=0")
@SQLDelete(sql = "update " + File.TABLE + " set deleted = 1 where id = ?")
@Data
public class File extends BaseEntity {
    public final static String TABLE = "file";
    private static final long serialVersionUID = 408359656184651337L;
	 /**
     * 文件类型
     */
    @Column(name = "type", nullable = false, columnDefinition = "tinyint(1) comment '文件类型'")
    private FileType type;
}

四、Repository的理解

常见的Repository相关接口有:

接口说明
1Repository
2JpaRepository
3CrudJpaRepository
4Repository
5Repository
6Repository
7Repository

1. Repository相关接口

接口说明
Repository顶级接口,提供了实体的简单查询(根据字段名)
CrudReposiroty继承自Repository,常见的CRUD操作
PagingAndSortingRepository继承自CrudReposiroty,提供了分页查询和排序操作,集合操作返回Iterable
JPARepository继承自PagingAndSortingRepository,直接返回了List
JpaSpecificationExecutor这个接口单独存在,主要提供了多条件查询的支持,并且可以在查询中添加分页和排序。

在实战开发过程中,只需要继承JPARepositoryJpaSpecificationExecutor接口,就能完成全部的条件查询操作。


下面重点介绍PagingAndSortingRepositoryJpaSpecificationExecutor两个接口的使用

2. PagingAndSortingRepository接口

根据名字,不难发现这个接口是跟分页排序相关的。所以下面我们就从这两个切入点考虑实战中如实优雅的写出代码。

  • 分页功能介绍
    实战中,可能用到的相关接口:Pageable@PageableDefaultJpaRepository<T, ID>

第一步:分页相关的参数(三个):当前页page、页大小size、总页数量totalPages

@Entity
@Table(name = MapFile.TABLE)
public class MapFile extends BaseEntity {
    public final static String TABLE = "factory_map_file";
    private static final long serialVersionUID = 408359656184651337L;
    // 实体相关属性
    // ...
}
@Repository
public interface MapFileRepository 
		extends JpaRepository<MapFile, Long>,
        JpaSpecificationExecutor<MapFile> {
}
@RestController
public class MapFileController {

    @PostMapping("/page")
    public Page<MapFile> page(@RequestBody @PageableDefault(size = 5, page = 1) Pageable pageable) {
        Page<MapFile> page = mapFileRepository.findAll(pageable);
        System.out.println("获取查询得到的数据:"+page.get());
        System.out.println(page);
        return page;
    }
}

说明

  1. @PageableDefault:指定默认的分页配置信息。
  2. PageableJpaRepository接口的重要相关参数
  • 排序功能介绍
    实战中,可能用到的相关接口:Pageable@PageableDefaultJpaRepository<T, ID>Sort@SortDefault
@RestController
public class MapFileController {
@GetMapping("/page2")
    public Page<MapFile> page2(@PageableDefault(size = 5, page = 1, sort = {"id"}) Pageable pageable) {
        Page<MapFile> page = mapFileRepository.findAll(pageable);
        System.out.println("获取查询得到的数据:" + page.get());
        System.out.println(page);
        return page;
    }

    @GetMapping("/sort")
    public Object sort(@SortDefault(sort = {"id", "createTime"},direction = Sort.Direction.DESC) Sort sort) {
        System.out.println(sort.toString());
//        List<MapFile> list = mapFileRepository.findAll(sort.descending());  // 逆序
        List<MapFile> list = mapFileRepository.findAll(sort.ascending());  // 正序(默认)
        System.out.println(list);
        return list;
    }
}

说明

  1. 既可以使用Pageable参数进行排序,也可以使用Sort接口进行排序
  2. Pageable接口,似乎不能进行逆序操作

五、实战中设计实体

1. 枚举类型

在实战开发过程中,设计实体对象时,就避免不了出现枚举类型的数据。但枚举类型仅仅只是在Java程序中的数据类型,在数据库中并不存在(其实也有枚举类型,但在实战开发过程中几乎不用)。

根据上述的描述,可以提出问题:在Java程序和数据库之间,枚举数据如何传递、转换?

1.1 @ConvertAttributeConverter接口

理论知识铺垫:
基本使用步骤:

  1. 实现AttributeConverter接口
  2. 声明枚举类型
  3. 设计实体,并使用上该类型,并使用@Convert(converter = EnumConverter.class)
  4. 开始进行测试

上案例

声明枚举类型

/**
 * code 在 java程序中,尽可能要唯一性。(这种程序更具有容错性,更易于扩展)
 * 提示:xxxx_xxx 前4个'x'表示不同的枚举类型,后3个'x'表示每种枚举类型中的值(如果觉得不够用可以加大范围)
 */
public interface EnumType {

    /**
     * 编码
     */
    Integer getCode();

    /**
     * 对应文字描述
     */
    String getName();

    /**
     * 中文描述
     */
    String getDesc();

    /**
     * 英文描述
     */
    String getEnDesc();

    default String getValue() {
        return getDesc();
    }

    default String toJson() {
        return String.format("{\"code\": %d,\"name\": \"%s\",\"desc\": \"%s\"}", getCode(), getName(), getValue());
    }

}

@Getter
public enum StatusType implements EnumType {

    MAN(103_001, "MAN", "男", "man"),
    WOMAN(103_002, "WOMAN", "女", "woman");

    private final Integer code;
    private final String name;
    private final String zhDesc;
    private final String enDesc;

    StatusType(Integer code, String name, String zhDesc, String enDesc) {
        this.code = code;
        this.name = name;
        this.zhDesc = zhDesc;
        this.enDesc = enDesc;
    }

    public static StatusType codeOf(Integer code) {
        StatusType[] values = StatusType.values();
        for (StatusType value : values) {
            if (value.getCode().equals(code)) {
                return value;
            }
        }
        return null;
    }


    @Override
    public String getDesc() {
        return zhDesc;
    }
}

Converter:

/**
 * TODO 这里应该将枚举类型抽象出一个顶级接口,然后再声明一个(如 上述的EnumType) 全局枚举类型的管理类进行管理。
 * 这样就不用每个枚举类型都写一个转换接口的了。
 */
public class EnumConverter implements AttributeConverter<StatusType, Integer> {

    @Override
    public Integer convertToDatabaseColumn(StatusType attribute) {
        return attribute.getCode();
    }

    @Override
    public StatusType convertToEntityAttribute(Integer code) {
        return StatusType.codeOf(code);
    }

}

entity

@Entity
@Table(name = Person.TABLE_NAME)
//@Where(clause = "deleted=0")
//@SQLDelete(sql = "update " + Person.TABLE_NAME + " set deleted = 1 where id = ?")
@Data
public class Person {

    public static final String TABLE_NAME = "person";

    private static final Long serialVersionUID = -12345456567434L;

    private String name;

    private String password;

    @Convert(converter = EnumConverter.class)
    private StatusType type;

}

repository

public interface PersonRepository extends JpaRepository<Person, Long>, JpaSpecificationExecutor<Person> {
}

controller:(这里少了serice层)

@RestController
@RequestMapping("/persons")
public class PersonController {

    @Autowired(required = false)
    private PersonRepository personRepository;

    @GetMapping("/{id}")
    public Person get(@PathVariable("id") Long id) {
        return personRepository.findById(id).get();
    }

    @PostMapping("/add")
    public Person add(@RequestBody Person person) {
        return personRepository.saveAndFlush(person);
    }

}

请求测试:(这里使用idea自带的测试工具,文件类型:‘http’)

### 查询 person
GET http://localhost:8080/persons/1

### add person
POST http://localhost:8080/persons/add
Content-Type: application/json

{
  "name": "zhangsan",
  "password": "123456",
  "type": "WOMAN"
}

1.2 @Convert方式处理的缺点

public interface AttributeConverter<X,Y> {
    public Y convertToDatabaseColumn (X attribute);

    public X convertToEntityAttribute (Y dbData);
}

该接口接受到的参数非常有限,不方便扩展。
问题:程序中有100个枚举类型数据,难道也要定义100个AttributeConverter接口的实现类?在实际开发中,这肯定是不希望看到的。

解决方案:因此,在实际开发过程中,需要定义好枚举类型的顶级接口BaseEnumType,以及全局枚举类型的管理类GlobaEnumManager。这样,再多的枚举类型,也只需要一个AttributeConverter接口的实现类。

2. 时间类型

和上述处理的方式一样。

未完成…待更新

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值