feat(数据库): 添加订阅实例数据库模型与安装逻辑
添加新的数据库表结构用于存储用户订阅实例,包括创建/更新/查询接口 修改插件激活钩子以安装数据库表 更新插件依赖声明和卸载钩子实现 添加数据库模型文档说明
This commit is contained in:
parent
d812995420
commit
69c5d866d6
|
|
@ -0,0 +1,52 @@
|
||||||
|
# 数据库模型(Yoone Subscriptions)
|
||||||
|
|
||||||
|
本插件新增一个独立的数据库模型目录 `includes/models/`,用于管理用户订阅实例的持久化数据。当前实现增加了基础表结构与 CRUD 接口,方便未来接入续订、暂停、取消等业务。
|
||||||
|
|
||||||
|
## 表结构
|
||||||
|
|
||||||
|
表名:`{wp_prefix}yoone_subscriptions`
|
||||||
|
|
||||||
|
字段:
|
||||||
|
- `id` bigint(20) unsigned 主键,自增
|
||||||
|
- `user_id` bigint(20) unsigned 用户ID(WP用户)
|
||||||
|
- `product_id` bigint(20) unsigned 产品ID(WooCommerce 产品)
|
||||||
|
- `period` varchar(10) 订阅周期:`month` 或 `year`
|
||||||
|
- `qty` int(11) unsigned 订阅数量(每周期数量)
|
||||||
|
- `price_per_cycle` decimal(18,8) 每周期价格(不含周期系数;年周期计算会乘以 12)
|
||||||
|
- `status` varchar(20) 订阅状态(如 `active`、`paused`、`canceled`,当前未做枚举限制)
|
||||||
|
- `start_date` datetime 订阅开始时间
|
||||||
|
- `next_renewal_date` datetime 下次续订时间(可空)
|
||||||
|
- `created_at` datetime 创建时间
|
||||||
|
- `updated_at` datetime 更新时间
|
||||||
|
|
||||||
|
索引:
|
||||||
|
- `user_id`
|
||||||
|
- `product_id`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
安装:在插件激活时调用安装逻辑创建/升级表结构(使用 `dbDelta`)。
|
||||||
|
|
||||||
|
## 目录与类
|
||||||
|
|
||||||
|
- 目录:`wp-content/plugins/yoone-subscriptions/includes/models/`
|
||||||
|
- 类:`Yoone_Subscriptions_DB`(文件:`class-yoone-subscriptions-db.php`)
|
||||||
|
|
||||||
|
主要方法:
|
||||||
|
- `Yoone_Subscriptions_DB::install()` 创建/升级表结构
|
||||||
|
- `Yoone_Subscriptions_DB::create(array $data)` 插入订阅实例,返回插入ID
|
||||||
|
- `Yoone_Subscriptions_DB::update(int $id, array $data)` 更新订阅实例
|
||||||
|
- `Yoone_Subscriptions_DB::get(int $id)` 获取单条订阅实例
|
||||||
|
- `Yoone_Subscriptions_DB::get_by_user(int $user_id, array $args = [])` 获取某用户的订阅实例列表
|
||||||
|
|
||||||
|
## 与现有逻辑的关系
|
||||||
|
|
||||||
|
目前产品的订阅计划配置(是否启用、周期、默认数量、每周期价格、是否允许一次性购买)仍存储在产品 `postmeta` 中,见 `Yoone_Subscriptions::get_config()`。
|
||||||
|
|
||||||
|
新增数据库模型仅作为“用户订阅实例”的数据持久层,暂未在前端流程中自动写入。待未来接入续订与周期性扣费时,可在下单或激活订阅时创建实例,并结合定时任务(`wp_cron`)或队列进行续订处理。
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
- 在结账成功(`woocommerce_checkout_order_processed` 或 `woocommerce_order_status_changed`)时,根据订单行项目写入订阅实例。
|
||||||
|
- 增加状态枚举与状态迁移方法(active→paused→canceled),以及日志记录。
|
||||||
|
- 增加管理后台页面展示用户订阅列表(可筛选用户、产品、状态),支持手动操作。
|
||||||
|
- 结合支付网关对接续订扣费逻辑,写入 `next_renewal_date` 并在到期前触发续订流程。
|
||||||
|
|
@ -16,8 +16,8 @@ class Yoone_Subscriptions_Frontend {
|
||||||
// 在简单产品的 add-to-cart 区域前渲染订阅选项
|
// 在简单产品的 add-to-cart 区域前渲染订阅选项
|
||||||
add_action('woocommerce_before_add_to_cart_button', array($this, 'render_subscription_options'));
|
add_action('woocommerce_before_add_to_cart_button', array($this, 'render_subscription_options'));
|
||||||
|
|
||||||
// 校验与存储购物车项数据
|
// 校验与存储购物车项数据(Woo 默认传递 3 个参数:$passed, $product_id, $quantity)
|
||||||
add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 10, 6);
|
add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 10, 3);
|
||||||
add_filter('woocommerce_add_cart_item_data', array($this, 'add_cart_item_data'), 10, 3);
|
add_filter('woocommerce_add_cart_item_data', array($this, 'add_cart_item_data'), 10, 3);
|
||||||
|
|
||||||
// 展示购物车/订单中的订阅摘要
|
// 展示购物车/订单中的订阅摘要
|
||||||
|
|
@ -85,7 +85,7 @@ class Yoone_Subscriptions_Frontend {
|
||||||
/**
|
/**
|
||||||
* 校验加入购物车的订阅参数。
|
* 校验加入购物车的订阅参数。
|
||||||
*/
|
*/
|
||||||
public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id, $variations, $cart_item_data) {
|
public function validate_add_to_cart($passed, $product_id, $quantity) {
|
||||||
$product = wc_get_product($product_id);
|
$product = wc_get_product($product_id);
|
||||||
if (! $product) return $passed;
|
if (! $product) return $passed;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* Version: 0.1.0
|
* Version: 0.1.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
|
* Requires Plugins: woocommerce
|
||||||
* WC requires at least: 6.0
|
* WC requires at least: 6.0
|
||||||
* WC tested up to: 8.x
|
* WC tested up to: 8.x
|
||||||
* Text Domain: yoone-subscriptions
|
* Text Domain: yoone-subscriptions
|
||||||
|
|
@ -25,12 +26,19 @@ add_action('init', function() {
|
||||||
|
|
||||||
// 激活与卸载钩子
|
// 激活与卸载钩子
|
||||||
register_activation_hook(__FILE__, function() {
|
register_activation_hook(__FILE__, function() {
|
||||||
// 预留:如需创建自定义表或初始化选项,可在此处理
|
// 安装数据库表(订阅实例记录)
|
||||||
|
require_once YOONE_SUBS_PATH . 'includes/models/class-yoone-subscriptions-db.php';
|
||||||
|
if (class_exists('Yoone_Subscriptions_DB')) {
|
||||||
|
Yoone_Subscriptions_DB::install();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
register_uninstall_hook(__FILE__, function() {
|
// 卸载钩子:注意不可使用匿名函数(Closure),因为 WordPress 会序列化回调,PHP 不允许序列化 Closure。
|
||||||
// 预留:清理选项/自定义表;当前实现使用 postmeta 不做强制清理
|
function yoone_subscriptions_uninstall() {
|
||||||
});
|
// 预留:清理选项/自定义表;当前实现使用 postmeta,不做强制清理。
|
||||||
|
// 如需清理,可在此执行:删除自定义表、删除选项等。
|
||||||
|
}
|
||||||
|
register_uninstall_hook(__FILE__, 'yoone_subscriptions_uninstall');
|
||||||
|
|
||||||
// WooCommerce 依赖检查
|
// WooCommerce 依赖检查
|
||||||
add_action('plugins_loaded', function() {
|
add_action('plugins_loaded', function() {
|
||||||
|
|
@ -46,6 +54,7 @@ add_action('plugins_loaded', function() {
|
||||||
require_once YOONE_SUBS_PATH . 'includes/admin/class-yoone-subscriptions-admin.php';
|
require_once YOONE_SUBS_PATH . 'includes/admin/class-yoone-subscriptions-admin.php';
|
||||||
require_once YOONE_SUBS_PATH . 'includes/frontend/class-yoone-subscriptions-frontend.php';
|
require_once YOONE_SUBS_PATH . 'includes/frontend/class-yoone-subscriptions-frontend.php';
|
||||||
require_once YOONE_SUBS_PATH . 'includes/logging/class-yoone-subscriptions-logger.php';
|
require_once YOONE_SUBS_PATH . 'includes/logging/class-yoone-subscriptions-logger.php';
|
||||||
|
require_once YOONE_SUBS_PATH . 'includes/models/class-yoone-subscriptions-db.php';
|
||||||
|
|
||||||
// 引导
|
// 引导
|
||||||
Yoone_Subscriptions::instance();
|
Yoone_Subscriptions::instance();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue