get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // 候选人主表 dbDelta("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}im_candidates ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, first_name VARCHAR(100) NOT NULL DEFAULT '', last_name VARCHAR(100) NOT NULL DEFAULT '', preferred_name VARCHAR(100) NOT NULL DEFAULT '', email VARCHAR(200) NOT NULL DEFAULT '', phone VARCHAR(50) NOT NULL DEFAULT '', location VARCHAR(200) NOT NULL DEFAULT '', degree_level VARCHAR(50) NOT NULL DEFAULT '', university VARCHAR(200) NOT NULL DEFAULT '', major VARCHAR(200) NOT NULL DEFAULT '', grad_year VARCHAR(10) NOT NULL DEFAULT '', gpa VARCHAR(20) NOT NULL DEFAULT '', deans_list TINYINT(1) NOT NULL DEFAULT 0, ug_university VARCHAR(200) NOT NULL DEFAULT '', ug_major VARCHAR(200) NOT NULL DEFAULT '', ug_grad_year VARCHAR(10) NOT NULL DEFAULT '', ms_university VARCHAR(200) NOT NULL DEFAULT '', ms_major VARCHAR(200) NOT NULL DEFAULT '', ms_grad_year VARCHAR(10) NOT NULL DEFAULT '', languages TEXT, teaching_exp TEXT, has_achievement TINYINT(1) NOT NULL DEFAULT 0, achievement_type VARCHAR(200) NOT NULL DEFAULT '', achievement_desc TEXT, subjects TEXT, extra_notes TEXT, status VARCHAR(20) NOT NULL DEFAULT 'applied', reject_reason TEXT, apply_token VARCHAR(64) NOT NULL DEFAULT '' COMMENT '详细表单专属 token,防止他人访问', apply_token_used TINYINT(1) NOT NULL DEFAULT 0, apply_opened_at DATETIME DEFAULT NULL, ca_highschool TINYINT(1) NOT NULL DEFAULT 0, ca_highschool_name VARCHAR(200) NOT NULL DEFAULT '', country VARCHAR(100) NOT NULL DEFAULT '', city VARCHAR(200) NOT NULL DEFAULT '', training_token VARCHAR(64) NOT NULL DEFAULT '', training_opened_at DATETIME DEFAULT NULL, training_completed_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY email (email), KEY status (status), KEY apply_token (apply_token), KEY training_token (training_token) ) $charset;"); // 面试 Token 表 dbDelta("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}im_interview_tokens ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, candidate_id BIGINT(20) UNSIGNED NOT NULL, token VARCHAR(64) NOT NULL DEFAULT '', expires_at DATETIME NOT NULL, is_used TINYINT(1) NOT NULL DEFAULT 0, video_path TEXT, video_filename VARCHAR(255) NOT NULL DEFAULT '', sent_at DATETIME DEFAULT NULL, opened_at DATETIME DEFAULT NULL, submitted_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY token (token), KEY candidate_id (candidate_id) ) $charset;"); // 附件上传记录表 dbDelta("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}im_attachments ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, candidate_id BIGINT(20) UNSIGNED NOT NULL, file_path TEXT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', file_type VARCHAR(50) NOT NULL DEFAULT '', uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY candidate_id (candidate_id) ) $charset;"); if ($current_version === '1.1') { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'applied'"); $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD COLUMN IF NOT EXISTS reject_reason TEXT"); $wpdb->query("UPDATE {$wpdb->prefix}im_candidates SET status='applied' WHERE status='pending'"); $wpdb->query("UPDATE {$wpdb->prefix}im_candidates SET status='screening' WHERE status='submitted' AND apply_token_used=1"); $wpdb->query("UPDATE {$wpdb->prefix}im_candidates c JOIN {$wpdb->prefix}im_interview_tokens t ON c.id=t.candidate_id AND t.is_used=1 SET c.status='completed' "); } if (version_compare($current_version, '1.4', '<')) { // Bulletproof method to add columns for older MySQL versions that fail on "ADD COLUMN IF NOT EXISTS" $cand_cols = $wpdb->get_col("SHOW COLUMNS FROM {$wpdb->prefix}im_candidates"); if (!in_array('apply_opened_at', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD apply_opened_at DATETIME DEFAULT NULL"); } if (!in_array('country', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD country VARCHAR(100) NOT NULL DEFAULT ''"); } if (!in_array('city', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD city VARCHAR(200) NOT NULL DEFAULT ''"); } $tok_cols = $wpdb->get_col("SHOW COLUMNS FROM {$wpdb->prefix}im_interview_tokens"); if (!in_array('opened_at', $tok_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_interview_tokens ADD opened_at DATETIME DEFAULT NULL"); } update_option('im_db_version', '1.4'); } if (version_compare(get_option('im_db_version'), '1.5', '<')) { $cand_cols = $wpdb->get_col("SHOW COLUMNS FROM {$wpdb->prefix}im_candidates"); if (!in_array('ca_highschool', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD ca_highschool TINYINT(1) NOT NULL DEFAULT 0"); } if (!in_array('ca_highschool_name', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD ca_highschool_name VARCHAR(200) NOT NULL DEFAULT ''"); } update_option('im_db_version', '1.5'); } if (version_compare(get_option('im_db_version'), '1.6', '<')) { $cand_cols = $wpdb->get_col("SHOW COLUMNS FROM {$wpdb->prefix}im_candidates"); if (!in_array('ms_university', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD ms_university VARCHAR(200) NOT NULL DEFAULT ''"); } if (!in_array('ms_major', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD ms_major VARCHAR(200) NOT NULL DEFAULT ''"); } if (!in_array('ms_grad_year', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD ms_grad_year VARCHAR(10) NOT NULL DEFAULT ''"); } update_option('im_db_version', '1.6'); } if (version_compare(get_option('im_db_version'), '1.7', '<')) { $cand_cols = $wpdb->get_col("SHOW COLUMNS FROM {$wpdb->prefix}im_candidates"); if (!in_array('training_token', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD training_token VARCHAR(64) NOT NULL DEFAULT ''"); $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD INDEX training_token (training_token)"); } if (!in_array('training_opened_at', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD training_opened_at DATETIME DEFAULT NULL"); } if (!in_array('training_completed_at', $cand_cols)) { $wpdb->query("ALTER TABLE {$wpdb->prefix}im_candidates ADD training_completed_at DATETIME DEFAULT NULL"); } // Migrate existing 'hired' candidates to 'training' status $wpdb->query("UPDATE {$wpdb->prefix}im_candidates SET status='training' WHERE status='hired'"); update_option('im_db_version', '1.7'); } } add_action('init', 'im_maybe_create_tables', 1); /* ============================================================ IM_Candidate ============================================================ */ if (!class_exists('IM_Candidate')) { class IM_Candidate { public static function table() { global $wpdb; return $wpdb->prefix . 'im_candidates'; } /** 第一步:仅凭 Join Us 简表单创建候选人(只有姓名、邮箱) */ public static function create_from_joinus(string $first_name, string $last_name, string $email) { global $wpdb; $email = sanitize_email($email); $existing = self::get_by_email($email); if ($existing) return (int) $existing->id; $apply_token = bin2hex(random_bytes(24)); // 48 位申请表 token $wpdb->insert(self::table(), [ 'first_name' => sanitize_text_field($first_name), 'last_name' => sanitize_text_field($last_name), 'email' => $email, 'apply_token' => $apply_token, 'status' => 'applied', ]); return $wpdb->insert_id ?: false; } /** 第二步:详细表单提交后,更新候选人全部信息 */ public static function update_full(int $id, array $data) { global $wpdb; return $wpdb->update(self::table(), [ 'preferred_name' => sanitize_text_field($data['preferred_name'] ?? ''), 'phone' => sanitize_text_field($data['phone'] ?? ''), 'country' => sanitize_text_field($data['country'] ?? ''), 'city' => sanitize_text_field($data['city'] ?? ''), 'degree_level' => sanitize_text_field($data['degree_level'] ?? ''), 'university' => sanitize_text_field($data['university'] ?? ''), 'major' => sanitize_text_field($data['major'] ?? ''), 'grad_year' => sanitize_text_field($data['grad_year'] ?? ''), 'gpa' => sanitize_text_field($data['gpa'] ?? ''), 'deans_list' => (int) ($data['deans_list'] ?? 0), 'ug_university' => sanitize_text_field($data['ug_university'] ?? ''), 'ug_major' => sanitize_text_field($data['ug_major'] ?? ''), 'ug_grad_year' => sanitize_text_field($data['ug_grad_year'] ?? ''), 'ms_university' => sanitize_text_field($data['ms_university'] ?? ''), 'ms_major' => sanitize_text_field($data['ms_major'] ?? ''), 'ms_grad_year' => sanitize_text_field($data['ms_grad_year'] ?? ''), 'ca_highschool' => (int) ($data['ca_highschool'] ?? 0), 'ca_highschool_name' => sanitize_text_field($data['ca_highschool_name'] ?? ''), 'languages' => sanitize_textarea_field($data['languages'] ?? ''), 'teaching_exp' => sanitize_textarea_field($data['teaching_exp'] ?? ''), 'has_achievement' => (int) ($data['has_achievement'] ?? 0), 'achievement_type' => sanitize_text_field($data['achievement_type'] ?? ''), 'achievement_desc' => sanitize_textarea_field($data['achievement_desc'] ?? ''), 'subjects' => is_array($data['subjects'] ?? null) ? json_encode(array_values($data['subjects']), JSON_UNESCAPED_UNICODE) : '[]', 'extra_notes' => sanitize_textarea_field($data['extra_notes'] ?? ''), 'status' => 'screening', 'apply_token_used' => 1, ], ['id' => $id]); } public static function get(int $id) { global $wpdb; return $wpdb->get_row($wpdb->prepare('SELECT * FROM ' . self::table() . ' WHERE id=%d', $id)); } public static function mark_apply_opened(int $id) { global $wpdb; return $wpdb->query($wpdb->prepare('UPDATE ' . self::table() . ' SET apply_opened_at = CURRENT_TIMESTAMP WHERE id = %d AND apply_opened_at IS NULL', $id)); } public static function get_by_email(string $email) { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE email=%s', sanitize_email($email) )); } public static function get_by_apply_token(string $token) { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE apply_token=%s AND apply_token_used=0', $token )); } public static function get_by_apply_token_any(string $token) { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE apply_token=%s', $token )); } public static function get_list(array $args = []) { global $wpdb; $limit = (int) ($args['per_page'] ?? 20); $offset = ((int) ($args['page'] ?? 1) - 1) * $limit; [$where, $params] = self::_where($args); $sql = 'SELECT * FROM ' . self::table() . " WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d"; return $wpdb->get_results($wpdb->prepare($sql, ...array_merge($params, [$limit, $offset]))); } public static function count(array $args = []) { global $wpdb; [$where, $params] = self::_where($args); $sql = 'SELECT COUNT(*) FROM ' . self::table() . " WHERE $where"; return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, ...$params)) : $wpdb->get_var($sql)); } public static function update_status(int $id, string $status) { global $wpdb; return $wpdb->update(self::table(), ['status' => $status], ['id' => $id]); } public static function reject(int $id, string $reason) { global $wpdb; return $wpdb->update(self::table(), ['status' => 'rejected', 'reject_reason' => sanitize_textarea_field($reason)], ['id' => $id]); } /** 删除候选人及其所有关联数据和文件 */ public static function delete_with_files(int $id): bool { global $wpdb; $c = self::get($id); if (!$c) return false; $upload_dir = wp_upload_dir(); // 删除申请附件目录 $app_dir = $upload_dir['basedir'] . '/im-applications/' . $id; if (is_dir($app_dir)) { self::_rmdir($app_dir); } // 删除面试视频目录 $iv_dir = $upload_dir['basedir'] . '/interviews/' . $id; if (is_dir($iv_dir)) { self::_rmdir($iv_dir); } // 删除数据库记录 $wpdb->delete(IM_Attachment::table(), ['candidate_id' => $id]); $wpdb->delete(IM_Token::table(), ['candidate_id' => $id]); $wpdb->delete(self::table(), ['id' => $id]); return true; } private static function _rmdir(string $dir): void { $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($files as $f) { $f->isDir() ? rmdir($f->getRealPath()) : unlink($f->getRealPath()); } rmdir($dir); } public static function get_by_training_token(string $token) { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE training_token=%s AND status=%s', $token, 'training' )); } public static function get_by_training_token_any(string $token) { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE training_token=%s', $token )); } public static function generate_training_token(int $id): string|false { global $wpdb; $c = self::get($id); if (!$c) return false; if ($c->training_token) { return add_query_arg('im_training_token', $c->training_token, IM_TRAINING_PAGE_URL); } $token = bin2hex(random_bytes(24)); $ok = $wpdb->update(self::table(), [ 'training_token' => $token, 'status' => 'training', ], ['id' => $id]); if ($ok === false) return false; return add_query_arg('im_training_token', $token, IM_TRAINING_PAGE_URL); } public static function mark_training_opened(int $id) { global $wpdb; return $wpdb->query($wpdb->prepare( 'UPDATE ' . self::table() . ' SET training_opened_at = CURRENT_TIMESTAMP WHERE id = %d AND training_opened_at IS NULL', $id )); } public static function mark_training_completed(int $id) { global $wpdb; return $wpdb->update(self::table(), [ 'status' => 'trained', 'training_completed_at' => current_time('mysql'), ], ['id' => $id]); } public static function get_subjects($candidate): array { if (is_numeric($candidate)) $candidate = self::get((int) $candidate); if (!$candidate) return []; $s = json_decode($candidate->subjects ?? '[]', true); if (!is_array($s)) return []; $titles = []; foreach ($s as $id) { if (is_numeric($id)) { $post = get_post((int) $id); $titles[] = $post ? $post->post_title : (string) $id; } else { $titles[] = (string) $id; } } return $titles; } private static function _where(array $args): array { global $wpdb; $where = ['1=1']; $params = []; if (!empty($args['status']) && in_array($args['status'], ['applied', 'screening', 'invited', 'completed', 'rejected', 'hired', 'training', 'trained'], true)) { $where[] = 'status=%s'; $params[] = $args['status']; } if (!empty($args['search'])) { $like = '%' . $wpdb->esc_like($args['search']) . '%'; $where[] = '(first_name LIKE %s OR last_name LIKE %s OR email LIKE %s)'; array_push($params, $like, $like, $like); } return [implode(' AND ', $where), $params]; } } } // end IM_Candidate /* ============================================================ IM_Attachment ============================================================ */ if (!class_exists('IM_Attachment')) { class IM_Attachment { public static function table() { global $wpdb; return $wpdb->prefix . 'im_attachments'; } public static function add(int $candidate_id, string $path, string $name, string $type) { global $wpdb; return $wpdb->insert(self::table(), [ 'candidate_id' => $candidate_id, 'file_path' => $path, 'file_name' => $name, 'file_type' => $type, ]); } public static function get_by_candidate(int $candidate_id) { global $wpdb; return $wpdb->get_results($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE candidate_id=%d ORDER BY uploaded_at ASC', $candidate_id )); } } } // end IM_Attachment /* ============================================================ IM_Token ============================================================ */ if (!class_exists('IM_Token')) { class IM_Token { public static function table() { global $wpdb; return $wpdb->prefix . 'im_interview_tokens'; } public static function generate(int $candidate_id): string|false { global $wpdb; $expires_at = date('Y-m-d H:i:s', current_time('timestamp') + 86400 * 3); $sent_at = current_time('mysql'); // Check if token already exists for candidate $tk = $wpdb->get_row($wpdb->prepare('SELECT * FROM ' . self::table() . ' WHERE candidate_id=%d ORDER BY id DESC LIMIT 1', $candidate_id)); if ($tk) { $token = $tk->token; // Update existing token to reset state and extend validity $ok = $wpdb->update(self::table(), [ 'expires_at' => $expires_at, 'sent_at' => $sent_at, 'is_used' => 0, 'opened_at' => null, 'video_path' => null, 'video_filename' => null, 'submitted_at' => null ], ['id' => $tk->id]); if ($ok === false) return false; } else { $token = bin2hex(random_bytes(32)); $ok = $wpdb->insert(self::table(), [ 'candidate_id' => $candidate_id, 'token' => $token, 'expires_at' => $expires_at, 'is_used' => 0, 'sent_at' => $sent_at, ]); if (!$ok) return false; } IM_Candidate::update_status($candidate_id, 'invited'); return add_query_arg('im_token', $token, IM_INTERVIEW_PAGE_URL); } public static function validate(string $token): ?object { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE token=%s AND is_used=0 AND expires_at>%s', $token, current_time('mysql') )) ?: null; } public static function mark_opened(int $id): bool { global $wpdb; $now = current_time('mysql'); $expires = date('Y-m-d H:i:s', current_time('timestamp') + 86400); // 24 hours return (bool) $wpdb->query($wpdb->prepare( 'UPDATE ' . self::table() . ' SET opened_at = %s, expires_at = %s WHERE id = %d AND opened_at IS NULL', $now, $expires, $id )); } public static function get_by_token(string $token): ?object { global $wpdb; return $wpdb->get_row($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE token=%s', $token )) ?: null; } public static function get_by_candidate(int $candidate_id): array { global $wpdb; return $wpdb->get_results($wpdb->prepare( 'SELECT * FROM ' . self::table() . ' WHERE candidate_id=%d ORDER BY created_at DESC', $candidate_id )) ?: []; } public static function mark_used(int $id, string $path, string $filename): bool { global $wpdb; return (bool) $wpdb->update(self::table(), [ 'is_used' => 1, 'video_path' => $path, 'video_filename' => $filename, 'submitted_at' => current_time('mysql'), ], ['id' => $id]); } } } // end IM_Token /* ============================================================ IM_Mailer ============================================================ */ if (!class_exists('IM_Mailer')) { class IM_Mailer { /** * Join Us 简表单提交后 → 发给候选人的确认邮件 * 邮件中包含详细申请表单的专属链接(带 apply_token) */ public static function send_joinus_confirmation(int $candidate_id): bool { $c = IM_Candidate::get($candidate_id); if (!$c) return false; $name = esc_html($c->first_name); $apply_url = add_query_arg('im_apply_token', $c->apply_token, IM_APPLY_PAGE_URL); $subject = 'Next Step: Complete Your Application'; return self::send($c->email, $subject, self::wrap($subject, "
Dear {$name},
Thank you for your interest in joining YStar Edu. We have received your initial registration.
To proceed, please complete the detailed application form by clicking the button below. This form will collect additional information such as your educational background, language proficiency, and teaching subjects.
If the button does not work, you may copy and paste the following link into your browser:
{$apply_url}
If you have any questions, please feel free to reply to this email. We are happy to assist you.
Best regards,
Recruitment Team
YStar Edu
Dear {$name},
Your application form has been successfully submitted! Our team will contact you after review.
The review process typically takes 3–5 business days. Thank you for your patience.
Best regards,
Recruitment Team
YStar Edu
Dear {$name},
Thank you for submitting your application. We are pleased to invite you to a video interview.
Your interview will focus on the subjects you selected in your application. The questions are designed to assess your subject knowledge and teaching ability.
You will be asked up to three questions in total.
Please click the button below to access the interview page. You will be able to review the questions and upload your video.
Supported formats: mp4, mov, avi. Maximum file size: 500 MB.
If you experience any technical issues, please reply to this email.
Good luck with your interview!
Best regards,
Recruitment Team
YStar Edu
Dear {$name},
Thank you for taking the time to apply and for your interest in joining YStar Edu.
After careful review, we regret to inform you that we will not be moving forward with your application at this time.
We appreciate your interest and wish you all the best in your future endeavors.
Best regards,
Recruitment Team
YStar Edu
Dear {$name},
Congratulations and welcome to YSTAR EDU!
We are pleased to inform you that you have successfully passed the interview process and have been selected to join our team as a Tutor.
Before starting classes, all new tutors are required to complete our online onboarding training (E-learning).
Please access the onboarding training using the link below:
If the button does not work, you may copy and paste the following link into your browser:
{$training_url}
We recommend completing the onboarding training as soon as possible so that your tutoring account can be activated.
We are excited to welcome you to YSTAR EDU and look forward to working with you.
Best regards,
Recruitment Team
YStar Edu
Dear {$name},
Congratulations! You have successfully completed the onboarding training with a 100% passing score.
Here are your Microsoft Teams trial account details for tutoring:
| Account Email: | {$account} |
| Temporary Password: | {$password} |
Please log in to your account and familiarize yourself with Microsoft Teams before your first lesson.
If you have any questions about account setup or your first session, feel free to reply to this email — we're happy to help.
Welcome to YStar Edu!
Best regards,
Recruitment Team
YStar Edu