feat(purchase): 新增 Apple 内购完整链路

- AppleReceiptController 改造:验签后立刻落库并解锁权益
- 新增 ApplePurchaseService 处理业务:防重、写订单、发道具
- 新增 KeyboardUserPurchaseRecords 实体与 Mapper,记录用户购买
- ErrorCode 补充 RECEIPT_INVALID(50016)
- 删除过期 AGENTS.md,修正 i18n_message 表名与 CORS 白名单
This commit is contained in:
2025-12-12 18:18:55 +08:00
parent 2e16183cb8
commit a24a795887
16 changed files with 491 additions and 42 deletions

View File

@@ -1,33 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- Entrypoint `src/main/java/com/yolo/keyborad/MyApplication.java`; feature code organized by layer: `controller` (REST), `service` (business), `mapper` (MyBatis mappers), `model`/`common`/`constant` for DTOs, responses, and constants, plus `config`, `aop`, `annotation`, `Interceptor`, and `utils` for cross-cutting concerns.
- Resource configs live in `src/main/resources`: `application.yml` with `application-dev.yml`/`application-prod.yml` profiles, mapper XML files under `mapper/`, and platform keys/certs (Apple, mail, storage). Keep secrets out of commits.
- Tests belong in `src/test/java/com/yolo/keyborad/...` mirroring package names; add fixtures alongside tests when needed.
## Build, Test, and Development Commands
- `./mvnw clean install` — full build with tests; requires JDK 17.
- `./mvnw test` — run test suite only.
- `./mvnw spring-boot:run -Dspring-boot.run.profiles=dev` — start the API with the dev profile (loads `application-dev.yml`).
- `./mvnw clean package -DskipTests` — create an artifact when tests are already covered elsewhere.
## Coding Style & Naming Conventions
- Java 17, Spring Boot 3.5, MyBatis/MyBatis-Plus; prefer Lombok for boilerplate (`@Data`, `@Builder`) and constructor injection for services.
- Use 4-space indentation, lowercase package names, `UpperCamelCase` for classes, `lowerCamelCase` for fields/params.
- Controllers end with `*Controller`, services with `*Service`, mapper interfaces with `*Mapper`, and request/response DTOs under `model` or `common` with clear suffixes like `Request`/`Response`.
- Keep configuration isolated in `config`; shared constants in `constant`; AOP/logging in `aop`; custom annotations in `annotation`.
## Testing Guidelines
- Use Spring Boot Test + JUnit (via `spring-boot-starter-test`, JUnit 4/5 support) and MockMvc/WebTestClient for HTTP layers when practical.
- Name classes `*Test` and align packages with the code under test. Cover service logic, mappers, and controller contracts (status + payload shape).
- For data-access tests, use in-memory setups or dedicated test containers and clean up test data.
## Commit & Pull Request Guidelines
- Follow the existing conventional style seen in history (e.g., `feat(user): add email registration`); keep scope lowercase and concise.
- PRs should describe the change, list validation steps/commands run, call out config/profile impacts, and link issues/tasks. Add screenshots or sample requests/responses for API-facing changes when helpful.
- Ensure secrets (p8 certificates, Mailgun keys, AWS creds) are never committed; rely on environment variables or local config overrides.
## Security & Configuration Tips
- Activate the intended profile via `SPRING_PROFILES_ACTIVE` or `-Dspring-boot.run.profiles`. Keep `application-dev.yml` local-only; never hardcode production endpoints or credentials.
- Validate signing/encryption helpers (`SignInterceptor`, JWT, Apple receipt validation) with representative non-production keys before merging.
- Log only necessary context; avoid logging tokens, receipts, or PII.

View File

@@ -101,7 +101,7 @@
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>3.6.0</version>
<version>4.0.0</version>
</dependency>
<!-- x-file-storage -->

View File

@@ -50,7 +50,8 @@ public enum ErrorCode {
INSUFFICIENT_BALANCE(50013, "余额不足"),
THEME_NOT_FOUND(40410, "主题不存在"),
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
THEME_NOT_AVAILABLE(50015, "主题不可购买");
THEME_NOT_AVAILABLE(50015, "主题不可购买"),
RECEIPT_INVALID(50016, "收据无效");
/**
* 状态码
*/

View File

@@ -97,7 +97,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/products/listByType",
"/products/detail",
"/products/inApp/list",
"/products/subscription/list"
"/products/subscription/list",
"/purchase/handle"
};
}

View File

@@ -1,6 +1,12 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -14,14 +20,32 @@ import java.util.Map;
public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService;
public AppleReceiptController(AppleReceiptService appleReceiptService) {
public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService) {
this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService;
}
// @PostMapping("/validate-receipt")
// public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
// String receipt = body.get("receipt");
// return appleReceiptService.validateReceipt(receipt);
// }
@PostMapping("/validate-receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) {
String receipt = body.get("receipt");
return appleReceiptService.validateReceipt(receipt);
if (receipt == null || receipt.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空");
}
Long userId = StpUtil.getLoginIdAsLong();
AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(receipt);
applePurchaseService.processPurchase(userId, validationResult);
return ResultUtils.success(Boolean.TRUE);
}
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
public interface KeyboardUserPurchaseRecordsMapper extends BaseMapper<KeyboardUserPurchaseRecords> {
}

View File

@@ -13,7 +13,7 @@ import lombok.Data;
@Schema(description="多语言消息表")
@Data
@TableName("i18n_message")
@TableName("keyboard_i18n_message")
public class I18nMessage {
@TableId("id")
@Schema(description="主键")

View File

@@ -0,0 +1,134 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.yolo.keyborad.typehandler.StringArrayTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
import org.apache.ibatis.type.JdbcType;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Schema
@Data
@TableName(value = "keyboard_user_purchase_records")
public class KeyboardUserPurchaseRecords {
/**
* 主键,自增,唯一标识每条购买记录
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键,自增,唯一标识每条购买记录")
private Integer id;
/**
* 用户 ID关联到用户表表示是哪位用户购买了产品
*/
@TableField(value = "user_id")
@Schema(description="用户 ID关联到用户表表示是哪位用户购买了产品")
private Integer userId;
/**
* 购买的产品 ID关联到产品表
*/
@TableField(value = "product_id")
@Schema(description="购买的产品 ID关联到产品表")
private String productId;
/**
* 购买数量(如内购的金币数量,订阅的时长)
*/
@TableField(value = "purchase_quantity")
@Schema(description="购买数量(如内购的金币数量,订阅的时长)")
private Integer purchaseQuantity;
/**
* 实际支付价格
*/
@TableField(value = "price")
@Schema(description="实际支付价格")
private BigDecimal price;
/**
* 货币类型(如美元 $
*/
@TableField(value = "currency")
@Schema(description="货币类型(如美元 $")
private String currency;
/**
* 购买时间
*/
@TableField(value = "purchase_time")
@Schema(description="购买时间")
private Date purchaseTime;
/**
* 购买类型(如内购,订阅)
*/
@TableField(value = "purchase_type")
@Schema(description="购买类型(如内购,订阅)")
private String purchaseType;
/**
* 购买状态(如已支付,待支付,退款)
*/
@TableField(value = "\"status\"")
@Schema(description="购买状态(如已支付,待支付,退款)")
private String status;
/**
* 支付方式(如信用卡,支付宝等)
*/
@TableField(value = "payment_method")
@Schema(description="支付方式(如信用卡,支付宝等)")
private String paymentMethod;
/**
* 唯一的交易 ID用于标识该购买操作
*/
@TableField(value = "transaction_id")
@Schema(description="唯一的交易 ID用于标识该购买操作")
private String transactionId;
/**
* 苹果的原始交易 ID
*/
@TableField(value = "original_transaction_id")
@Schema(description="苹果的原始交易 ID")
private String originalTransactionId;
/**
* 购买的产品 ID 列表JSON 格式或数组)
*/
@TableField(value = "product_ids", typeHandler = StringArrayTypeHandler.class, jdbcType = JdbcType.ARRAY)
@Schema(description="购买的产品 ID 列表JSON 格式或数组)")
private String[] productIds;
/**
* 苹果返回的购买时间
*/
@TableField(value = "purchase_date")
@Schema(description="苹果返回的购买时间")
private Date purchaseDate;
/**
* 苹果返回的过期时间(如果有)
*/
@TableField(value = "expires_date")
@Schema(description="苹果返回的过期时间(如果有)")
private Date expiresDate;
/**
* 苹果的环境(如 Sandbox 或 Production
*/
@TableField(value = "environment")
@Schema(description="苹果的环境(如 Sandbox 或 Production")
private String environment;
}

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
/**
* 处理苹果购买后的业务逻辑
*/
public interface ApplePurchaseService {
/**
* 基于验签结果处理购买逻辑(订阅 / 内购)
*
* @param userId 用户ID
* @param validationResult 苹果验签结果
*/
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
}

View File

@@ -27,6 +27,11 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
*/
KeyboardProductItemRespVO getProductDetailByProductId(String productId);
/**
* 根据 productId 获取商品实体
*/
KeyboardProductItems getProductEntityByProductId(String productId);
/**
* 根据商品类型查询商品列表
* type=all 时返回全部

View File

@@ -0,0 +1,13 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
}

View File

@@ -0,0 +1,216 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.KeyboardProductItemsService;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
import com.yolo.keyborad.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
*/
@Service
@Slf4j
public class ApplePurchaseServiceImpl implements ApplePurchaseService {
@Resource
private KeyboardProductItemsService productItemsService;
@Resource
private KeyboardUserPurchaseRecordsService purchaseRecordsService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardWalletTransactionService walletTransactionService;
@Resource
private UserService userService;
@Override
@Transactional(rollbackFor = Exception.class)
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
if (validationResult == null || !validationResult.isValid()) {
throw new BusinessException(ErrorCode.RECEIPT_INVALID);
}
String productId = resolveProductId(validationResult.getProductIds());
// 幂等transactionId 已经处理过则跳过
boolean handled = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getTransactionId, validationResult.getTransactionId())
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
.exists();
if (handled) {
log.info("Apple purchase already handled, transactionId={}", validationResult.getTransactionId());
return;
}
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
if (product == null) {
log.error("Apple purchase not found, productId={}", productId);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "商品不存在: " + productId);
}
// 写购买记录
KeyboardUserPurchaseRecords purchaseRecord = buildPurchaseRecord(userId, validationResult, product);
purchaseRecordsService.save(purchaseRecord);
if ("subscription".equalsIgnoreCase(product.getType())) {
handleSubscription(userId, product, validationResult);
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
handleInAppPurchase(userId, product, purchaseRecord.getId());
} else {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未知商品类型: " + product.getType());
}
}
private void handleSubscription(Long userId, KeyboardProductItems product, AppleReceiptValidationResult validationResult) {
KeyboardUser user = userService.getById(userId);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
Instant base = resolveBaseExpiry(user.getVipExpiry());
Instant newExpiry = base.plus(resolveDurationDays(product), ChronoUnit.DAYS);
user.setIsVip(true);
user.setVipExpiry(Date.from(newExpiry));
boolean updated = userService.updateById(user);
if (!updated) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "更新用户VIP失败");
}
log.info("Extend VIP for user {} to {}", userId, newExpiry);
}
private void handleInAppPurchase(Long userId, KeyboardProductItems product, Integer purchaseRecordId) {
KeyboardUserWallet wallet = walletService.lambdaQuery()
.eq(KeyboardUserWallet::getUserId, userId)
.one();
if (wallet == null) {
wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(BigDecimal.ZERO);
wallet.setStatus((short) 1);
wallet.setVersion(0);
wallet.setCreatedAt(new Date());
}
BigDecimal credit = resolveCreditAmount(product);
if (credit.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "商品额度未配置");
}
BigDecimal before = wallet.getBalance() == null ? BigDecimal.ZERO : wallet.getBalance();
BigDecimal after = before.add(credit);
wallet.setBalance(after);
wallet.setUpdatedAt(new Date());
walletService.saveOrUpdate(wallet);
KeyboardWalletTransaction tx = walletTransactionService.createTransaction(
userId,
purchaseRecordId == null ? null : purchaseRecordId.longValue(),
credit,
(short) 2, // 2: 苹果内购充值
before,
after,
"Apple 充值: " + product.getProductId()
);
log.info("Wallet recharge success, user={}, credit={}, txId={}", userId, credit, tx.getId());
}
private KeyboardUserPurchaseRecords buildPurchaseRecord(Long userId, AppleReceiptValidationResult validationResult, KeyboardProductItems product) {
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
record.setUserId(userId.intValue());
record.setProductId(product.getProductId());
record.setPurchaseQuantity(product.getDurationValue());
record.setPrice(product.getPrice());
record.setCurrency(product.getCurrency());
record.setPurchaseTime(Date.from(Objects.requireNonNullElseGet(validationResult.getPurchaseDate(), Instant::now)));
record.setPurchaseType(product.getType());
record.setStatus("PAID");
record.setPaymentMethod("APPLE");
record.setTransactionId(validationResult.getTransactionId());
record.setOriginalTransactionId(validationResult.getOriginalTransactionId());
record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())});
if (validationResult.getExpiresDate() != null) {
record.setExpiresDate(Date.from(validationResult.getExpiresDate()));
}
record.setEnvironment(validationResult.getEnvironment() == null ? null : validationResult.getEnvironment().name());
record.setPurchaseDate(validationResult.getPurchaseDate() == null ? null : Date.from(validationResult.getPurchaseDate()));
return record;
}
private String resolveProductId(List<String> productIds) {
if (productIds == null || productIds.isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失");
}
return productIds.get(0);
}
private Instant resolveBaseExpiry(Date currentExpiry) {
if (currentExpiry == null) {
return Instant.now();
}
Instant current = currentExpiry.toInstant();
return current.isAfter(Instant.now()) ? current : Instant.now();
}
private long resolveDurationDays(KeyboardProductItems product) {
if (product.getDurationDays() != null && product.getDurationDays() > 0) {
return product.getDurationDays();
}
Integer value = product.getDurationValue();
String unit = product.getDurationUnit() == null ? "" : product.getDurationUnit().toLowerCase();
if (value == null || value <= 0) {
return 0;
}
if (unit.contains("day") || unit.contains("")) {
return value;
}
if (unit.contains("month") || unit.contains("")) {
return (long) value * 30;
}
return value;
}
private BigDecimal resolveCreditAmount(KeyboardProductItems product) {
// 优先使用 name 中的数值,否则退回 durationValue
BigDecimal fromName = parseNumber(product.getName());
if (fromName != null) {
return fromName;
}
if (product.getDurationValue() != null) {
return BigDecimal.valueOf(product.getDurationValue());
}
return BigDecimal.ZERO;
}
private BigDecimal parseNumber(String raw) {
if (raw == null) {
return null;
}
try {
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
} catch (Exception e) {
return null;
}
}
}

View File

@@ -59,6 +59,16 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
@Override
public KeyboardProductItems getProductEntityByProductId(String productId) {
if (productId == null || productId.isBlank()) {
return null;
}
return this.lambdaQuery()
.eq(KeyboardProductItems::getProductId, productId)
.one();
}
/**
* 根据类型获取产品列表

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Service
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
}

View File

@@ -3,7 +3,7 @@
<mapper namespace="com.yolo.keyborad.mapper.I18nMessageMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.I18nMessage">
<!--@mbg.generated-->
<!--@Table i18n_message-->
<!--@Table keyboard_i18n_message-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="code" jdbcType="VARCHAR" property="code" />
<result column="locale" jdbcType="VARCHAR" property="locale" />
@@ -15,6 +15,6 @@
</sql>
<select id="selectByCodeAndLocale" resultMap="BaseResultMap">
SELECT * FROM "i18n_message" WHERE code = #{code,jdbcType=VARCHAR} AND locale = #{locale,jdbcType=VARCHAR}
SELECT * FROM "keyboard_i18n_message" WHERE code = #{code,jdbcType=VARCHAR} AND locale = #{locale,jdbcType=VARCHAR}
</select>
</mapper>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords">
<!--@mbg.generated-->
<!--@Table keyboard_user_purchase_records-->
<id column="id" jdbcType="INTEGER" property="id" />
<result column="user_id" jdbcType="INTEGER" property="userId" />
<result column="product_id" jdbcType="VARCHAR" property="productId" />
<result column="purchase_quantity" jdbcType="INTEGER" property="purchaseQuantity" />
<result column="price" jdbcType="NUMERIC" property="price" />
<result column="currency" jdbcType="VARCHAR" property="currency" />
<result column="purchase_time" jdbcType="TIMESTAMP" property="purchaseTime" />
<result column="purchase_type" jdbcType="VARCHAR" property="purchaseType" />
<result column="status" jdbcType="VARCHAR" property="status" />
<result column="payment_method" jdbcType="VARCHAR" property="paymentMethod" />
<result column="transaction_id" jdbcType="VARCHAR" property="transactionId" />
<result column="original_transaction_id" jdbcType="VARCHAR" property="originalTransactionId" />
<result column="product_ids" jdbcType="VARCHAR" property="productIds" />
<result column="purchase_date" jdbcType="TIMESTAMP" property="purchaseDate" />
<result column="expires_date" jdbcType="TIMESTAMP" property="expiresDate" />
<result column="environment" jdbcType="VARCHAR" property="environment" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, product_id, purchase_quantity, price, currency, purchase_time, purchase_type,
"status", payment_method, transaction_id, original_transaction_id, product_ids, purchase_date,
expires_date, environment
</sql>
</mapper>