feat(订阅): 新增订阅管理功能与分级折扣配置

- 添加后台订阅列表管理页面,支持筛选、分页和状态管理
- 引入最小起订量和分级折扣规则配置
- 优化一次性购买逻辑,不再显示订阅信息
- 添加兜底机制确保订阅表存在
- 更新需求文档
This commit is contained in:
tikkhun 2025-11-07 11:43:10 +08:00
parent 69c5d866d6
commit b172bd61bf
5 changed files with 232 additions and 6 deletions

4
docs/需求.md Normal file
View File

@ -0,0 +1,4 @@
- 扣款失败需要发邮件给客户
- 后台有用户订阅列表管理页面
- 数据存储在数据库
- 前端用户登陆后可以在 my account 页面中查看已设置的订阅

View File

@ -16,6 +16,9 @@ class Yoone_Subscriptions_Admin {
add_filter('woocommerce_product_data_tabs', array($this, 'add_tab')); add_filter('woocommerce_product_data_tabs', array($this, 'add_tab'));
add_action('woocommerce_product_data_panels', array($this, 'render_panel')); add_action('woocommerce_product_data_panels', array($this, 'render_panel'));
add_action('woocommerce_admin_process_product_meta', array($this, 'save_meta')); add_action('woocommerce_admin_process_product_meta', array($this, 'save_meta'));
// 在 WooCommerce 菜单下添加“Subscriptions”子菜单
add_action('admin_menu', array($this, 'register_submenu'));
} }
/** /**
@ -73,6 +76,17 @@ class Yoone_Subscriptions_Admin {
'description' => __('用于前端默认数量,可在产品页调整。', 'yoone-subscriptions'), 'description' => __('用于前端默认数量,可在产品页调整。', 'yoone-subscriptions'),
)); ));
// 最小起订量(仅订阅模式下生效)
woocommerce_wp_text_input(array(
'id' => 'yoone_sub_min_qty',
'label' => __('最小起订量', 'yoone-subscriptions'),
'type' => 'number',
'value' => isset($cfg['min_qty']) ? intval($cfg['min_qty']) : 1,
'custom_attributes' => array('min' => '1', 'step' => '1'),
'desc_tip' => true,
'description' => __('仅在订阅购买模式下生效,购物车与订单将进行数量校验与提示。', 'yoone-subscriptions'),
));
// 订阅价格(可选,留空则使用产品常规价) // 订阅价格(可选,留空则使用产品常规价)
woocommerce_wp_text_input(array( woocommerce_wp_text_input(array(
'id' => 'yoone_sub_price', 'id' => 'yoone_sub_price',
@ -83,6 +97,17 @@ class Yoone_Subscriptions_Admin {
'description' => __('留空表示使用产品常规价。', 'yoone-subscriptions'), 'description' => __('留空表示使用产品常规价。', 'yoone-subscriptions'),
)); ));
// 分级折扣配置(逗号分隔的“数量:折扣%”对)
woocommerce_wp_text_input(array(
'id' => 'yoone_sub_tier_rules',
'label' => __('分级折扣规则', 'yoone-subscriptions'),
'type' => 'text',
'placeholder' => '10:5,20:10',
'value' => isset($cfg['tier_rules']) ? esc_attr($cfg['tier_rules']) : '',
'desc_tip' => true,
'description' => __('格式:数量:折扣%用逗号分隔多级。如“10:5,20:10”表示≥10件打95折≥20件打9折。留空则不启用。', 'yoone-subscriptions'),
));
// 是否允许一次性购买 // 是否允许一次性购买
woocommerce_wp_checkbox(array( woocommerce_wp_checkbox(array(
'id' => 'yoone_sub_allow_onetime', 'id' => 'yoone_sub_allow_onetime',
@ -107,7 +132,9 @@ class Yoone_Subscriptions_Admin {
$enabled = isset($_POST['yoone_sub_enabled']) && 'yes' === $_POST['yoone_sub_enabled']; $enabled = isset($_POST['yoone_sub_enabled']) && 'yes' === $_POST['yoone_sub_enabled'];
$period = isset($_POST['yoone_sub_period']) ? sanitize_text_field($_POST['yoone_sub_period']) : 'month'; $period = isset($_POST['yoone_sub_period']) ? sanitize_text_field($_POST['yoone_sub_period']) : 'month';
$qty = isset($_POST['yoone_sub_qty_default']) ? absint($_POST['yoone_sub_qty_default']) : 1; $qty = isset($_POST['yoone_sub_qty_default']) ? absint($_POST['yoone_sub_qty_default']) : 1;
$min_qty = isset($_POST['yoone_sub_min_qty']) ? absint($_POST['yoone_sub_min_qty']) : 1;
$price = isset($_POST['yoone_sub_price']) ? wc_clean($_POST['yoone_sub_price']) : ''; $price = isset($_POST['yoone_sub_price']) ? wc_clean($_POST['yoone_sub_price']) : '';
$tiers = isset($_POST['yoone_sub_tier_rules']) ? sanitize_text_field($_POST['yoone_sub_tier_rules']) : '';
$onetime = isset($_POST['yoone_sub_allow_onetime']) && 'yes' === $_POST['yoone_sub_allow_onetime']; $onetime = isset($_POST['yoone_sub_allow_onetime']) && 'yes' === $_POST['yoone_sub_allow_onetime'];
$period = in_array($period, array('month','year'), true) ? $period : 'month'; $period = in_array($period, array('month','year'), true) ? $period : 'month';
@ -117,11 +144,193 @@ class Yoone_Subscriptions_Admin {
update_post_meta($post_id, Yoone_Subscriptions::META_ENABLED, $enabled ? '1' : ''); update_post_meta($post_id, Yoone_Subscriptions::META_ENABLED, $enabled ? '1' : '');
update_post_meta($post_id, Yoone_Subscriptions::META_PERIOD, $period); update_post_meta($post_id, Yoone_Subscriptions::META_PERIOD, $period);
update_post_meta($post_id, Yoone_Subscriptions::META_QTY_DEFAULT, $qty); update_post_meta($post_id, Yoone_Subscriptions::META_QTY_DEFAULT, $qty);
update_post_meta($post_id, Yoone_Subscriptions::META_MIN_QTY, max(1, $min_qty));
if ($price_v === '') { if ($price_v === '') {
delete_post_meta($post_id, Yoone_Subscriptions::META_PRICE); delete_post_meta($post_id, Yoone_Subscriptions::META_PRICE);
} else { } else {
update_post_meta($post_id, Yoone_Subscriptions::META_PRICE, $price_v); update_post_meta($post_id, Yoone_Subscriptions::META_PRICE, $price_v);
} }
if ($tiers === '') {
delete_post_meta($post_id, Yoone_Subscriptions::META_TIER_RULES);
} else {
update_post_meta($post_id, Yoone_Subscriptions::META_TIER_RULES, $tiers);
}
update_post_meta($post_id, Yoone_Subscriptions::META_ALLOW_ONETIME, $onetime ? '1' : ''); update_post_meta($post_id, Yoone_Subscriptions::META_ALLOW_ONETIME, $onetime ? '1' : '');
} }
/**
* 注册 WooCommerce Subscriptions 子菜单。
*/
public function register_submenu() {
// 仅限具有管理 WooCommerce 权限的用户可访问
add_submenu_page(
'woocommerce',
__('Subscriptions', 'yoone-subscriptions'),
__('Subscriptions', 'yoone-subscriptions'),
'manage_woocommerce',
'yoone-subscriptions',
array($this, 'render_subscriptions_page'),
56 // 显示位置(可选)
);
}
/**
* 渲染订阅列表管理页面。
* 支持按用户、产品、状态筛选与分页。
*/
public function render_subscriptions_page() {
if (! current_user_can('manage_woocommerce')) {
wp_die(__('您没有权限查看该页面。', 'yoone-subscriptions'));
}
// 处理状态更新操作pause/resume/cancel带 nonce
if (isset($_POST['yoone_sub_action']) && isset($_POST['_wpnonce']) && wp_verify_nonce($_POST['_wpnonce'], 'yoone_subscriptions_action')) {
$action = sanitize_key($_POST['yoone_sub_action']);
$sub_id = isset($_POST['yoone_sub_id']) ? absint($_POST['yoone_sub_id']) : 0;
$allowed = array('pause' => 'paused', 'resume' => 'active', 'cancel' => 'canceled');
if ($sub_id > 0 && isset($allowed[$action])) {
if (class_exists('Yoone_Subscriptions_DB')) {
Yoone_Subscriptions_DB::update($sub_id, array('status' => $allowed[$action]));
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__('状态已更新。', 'yoone-subscriptions') . '</p></div>';
}
}
}
// 读取筛选参数
$status = isset($_GET['status']) ? sanitize_key($_GET['status']) : '';
$product_id = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0;
$user_query = isset($_GET['user']) ? sanitize_text_field($_GET['user']) : '';
$paged = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1;
$per_page = 20;
$offset = ($paged - 1) * $per_page;
global $wpdb;
$table = method_exists('Yoone_Subscriptions_DB', 'table_name') ? Yoone_Subscriptions_DB::table_name() : $wpdb->prefix . 'yoone_subscriptions';
// 构造 WHERE 条件
$where = array('1=1');
$params = array();
if (! empty($status)) { $where[] = 'status = %s'; $params[] = $status; }
if ($product_id > 0) { $where[] = 'product_id = %d'; $params[] = $product_id; }
$user_clause = '';
if (! empty($user_query)) {
// 支持用用户ID或邮箱搜索
if (is_numeric($user_query)) {
$where[] = 'user_id = %d'; $params[] = absint($user_query);
} else {
// 通过邮箱查询用户ID
$user = get_user_by('email', $user_query);
if ($user) { $where[] = 'user_id = %d'; $params[] = $user->ID; }
else { $where[] = '0=1'; } // 无匹配,返回空
}
}
// 查询总数用于分页
$sql_count = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
$total = $wpdb->get_var($wpdb->prepare($sql_count, $params));
// 查询当前页数据
$sql = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where) . " ORDER BY id DESC LIMIT %d OFFSET %d";
$params_page = array_merge($params, array($per_page, $offset));
$rows = $wpdb->get_results($wpdb->prepare($sql, $params_page), ARRAY_A);
// 渲染页面
echo '<div class="wrap">';
echo '<h1 class="wp-heading-inline">' . esc_html__('Subscriptions', 'yoone-subscriptions') . '</h1>';
echo '<hr class="wp-header-end">';
// 筛选表单
echo '<form method="get">';
echo '<input type="hidden" name="page" value="yoone-subscriptions" />';
echo '<div class="tablenav top">';
echo '<div class="alignleft actions">';
echo '<select name="status">';
$statuses = array('' => __('全部状态', 'yoone-subscriptions'), 'active' => __('Active', 'yoone-subscriptions'), 'paused' => __('Paused', 'yoone-subscriptions'), 'canceled' => __('Canceled', 'yoone-subscriptions'));
foreach ($statuses as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select> ';
echo '<input type="number" name="product_id" placeholder="' . esc_attr__('产品ID', 'yoone-subscriptions') . '" value="' . esc_attr($product_id) . '" style="width:120px" /> ';
echo '<input type="text" name="user" placeholder="' . esc_attr__('用户ID或邮箱', 'yoone-subscriptions') . '" value="' . esc_attr($user_query) . '" style="width:200px" /> ';
submit_button(__('筛选'), 'secondary', null, false);
echo '</div>';
echo '</div>';
echo '</form>';
// 列表表格
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
$cols = array(
__('ID', 'yoone-subscriptions'),
__('用户', 'yoone-subscriptions'),
__('产品', 'yoone-subscriptions'),
__('周期', 'yoone-subscriptions'),
__('数量', 'yoone-subscriptions'),
__('每周期金额', 'yoone-subscriptions'),
__('状态', 'yoone-subscriptions'),
__('开始时间', 'yoone-subscriptions'),
__('下次续费', 'yoone-subscriptions'),
__('操作', 'yoone-subscriptions'),
);
foreach ($cols as $c) echo '<th>' . esc_html($c) . '</th>';
echo '</tr></thead><tbody>';
if (empty($rows)) {
echo '<tr><td colspan="10">' . esc_html__('暂无订阅记录。', 'yoone-subscriptions') . '</td></tr>';
} else {
foreach ($rows as $r) {
$user = get_user_by('id', intval($r['user_id']));
$product = wc_get_product(intval($r['product_id']));
echo '<tr>';
echo '<td>#' . intval($r['id']) . '</td>';
echo '<td>' . ($user ? esc_html($user->user_email) . ' (ID ' . intval($user->ID) . ')' : '-') . '</td>';
echo '<td>' . ($product ? esc_html($product->get_name()) . ' (ID ' . intval($product->get_id()) . ')' : 'ID ' . intval($r['product_id'])) . '</td>';
echo '<td>' . esc_html($r['period']) . '</td>';
echo '<td>' . intval($r['qty']) . '</td>';
echo '<td>' . wc_price(floatval($r['price_per_cycle'])) . '</td>';
echo '<td>' . esc_html($r['status']) . '</td>';
echo '<td>' . esc_html($r['start_date']) . '</td>';
echo '<td>' . esc_html($r['next_renewal_date']) . '</td>';
echo '<td>';
echo '<form method="post" style="display:inline-block;margin-right:6px;">' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . '<input type="hidden" name="yoone_sub_id" value="' . intval($r['id']) . '" />';
if ($r['status'] !== 'paused') {
echo '<input type="hidden" name="yoone_sub_action" value="pause" />';
submit_button(__('暂停', 'yoone-subscriptions'), 'small', null, false);
}
echo '</form>';
echo '<form method="post" style="display:inline-block;margin-right:6px;">' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . '<input type="hidden" name="yoone_sub_id" value="' . intval($r['id']) . '" />';
if ($r['status'] !== 'active') {
echo '<input type="hidden" name="yoone_sub_action" value="resume" />';
submit_button(__('恢复', 'yoone-subscriptions'), 'small', null, false);
}
echo '</form>';
echo '<form method="post" style="display:inline-block;">' . wp_nonce_field('yoone_subscriptions_action', '_wpnonce', true, false) . '<input type="hidden" name="yoone_sub_id" value="' . intval($r['id']) . '" />';
if ($r['status'] !== 'canceled') {
echo '<input type="hidden" name="yoone_sub_action" value="cancel" />';
submit_button(__('取消', 'yoone-subscriptions'), 'small', null, false);
}
echo '</form>';
echo '</td>';
echo '</tr>';
}
}
echo '</tbody></table>';
// 分页导航
if ($total > $per_page) {
$total_pages = ceil($total / $per_page);
echo '<div class="tablenav bottom"><div class="tablenav-pages">';
echo paginate_links(array(
'base' => add_query_arg(array('paged' => '%#%')),
'format' => '',
'prev_text' => __('« 上一页', 'yoone-subscriptions'),
'next_text' => __('下一页 »', 'yoone-subscriptions'),
'total' => $total_pages,
'current' => $paged,
));
echo '</div></div>';
}
echo '</div>';
}
} }

View File

@ -13,6 +13,8 @@ class Yoone_Subscriptions {
const META_QTY_DEFAULT = '_yoone_sub_qty_default'; // int >=1 const META_QTY_DEFAULT = '_yoone_sub_qty_default'; // int >=1
const META_PRICE = '_yoone_sub_price'; // decimal string const META_PRICE = '_yoone_sub_price'; // decimal string
const META_ALLOW_ONETIME = '_yoone_sub_allow_onetime'; // bool const META_ALLOW_ONETIME = '_yoone_sub_allow_onetime'; // bool
const META_MIN_QTY = '_yoone_sub_min_qty'; // int >=1最小起订量仅订阅模式下生效
const META_TIER_RULES = '_yoone_sub_tier_rules'; // string 逗号分隔“数量:折扣%”对,如 10:5,20:10
protected static $instance = null; protected static $instance = null;
@ -43,6 +45,8 @@ class Yoone_Subscriptions {
$qty = absint(get_post_meta($pid, self::META_QTY_DEFAULT, true)); $qty = absint(get_post_meta($pid, self::META_QTY_DEFAULT, true));
$price = get_post_meta($pid, self::META_PRICE, true); $price = get_post_meta($pid, self::META_PRICE, true);
$onetime = (bool) get_post_meta($pid, self::META_ALLOW_ONETIME, true); $onetime = (bool) get_post_meta($pid, self::META_ALLOW_ONETIME, true);
$min_qty = absint(get_post_meta($pid, self::META_MIN_QTY, true));
$tiers = get_post_meta($pid, self::META_TIER_RULES, true);
$period = in_array($period, array('month','year'), true) ? $period : 'month'; $period = in_array($period, array('month','year'), true) ? $period : 'month';
$qty = max(1, $qty); $qty = max(1, $qty);
$price = is_numeric($price) ? floatval($price) : 0.0; // 0 表示按产品原价 $price = is_numeric($price) ? floatval($price) : 0.0; // 0 表示按产品原价
@ -52,6 +56,8 @@ class Yoone_Subscriptions {
'qty_default' => $qty, 'qty_default' => $qty,
'price' => $price, 'price' => $price,
'allow_onetime' => $onetime, 'allow_onetime' => $onetime,
'min_qty' => max(1, $min_qty),
'tier_rules' => is_string($tiers) ? $tiers : '',
); );
} }
@ -65,6 +71,8 @@ class Yoone_Subscriptions {
'qty_default' => 1, 'qty_default' => 1,
'price' => 0.0, 'price' => 0.0,
'allow_onetime' => true, 'allow_onetime' => true,
'min_qty' => 1,
'tier_rules' => '',
); );
} }

View File

@ -124,11 +124,8 @@ class Yoone_Subscriptions_Frontend {
$period = in_array($period, array('month','year'), true) ? $period : $cfg['period']; $period = in_array($period, array('month','year'), true) ? $period : $cfg['period'];
$qty = max(1, $qty); $qty = max(1, $qty);
// 如果选择一次性购买,则不写入订阅元数据,购物车中不显示任何订阅信息
if ($cfg['allow_onetime'] && $mode === 'onetime') { if ($cfg['allow_onetime'] && $mode === 'onetime') {
// 标记为一次性购买,便于显示
$cart_item_data['yoone_subscriptions'] = array(
'mode' => 'onetime',
);
return $cart_item_data; return $cart_item_data;
} }
@ -147,8 +144,8 @@ class Yoone_Subscriptions_Frontend {
public function display_item_data($item_data, $cart_item) { public function display_item_data($item_data, $cart_item) {
if (empty($cart_item['yoone_subscriptions'])) return $item_data; if (empty($cart_item['yoone_subscriptions'])) return $item_data;
$data = $cart_item['yoone_subscriptions']; $data = $cart_item['yoone_subscriptions'];
if ($data['mode'] === 'onetime') { // 仅在选择订阅购买时展示订阅信息;一次性购买不展示任何订阅相关信息
$item_data[] = array('key' => __('购买方式', 'yoone-subscriptions'), 'value' => __('一次性购买', 'yoone-subscriptions')); if (! isset($data['mode']) || $data['mode'] !== 'subscribe') {
return $item_data; return $item_data;
} }
$period_label = $data['period'] === 'year' ? __('年', 'yoone-subscriptions') : __('月', 'yoone-subscriptions'); $period_label = $data['period'] === 'year' ? __('年', 'yoone-subscriptions') : __('月', 'yoone-subscriptions');

View File

@ -60,6 +60,14 @@ add_action('plugins_loaded', function() {
Yoone_Subscriptions::instance(); Yoone_Subscriptions::instance();
Yoone_Subscriptions_Admin::instance(); Yoone_Subscriptions_Admin::instance();
Yoone_Subscriptions_Frontend::instance(); Yoone_Subscriptions_Frontend::instance();
// 兜底:若订阅表不存在则安装(避免早期版本已激活但未创建表的情况)
global $wpdb;
$table = Yoone_Subscriptions_DB::table_name();
$exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table));
if ($exists !== $table) {
Yoone_Subscriptions_DB::install();
}
}); });
// 资源 // 资源