diff --git a/docs/数据库模型.md b/docs/数据库模型.md new file mode 100644 index 0000000..23c1e09 --- /dev/null +++ b/docs/数据库模型.md @@ -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` 并在到期前触发续订流程。 \ No newline at end of file diff --git a/includes/frontend/class-yoone-subscriptions-frontend.php b/includes/frontend/class-yoone-subscriptions-frontend.php index 4a301c9..7a8d487 100644 --- a/includes/frontend/class-yoone-subscriptions-frontend.php +++ b/includes/frontend/class-yoone-subscriptions-frontend.php @@ -16,8 +16,8 @@ class Yoone_Subscriptions_Frontend { // 在简单产品的 add-to-cart 区域前渲染订阅选项 add_action('woocommerce_before_add_to_cart_button', array($this, 'render_subscription_options')); - // 校验与存储购物车项数据 - add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 10, 6); + // 校验与存储购物车项数据(Woo 默认传递 3 个参数:$passed, $product_id, $quantity) + 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); // 展示购物车/订单中的订阅摘要 @@ -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); if (! $product) return $passed; diff --git a/includes/models/class-yoone-subscriptions-db.php b/includes/models/class-yoone-subscriptions-db.php new file mode 100644 index 0000000..2371846 --- /dev/null +++ b/includes/models/class-yoone-subscriptions-db.php @@ -0,0 +1,238 @@ +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); + } +} \ No newline at end of file diff --git a/yoone-subscriptions.php b/yoone-subscriptions.php index 9a2aacb..bb95a2f 100644 --- a/yoone-subscriptions.php +++ b/yoone-subscriptions.php @@ -6,6 +6,7 @@ * Version: 0.1.0 * Requires at least: 6.0 * Requires PHP: 7.4 + * Requires Plugins: woocommerce * WC requires at least: 6.0 * WC tested up to: 8.x * Text Domain: yoone-subscriptions @@ -25,12 +26,19 @@ add_action('init', 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() { - // 预留:清理选项/自定义表;当前实现使用 postmeta 不做强制清理 -}); +// 卸载钩子:注意不可使用匿名函数(Closure),因为 WordPress 会序列化回调,PHP 不允许序列化 Closure。 +function yoone_subscriptions_uninstall() { + // 预留:清理选项/自定义表;当前实现使用 postmeta,不做强制清理。 + // 如需清理,可在此执行:删除自定义表、删除选项等。 +} +register_uninstall_hook(__FILE__, 'yoone_subscriptions_uninstall'); // WooCommerce 依赖检查 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/frontend/class-yoone-subscriptions-frontend.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();