Files
wordpress-theme-aksrt/src/AKSRT/Shop/ProductReview.php
T
kate522 433b7cb3a6 feat(shop): category filter bar + sort/load-more 1:1 UX
Port zibll hierarchical cat tabs, multi-metric sort with price toggle,
skeleton/empty states, and REST archive payload (header/html/paginate)
while reusing product-card template for AJAX HTML.
2026-07-18 16:42:52 +08:00

513 lines
22 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Shop;
defined('ABSPATH') || exit;
class ProductReview {
private static $instance = null;
const COMMENT_TYPE = 'aksrt_product_review';
const RATING_LEVELS = [
5 => '超赞',
4 => '很棒',
3 => '不错',
2 => '一般',
1 => '较差',
0 => '很差',
];
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('aksrt_daily_cron', [$this, 'run_auto_positive_cron']);
}
public function submit_review(int $user_id, string $order_number, int $product_id, array $data): array {
if ($user_id <= 0 || $order_number === '' || $product_id <= 0) {
return ['success' => false, 'message' => __('参数错误', 'aksrt')];
}
if (!$this->can_review($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')];
}
if ($this->has_reviewed($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('已评价过该商品', 'aksrt')];
}
$rating_product = max(0, min(5, (int) ($data['rating_product'] ?? 5)));
$rating_service = max(0, min(5, (int) ($data['rating_service'] ?? 5)));
$rating_shipping = max(0, min(5, (int) ($data['rating_shipping'] ?? 5)));
$average = round(($rating_product + $rating_service + $rating_shipping) / 3, 1);
$content = sanitize_textarea_field((string) ($data['content'] ?? ''));
$images = [];
$max_images = $this->get_max_images();
foreach ((array) ($data['images'] ?? []) as $img) {
if (count($images) >= $max_images) {
break;
}
$stored = $this->normalize_review_image((string) $img, $user_id);
if ($stored !== '') {
$images[] = $stored;
}
}
$comment_id = wp_insert_comment([
'comment_post_ID' => $product_id,
'user_id' => $user_id,
'comment_content' => $content ?: __('好评!', 'aksrt'),
'comment_type' => self::COMMENT_TYPE,
'comment_approved' => 1,
]);
if (!$comment_id) {
return ['success' => false, 'message' => __('评价提交失败', 'aksrt')];
}
update_comment_meta($comment_id, '_aksrt_order_number', $order_number);
update_comment_meta($comment_id, '_aksrt_rating_product', $rating_product);
update_comment_meta($comment_id, '_aksrt_rating_service', $rating_service);
update_comment_meta($comment_id, '_aksrt_rating_shipping', $rating_shipping);
update_comment_meta($comment_id, '_aksrt_rating_average', $average);
if (!empty($images)) {
update_comment_meta($comment_id, '_aksrt_review_images', $images);
}
$scores = $this->refresh_product_scores($product_id);
update_post_meta($product_id, '_aksrt_score_average', (float) ($scores['average'] ?? $average));
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$status = (string) ($order['status'] ?? '');
if ($status === \AKSRT\Payment\Order::STATUS_PENDING_REVIEW) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('用户已评价', 'aksrt')]
);
}
}
return ['success' => true, 'comment_id' => $comment_id];
}
public function can_review(int $user_id, string $order_number, int $product_id): bool {
if (!class_exists('\AKSRT\Payment\OrderRepository')) return false;
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) return false;
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [
\AKSRT\Payment\Order::STATUS_COMPLETED,
\AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
\AKSRT\Payment\Order::STATUS_DELIVERED,
], true)) {
return false;
}
if (class_exists('\AKSRT\Payment\AfterSale')) {
$tickets = \AKSRT\Payment\AfterSale::get_instance()->get_list([
'order_number' => $order_number,
'limit' => 1,
]);
foreach (($tickets['items'] ?? []) as $ticket) {
if (in_array(($ticket['status'] ?? ''), ['pending', 'processing'], true)) {
return false;
}
}
}
return true;
}
public function has_reviewed(int $user_id, string $order_number, int $product_id): bool {
$comments = get_comments([
'post_id' => $product_id,
'user_id' => $user_id,
'type' => self::COMMENT_TYPE,
'number' => 1,
'meta_query' => [
['key' => '_aksrt_order_number', 'value' => $order_number],
],
]);
return !empty($comments);
}
public function get_reviews(int $product_id, array $args = []): array {
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
$filter = sanitize_key((string) ($args['filter'] ?? ''));
$query_args = [
'post_id' => $product_id,
'type' => self::COMMENT_TYPE,
'status' => 'approve',
'number' => $per_page,
'offset' => ($page - 1) * $per_page,
'orderby' => 'comment_date',
'order' => 'DESC',
];
if ($filter === 'good') {
$query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 4, 'compare' => '>=', 'type' => 'NUMERIC']];
} elseif ($filter === 'bad') {
$query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 3, 'compare' => '<', 'type' => 'NUMERIC']];
} elseif ($filter === 'has_image') {
$query_args['meta_query'] = [['key' => '_aksrt_review_images', 'compare' => 'EXISTS']];
}
$comments = get_comments($query_args);
$total = (int) get_comments(array_merge($query_args, ['count' => true, 'number' => 0, 'offset' => 0]));
$items = [];
foreach ($comments as $comment) {
$cid = (int) $comment->comment_ID;
$items[] = [
'id' => $cid,
'user_id' => (int) $comment->user_id,
'user_name' => (string) $comment->comment_author,
'user_avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url((int) $comment->user_id) : get_avatar_url((int) $comment->user_id),
'content' => (string) $comment->comment_content,
'date' => (string) $comment->comment_date,
'rating_product' => (int) get_comment_meta($cid, '_aksrt_rating_product', true),
'rating_service' => (int) get_comment_meta($cid, '_aksrt_rating_service', true),
'rating_shipping' => (int) get_comment_meta($cid, '_aksrt_rating_shipping', true),
'rating_average' => (float) get_comment_meta($cid, '_aksrt_rating_average', true),
'images' => (array) (get_comment_meta($cid, '_aksrt_review_images', true) ?: []),
'order_number' => (string) get_comment_meta($cid, '_aksrt_order_number', true),
];
}
return ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
}
public function get_product_scores(int $product_id): array {
$cached = get_post_meta($product_id, '_aksrt_review_scores', true);
if (is_array($cached)) {
return $cached;
}
return $this->refresh_product_scores($product_id);
}
public function refresh_product_scores(int $product_id): array {
global $wpdb;
$results = $wpdb->get_row($wpdb->prepare(
"SELECT COUNT(*) as total,
AVG(CAST(m.meta_value AS DECIMAL(3,1))) as average
FROM {$wpdb->comments} c
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'",
$product_id, self::COMMENT_TYPE
), ARRAY_A);
$total = (int) ($results['total'] ?? 0);
$average = $total > 0 ? round((float) ($results['average'] ?? 0), 1) : 0;
$good_count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->comments} c
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'
AND CAST(m.meta_value AS DECIMAL(3,1)) >= 4",
$product_id, self::COMMENT_TYPE
));
$bad_count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->comments} c
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'
AND CAST(m.meta_value AS DECIMAL(3,1)) < 3",
$product_id, self::COMMENT_TYPE
));
$has_image = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->comments} c
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_review_images'
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'",
$product_id, self::COMMENT_TYPE
));
$scores = [
'total' => $total,
'average' => $average,
'good' => $good_count,
'bad' => $bad_count,
'has_image' => $has_image,
'level' => self::RATING_LEVELS[min(5, max(0, (int) round($average)))] ?? '',
];
update_post_meta($product_id, '_aksrt_review_scores', $scores);
return $scores;
}
public function run_auto_positive_cron(): void {
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
return;
}
$shop = \AKSRT\Settings\Settings::get_shop_settings();
$max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15));
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_day} days"));
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$orders = $repo->get_orders_admin([
'status' => \AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
'limit' => 200,
]);
foreach ($orders as $order) {
$completed_at = (string) ($order['completed_at'] ?? $order['delivered_at'] ?? '');
if ($completed_at === '' || $completed_at > $cutoff) {
continue;
}
$user_id = (int) ($order['user_id'] ?? 0);
$order_number = (string) ($order['order_number'] ?? '');
if ($user_id <= 0 || $order_number === '') continue;
$items = class_exists('AKSRT\\Shop\\Shop')
? Shop::get_instance()->get_order_items($order_number)
: [];
$product_ids = array_column($items, 'product_id');
if (empty($product_ids) && !empty($order['product_id'])) {
$product_ids = [(int) $order['product_id']];
}
foreach ($product_ids as $pid) {
if (!$this->has_reviewed($user_id, $order_number, (int) $pid)) {
$this->submit_review($user_id, $order_number, (int) $pid, [
'rating_product' => 5,
'rating_service' => 5,
'rating_shipping' => 5,
'content' => __('系统默认好评', 'aksrt'),
]);
}
}
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, [
'note' => sprintf(__('超过 %d 天未评价,系统自动好评。', 'aksrt'), $max_day),
]);
}
}
/**
* Remaining seconds until auto-positive review, or 0 if unknown/closed.
*/
public function get_auto_review_remaining_seconds(string $order_number): int {
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
return 0;
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order) {
return 0;
}
$anchor = (string) ($order['delivered_at'] ?? $order['completed_at'] ?? $order['updated_at'] ?? $order['created_at'] ?? '');
if ($anchor === '') {
return 0;
}
$shop = \AKSRT\Settings\Settings::get_shop_settings();
$max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15));
$deadline = strtotime($anchor . ' +' . $max_day . ' days');
if (!$deadline) {
return 0;
}
$remain = $deadline - time();
return $remain > 0 ? (int) $remain : 0;
}
/**
* Whether shipping score row should be shown for this order.
*/
public function order_needs_shipping_score(array $order): bool {
$method = is_array($order['shipping_method'] ?? null) ? $order['shipping_method'] : [];
$value = strtolower((string) ($method['value'] ?? $method['type'] ?? $order['delivery_type'] ?? ''));
if ($value === '') {
// Prefer showing logistics when shipping records or fee exist.
if (!empty($order['shipping_records']) || !empty($order['shipping']['count'])) {
return true;
}
$fee = (float) ($order['shipping_fee'] ?? ($order['shipping_method']['fee'] ?? 0));
return $fee > 0;
}
if (in_array($value, ['virtual', 'auto', 'none', 'offline', 'self', 'pickup', 'no_shipping'], true)) {
return false;
}
return true;
}
/**
* Build order review modal HTML (zibll order_comment_modal parity).
*
* @return array{success:bool,html?:string,message?:string}
*/
public function get_review_modal_html(int $user_id, string $order_number): array {
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) {
return ['success' => false, 'message' => __('订单数据异常', 'aksrt')];
}
$product_id = 0;
$items = class_exists('\AKSRT\Shop\Shop')
? Shop::get_instance()->get_order_items($order_number)
: [];
if (!empty($items[0]['product_id'])) {
$product_id = (int) $items[0]['product_id'];
} elseif (!empty($order['product_id'])) {
$product_id = (int) $order['product_id'];
}
if ($product_id <= 0) {
return ['success' => false, 'message' => __('订单商品无效', 'aksrt')];
}
if ($this->has_reviewed($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('订单已评价', 'aksrt')];
}
if (!$this->can_review($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')];
}
$shop = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_shop_settings()
: [];
$placeholder = (string) ($shop['timing']['comment_placeholder'] ?? '');
if ($placeholder === '') {
$placeholder = __('展开说说对商品的看法吧~', 'aksrt');
}
$img_num = max(0, min(9, (int) ($shop['timing']['comment_img_num'] ?? 3)));
$need_shipping = $this->order_needs_shipping_score($order);
$remain = $this->get_auto_review_remaining_seconds($order_number);
$score_html = '';
$score_html .= $this->render_score_row(__('商品评分', 'aksrt'), 'product');
if ($need_shipping) {
$score_html .= $this->render_score_row(__('物流服务', 'aksrt'), 'shipping');
}
$score_html .= $this->render_score_row(__('服务态度', 'aksrt'), 'service');
$img_html = '';
if ($img_num > 0) {
$img_html = '<div class="form-upload mb10">';
$img_html .= '<div class="em09 muted-2-color mb6">' . esc_html__('图片', 'aksrt') . '</div>';
$img_html .= '<div class="preview" data-aksrt-review-preview data-max="' . esc_attr((string) $img_num) . '">';
$img_html .= '<div class="add" data-aksrt-review-add role="button" tabindex="0" aria-label="' . esc_attr__('添加图片', 'aksrt') . '"></div>';
$img_html .= '</div>';
$img_html .= '<input class="hide" type="file" data-aksrt-review-file multiple accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" />';
$img_html .= '</div>';
}
$title = '<b>' . esc_html__('评价订单', 'aksrt') . '</b>';
if ($remain > 0) {
$title .= '<span class="px12 muted-2-color ml6">'
. '<span data-aksrt-review-countdown="' . esc_attr((string) $remain) . '">' . esc_html($this->format_countdown($remain)) . '</span>'
. esc_html__('后自动好评', 'aksrt')
. '</span>';
}
$html = '<div class="touch border-title">' . $title . '</div>';
$html .= '<button type="button" class="close abs-close aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<form class="order-comment-from form-upload" data-aksrt-order-review-form data-order-number="' . esc_attr($order_number) . '" data-product-id="' . esc_attr((string) $product_id) . '" data-need-shipping="' . ($need_shipping ? '1' : '0') . '">';
$html .= '<div class="mb20">' . $score_html . '</div>';
$html .= '<div class="mb20"><textarea class="form-control" name="comment" rows="4" placeholder="' . esc_attr($placeholder) . '"></textarea></div>';
$html .= $img_html;
$html .= '<div class="review-error" data-aksrt-review-error hidden></div>';
$html .= '<div class="but-average modal-buts">';
$html .= '<button type="button" class="but" data-aksrt-remote-modal-close>' . esc_html__('取消', 'aksrt') . '</button>';
$html .= '<button type="submit" class="but c-blue btn-primary" data-aksrt-review-submit><i class="fa fa-check" aria-hidden="true"></i> ' . esc_html__('确认提交', 'aksrt') . '</button>';
$html .= '</div></form>';
return [
'success' => true,
'html' => $html,
'product_id' => $product_id,
'need_shipping' => $need_shipping,
'img_max' => $img_num,
'remain_seconds' => $remain,
];
}
private function render_score_row(string $label, string $key): string {
return '<div class="mb10 shop-score-box flex ac" data-aksrt-score-box data-score-key="' . esc_attr($key) . '" score-value="0">'
. '<div class="em09 muted-2-color score-title">' . esc_html($label) . '</div>'
. '<div class="score-icon em16 mr10 ml20" style="height:30px;"></div>'
. '<div class="score-text em09 muted-2-color"></div>'
. '<input class="score-input" type="hidden" name="score[' . esc_attr($key) . ']" value="0">'
. '</div>';
}
private function format_countdown(int $seconds): string {
$seconds = max(0, $seconds);
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($d > 0) {
return sprintf('%d天%02d:%02d:%02d', $d, $h, $m, $s);
}
return sprintf('%02d:%02d:%02d', $h, $m, $s);
}
/**
* Accept https URL or data:image/*;base64,... and return a public URL.
*/
private function normalize_review_image(string $raw, int $user_id): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
if (preg_match('#^https?://#i', $raw)) {
return esc_url_raw($raw);
}
if (!preg_match('#^data:image/(png|jpe?g|gif|webp);base64,#i', $raw, $m)) {
return '';
}
$binary = base64_decode(substr($raw, strpos($raw, ',') + 1), true);
if ($binary === false || $binary === '') {
return '';
}
if (strlen($binary) > 5 * 1024 * 1024) {
return '';
}
$ext = strtolower($m[1]);
if ($ext === 'jpeg') {
$ext = 'jpg';
}
$filename = 'review-' . $user_id . '-' . wp_generate_password(8, false) . '.' . $ext;
$upload = wp_upload_bits($filename, null, $binary);
if (!empty($upload['error']) || empty($upload['url'])) {
return '';
}
return esc_url_raw((string) $upload['url']);
}
private function get_max_images(): int {
if (!class_exists('\AKSRT\Settings\Settings')) {
return 9;
}
$shop = \AKSRT\Settings\Settings::get_shop_settings();
return max(0, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9)));
}
}