238 lines
10 KiB
PHP
238 lines
10 KiB
PHP
<?php
|
||
/**
|
||
* 数据库模型:订阅实例记录(基础表结构与 CRUD)。
|
||
*
|
||
* 使用场景与业务说明:
|
||
* - 产品层面的订阅计划(周期、价格、折扣等)存放在 WooCommerce 的 postmeta 中;
|
||
* - 本数据表用于保存“用户的实际订阅实例”,包括开始时间、下一次续费时间、数量、每周期应付金额、状态等;
|
||
* - 后续可以基于此表实现:自动续费(根据 next_renewal_date 触发)、冻结/暂停、取消、续订记录审计、对账等;
|
||
* - 当前阶段仅提供表结构和读写 API,不在前端自动创建订阅实例,便于之后逐步接入。
|
||
*
|
||
* 表结构字段含义(yoone_subscriptions):
|
||
* - id:主键,自增;
|
||
* - user_id:订阅所属的用户(WP 用户 ID);
|
||
* - product_id:关联的商品 ID(通常为具有订阅属性的产品);
|
||
* - period:订阅周期,目前支持 'month'(月)与 'year'(年);
|
||
* - qty:订阅的数量(例如包月数量、份数等),默认为 1;
|
||
* - price_per_cycle:每个周期需要支付的金额(DECIMAL(18,8)),保留 8 位小数以兼容各种币种与税费;
|
||
* - status:订阅当前状态,示例:active(活跃)、paused(暂停)、canceled(已取消)等;
|
||
* - start_date:订阅开始时间(DATETIME);
|
||
* - next_renewal_date:下一次续费时间(DATETIME,可空),由定时任务或业务逻辑维护;
|
||
* - created_at / updated_at:记录创建与最后更新时间戳;
|
||
* 索引:
|
||
* - KEY user_id:便于按用户查询其订阅列表;
|
||
* - KEY product_id:便于按商品维度统计与筛选;
|
||
* - KEY status:便于按状态快速过滤(例如批量续费仅处理 active)。
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class Yoone_Subscriptions_DB {
|
||
const TABLE = 'yoone_subscriptions'; // 实际表名为 {$wpdb->prefix}yoone_subscriptions
|
||
|
||
protected static $instance = null;
|
||
|
||
public static function instance() {
|
||
if (null === self::$instance) self::$instance = new self();
|
||
return self::$instance;
|
||
}
|
||
|
||
private function __construct() {}
|
||
|
||
/**
|
||
* 返回完整表名(含前缀)。
|
||
*/
|
||
public static function table_name() {
|
||
global $wpdb;
|
||
return $wpdb->prefix . self::TABLE;
|
||
}
|
||
|
||
/**
|
||
* 安装/升级表结构。
|
||
*
|
||
* 业务要点:
|
||
* - 使用 dbDelta 执行 CREATE TABLE,当表结构有变更时可自动比对并调整;
|
||
* - 使用 $wpdb->prefix 保证与站点前缀一致,避免与其他站点冲突;
|
||
* - 使用 get_charset_collate() 为表设置正确字符集与排序规则;
|
||
* - 字段设计保守,满足通用订阅场景,后续可通过迁移增加更多业务字段(如上次扣费结果、失败次数等)。
|
||
*/
|
||
public static function install() {
|
||
global $wpdb;
|
||
$table = self::table_name();
|
||
$charset_collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
|
||
|
||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||
|
||
// 说明:下方 SQL 为完整表结构定义,字段含义见文件头部注释。
|
||
$sql = "CREATE TABLE {$table} (
|
||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||
product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||
period VARCHAR(10) NOT NULL DEFAULT 'month',
|
||
qty INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
||
price_per_cycle DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||
start_date DATETIME NOT NULL,
|
||
next_renewal_date DATETIME NULL,
|
||
created_at DATETIME NOT NULL,
|
||
updated_at DATETIME NOT NULL,
|
||
PRIMARY KEY (id),
|
||
KEY user_id (user_id),
|
||
KEY product_id (product_id),
|
||
KEY status (status)
|
||
) {$charset_collate};";
|
||
|
||
dbDelta($sql);
|
||
}
|
||
|
||
/**
|
||
* 创建订阅实例。
|
||
*
|
||
* 参数与校验规则:
|
||
* - user_id / product_id:转为非负整数(absint),缺省为 0;
|
||
* - period:仅允许 'month' 或 'year',否则回落到 'month';
|
||
* - qty:数量最小为 1;
|
||
* - price_per_cycle:每周期金额,使用 floatval;
|
||
* - status:使用 sanitize_key 过滤,建议统一枚举:active / paused / canceled 等;
|
||
* - start_date / next_renewal_date:字符串形式的 DATETIME,使用 sanitize_text_field;
|
||
* - created_at / updated_at:由系统自动填充当前时间;
|
||
* 返回值:插入成功返回插入的自增 ID,失败返回 false。
|
||
*
|
||
* 业务提示:
|
||
* - 该方法不做业务级冲突检测(例如同用户同产品是否已存在 active 实例),留给上层业务处理;
|
||
* - 可在创建后根据 period 自动计算 next_renewal_date(例如 +1 month/+1 year)。
|
||
*/
|
||
public static function create($data) {
|
||
global $wpdb;
|
||
$table = self::table_name();
|
||
$now = current_time('mysql');
|
||
|
||
$defaults = array(
|
||
'user_id' => 0,
|
||
'product_id' => 0,
|
||
'period' => 'month',
|
||
'qty' => 1,
|
||
'price_per_cycle' => 0.0,
|
||
'status' => 'active',
|
||
'start_date' => $now,
|
||
'next_renewal_date' => null,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
);
|
||
$data = wp_parse_args($data, $defaults);
|
||
|
||
// 执行插入,构造字段与格式数组以确保类型安全
|
||
$inserted = $wpdb->insert(
|
||
$table,
|
||
array(
|
||
'user_id' => absint($data['user_id']),
|
||
'product_id' => absint($data['product_id']),
|
||
'period' => in_array($data['period'], array('month','year'), true) ? $data['period'] : 'month',
|
||
'qty' => max(1, intval($data['qty'])),
|
||
'price_per_cycle' => floatval($data['price_per_cycle']),
|
||
'status' => sanitize_key($data['status']),
|
||
'start_date' => sanitize_text_field($data['start_date']),
|
||
'next_renewal_date' => $data['next_renewal_date'] ? sanitize_text_field($data['next_renewal_date']) : null,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
),
|
||
array('%d','%d','%s','%d','%f','%s','%s','%s','%s','%s')
|
||
);
|
||
|
||
if (false === $inserted) return false;
|
||
return $wpdb->insert_id;
|
||
}
|
||
|
||
/**
|
||
* 更新订阅实例(部分字段更新)。
|
||
*
|
||
* 使用说明:
|
||
* - 仅允许更新允许字段(allowed 列表),其他键会被忽略;
|
||
* - 自动刷新 updated_at 为当前时间;
|
||
* - 根据不同字段类型设置对应的 SQL 格式(%d/%f/%s),保证类型安全;
|
||
* - 返回布尔,true 表示更新成功(wpdb->update 返回非 false)。
|
||
*/
|
||
public static function update($id, $data) {
|
||
global $wpdb;
|
||
$table = self::table_name();
|
||
$id = absint($id);
|
||
if ($id <= 0) return false;
|
||
|
||
$data['updated_at'] = current_time('mysql');
|
||
|
||
// 过滤并构造格式:仅采纳白名单字段
|
||
$fields = array();
|
||
$formats = array();
|
||
$allowed = array('user_id','product_id','period','qty','price_per_cycle','status','start_date','next_renewal_date','updated_at');
|
||
foreach ($allowed as $key) {
|
||
if (! array_key_exists($key, $data)) continue;
|
||
$val = $data[$key];
|
||
switch ($key) {
|
||
case 'user_id':
|
||
case 'product_id':
|
||
$fields[$key] = absint($val); $formats[] = '%d'; break;
|
||
case 'qty':
|
||
$fields[$key] = max(1, intval($val)); $formats[] = '%d'; break;
|
||
case 'price_per_cycle':
|
||
$fields[$key] = floatval($val); $formats[] = '%f'; break;
|
||
case 'period':
|
||
$fields[$key] = in_array($val, array('month','year'), true) ? $val : 'month'; $formats[] = '%s'; break;
|
||
case 'status':
|
||
$fields[$key] = sanitize_key($val); $formats[] = '%s'; break;
|
||
case 'start_date':
|
||
case 'next_renewal_date':
|
||
case 'updated_at':
|
||
$fields[$key] = sanitize_text_field($val); $formats[] = '%s'; break;
|
||
}
|
||
}
|
||
|
||
if (empty($fields)) return false; // 无有效更新字段时直接返回
|
||
return $wpdb->update($table, $fields, array('id' => $id), $formats, array('%d')) !== false;
|
||
}
|
||
|
||
/**
|
||
* 获取单个订阅实例。
|
||
*
|
||
* @param int $id 订阅实例主键 ID
|
||
* @return array|null 关联数组(字段同表结构),找不到返回 null
|
||
*/
|
||
public static function get($id) {
|
||
global $wpdb;
|
||
$table = self::table_name();
|
||
$id = absint($id);
|
||
return $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
|
||
}
|
||
|
||
/**
|
||
* 获取某用户的订阅实例列表。
|
||
*
|
||
* 过滤参数:
|
||
* - status:按状态过滤(active/paused/canceled 等),为空则不限制;
|
||
* - product_id:按商品过滤,0 或空则不限制;
|
||
* - limit / offset:分页参数,默认 limit=50, offset=0;
|
||
* 返回值:二维数组,每项为一条订阅实例记录(关联数组)。
|
||
*/
|
||
public static function get_by_user($user_id, $args = array()) {
|
||
global $wpdb;
|
||
$table = self::table_name();
|
||
$user_id = absint($user_id);
|
||
$defaults = array('status' => '', 'product_id' => 0, 'limit' => 50, 'offset' => 0);
|
||
$args = wp_parse_args($args, $defaults);
|
||
|
||
$where = array('user_id = %d');
|
||
$params = array($user_id);
|
||
|
||
if (! empty($args['status'])) { $where[] = 'status = %s'; $params[] = sanitize_key($args['status']); }
|
||
if (! empty($args['product_id'])) { $where[] = 'product_id = %d'; $params[] = absint($args['product_id']); }
|
||
|
||
$limit = max(1, absint($args['limit']));
|
||
$offset = max(0, absint($args['offset']));
|
||
|
||
// 组装分页查询语句,按 id 倒序排列,便于查看最新实例
|
||
$sql = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where) . " ORDER BY id DESC LIMIT %d OFFSET %d";
|
||
$params[] = $limit;
|
||
$params[] = $offset;
|
||
|
||
return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||
}
|
||
} |