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); } }