2019年做了个企业招聘小程序,客户是酒店行业的招聘平台,需要在微信小程序里完成企业注册、职位发布、简历投递、面试邀约等完整招聘流程。后端用 Yii2.0 搭的,部署在阿里云 ECS 上。今天把这个项目的全栈开发实践整理出来。

一、项目背景与技术选型

客户需求很明确:

  • 企业端:注册登录、职位发布管理、简历查看、面试邀约
  • 求职者端:简历填写、职位搜索、投递记录、消息通知
  • 管理后台:数据统计、内容审核、企业管理

技术选型:

  • 后端:Yii2.0(团队熟悉,开发效率高)
  • 数据库:MySQL 5.7
  • 缓存:Redis
  • 部署:阿里云 ECS + RDS + OSS
  • 小程序:微信原生开发

为什么选 Yii2.0?团队之前一直用 Yii,开发效率高,Gii 代码生成器能快速生成 CRUD,ActiveRecord 用起来顺手。小项目快速迭代,Yii2 是很合适的选择。

二、后端架构设计

2.1 目录结构

aacccwpdoooeicmsmcvmmshncnb/ooeioimoeesof/nvdrnneodrlonit1ev/twnevpltgrAJRClirslieeruoeoscoscr/ltbsm/el/esllhCupslsleComa/eeronenrrsntCyss/troC//ronooltnllrtleorerlor.ll.pelphrehp.rpp.#hp#####phpAPI

API 和后台分开部署,API 走 api.example.com,后台走 admin.example.com。公共模型和服务放在 common 目录,两边共享。

2.2 模块化设计

按业务领域划分模块:

  • Auth:登录授权、Token 管理
  • Company:企业注册、信息管理
  • Job:职位发布、搜索、管理
  • Resume:简历创建、投递、管理
  • Message:消息通知、面试邀约
  • File:文件上传(头像、简历附件)

每个模块有独立的 Controller、Model、Service,Service 层封装业务逻辑,Controller 只做参数校验和响应格式化。

2.3 API 响应格式

统一的响应格式,前端处理起来方便:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 成功响应
return [
    'code' => 0,
    'message' => 'success',
    'data' => $data,
];

// 错误响应
return [
    'code' => 1001,
    'message' => '参数错误',
    'data' => null,
];

用 Yii2 的 beforeActionafterAction 做统一的响应格式化和异常处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public function behaviors()
{
    return [
        'contentNegotiator' => [
            'class' => ContentNegotiator::className(),
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
            ],
        ],
    ];
}

三、核心功能实现

3.1 小程序登录授权

微信小程序登录走 code2session 接口,用 code 换 openid 和 session_key。我们的方案是后端生成自定义 Token,存在 Redis 里,前端每次请求带 Token。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class AuthController extends Controller
{
    public function actionLogin()
    {
        $code = Yii::$app->request->post('code');
        if (!$code) {
            return ['code' => 1001, 'message' => 'code不能为空'];
        }

        // 调用微信接口换 openid
        $wechat = new WechatMiniProgram();
        $session = $wechat->code2Session($code);
        $openid = $session['openid'];

        // 查找或创建用户
        $user = User::findOne(['openid' => $openid]);
        if (!$user) {
            $user = new User();
            $user->openid = $openid;
            $user->created_at = time();
            $user->save();
        }

        // 生成 Token
        $token = $this->generateToken($user->id);
        $redis = Yii::$app->redis;
        $redis->setex("user_token:{$token}", 86400 * 30, $user->id);

        return [
            'code' => 0,
            'message' => 'success',
            'data' => [
                'token' => $token,
                'user_info' => [
                    'id' => $user->id,
                    'nickname' => $user->nickname,
                    'avatar' => $user->avatar,
                ],
            ],
        ];
    }

    private function generateToken($userId)
    {
        return md5($userId . time() . Yii::$app->security->generateRandomString(16));
    }
}

Token 验证用行为(Behavior)实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TokenAuthBehavior extends Behavior
{
    public function events()
    {
        return [
            Controller::EVENT_BEFORE_ACTION => 'beforeAction',
        ];
    }

    public function beforeAction($event)
    {
        $token = Yii::$app->request->getHeaders()->get('Authorization');
        if (!$token) {
            throw new UnauthorizedHttpException('未登录');
        }

        $userId = Yii::$app->redis->get("user_token:{$token}");
        if (!$userId) {
            throw new UnauthorizedHttpException('登录已过期');
        }

        Yii::$app->user->setIdentity(User::findOne($userId));
        return true;
    }
}

3.2 职位搜索

职位搜索是核心功能,需要支持关键词、城市、薪资、经验、学历等多维度筛选。数据量不大的时候用 MySQL 就行,加好联合索引:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
CREATE TABLE `job` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `company_id` int(11) NOT NULL,
  `title` varchar(100) NOT NULL,
  `city_id` int(11) NOT NULL,
  `salary_min` int(11) NOT NULL,
  `salary_max` int(11) NOT NULL,
  `experience` varchar(20) DEFAULT NULL,
  `education` varchar(20) DEFAULT NULL,
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_city_status_created` (`city_id`,`status`,`created_at`),
  KEY `idx_company_status` (`company_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

搜索接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public function actionSearch()
{
    $keyword = Yii::$app->request->get('keyword', '');
    $cityId = Yii::$app->request->get('city_id', 0);
    $salaryMin = Yii::$app->request->get('salary_min', 0);
    $salaryMax = Yii::$app->request->get('salary_max', 0);
    $experience = Yii::$app->request->get('experience', '');
    $page = Yii::$app->request->get('page', 1);
    $pageSize = Yii::$app->request->get('page_size', 20);

    $query = Job::find()
        ->where(['status' => 1])
        ->orderBy(['created_at' => SORT_DESC]);

    if ($keyword) {
        $query->andWhere(['like', 'title', $keyword]);
    }
    if ($cityId) {
        $query->andWhere(['city_id' => $cityId]);
    }
    if ($salaryMin) {
        $query->andWhere(['>=', 'salary_max', $salaryMin]);
    }
    if ($salaryMax) {
        $query->andWhere(['<=', 'salary_min', $salaryMax]);
    }
    if ($experience) {
        $query->andWhere(['experience' => $experience]);
    }

    $count = $query->count();
    $list = $query
        ->offset(($page - 1) * $pageSize)
        ->limit($pageSize)
        ->with('company')
        ->asArray()
        ->all();

    return [
        'code' => 0,
        'data' => [
            'list' => $list,
            'total' => $count,
            'page' => $page,
            'page_size' => $pageSize,
        ],
    ];
}

关键词搜索用 LIKE 在数据量小的时候够用,数据量大了之后要换 Elasticsearch。我们这个项目职位数在 5 万以内,MySQL 完全够用。

3.3 简历投递

简历投递要防重复投递,同一个用户对同一个职位只能投递一次。用数据库唯一索引 + Redis 分布式锁双重保障:

1
ALTER TABLE `job_apply` ADD UNIQUE KEY `uk_user_job` (`user_id`,`job_id`);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public function actionApply()
{
    $jobId = Yii::$app->request->post('job_id');
    $userId = Yii::$app->user->id;

    // Redis 防重
    $lockKey = "apply_lock:{$userId}:{$jobId}";
    if (!Yii::$app->redis->set($lockKey, 1, 'NX', 'EX', 5)) {
        return ['code' => 2001, 'message' => '请勿重复投递'];
    }

    try {
        $apply = new JobApply();
        $apply->user_id = $userId;
        $apply->job_id = $jobId;
        $apply->resume_id = Yii::$app->request->post('resume_id');
        $apply->status = 1; // 已投递
        $apply->created_at = time();

        if (!$apply->save()) {
            // 唯一索引冲突
            if (strpos($apply->getFirstError(''), 'Duplicate') !== false) {
                return ['code' => 2002, 'message' => '已经投递过该职位'];
            }
            return ['code' => 2003, 'message' => '投递失败'];
        }

        // 投递成功,发通知给企业
        Yii::$app->queue->push(new NewApplyJob([
            'apply_id' => $apply->id,
        ]));

        return ['code' => 0, 'message' => '投递成功'];
    } finally {
        Yii::$app->redis->del($lockKey);
    }
}

Redis 锁防止用户快速点击重复提交,数据库唯一索引保证最终一致性。两层防护,万无一失。

3.4 文件上传

头像和简历附件上传到阿里云 OSS,用 STS 临时授权让前端直传,后端只负责生成 STS Token:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public function actionSts()
{
    $oss = new AliyunOSS();
    $sts = $oss->generateStsToken([
        'bucket' => 'recruit-files',
        'dir' => 'uploads/' . date('Ymd') . '/',
        'expire' => 3600,
    ]);

    return [
        'code' => 0,
        'data' => $sts,
    ];
}

前端拿到 STS Token 后直接上传到 OSS,上传成功后把 URL 传给后端保存。这样做的好处是文件流量不经过服务器,节省带宽,上传速度也更快。

四、部署与运维

4.1 阿里云架构

SLBECSAPI+RRODeSSdSiMsySQL

SLB 做负载均衡,后面挂两台 ECS,RDS 用高可用版,Redis 用主从版。OSS 存文件,CDN 加速静态资源。

4.2 自动化部署

用 GitLab CI 做自动化部署,代码 push 到 master 分支后自动构建部署:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
stages:
  - test
  - deploy

test:
  stage: test
  script:
    - composer install
    - ./vendor/bin/phpunit

deploy_production:
  stage: deploy
  only:
    - master
  script:
    - ssh deploy@api.example.com "cd /var/www/api && git pull && composer install --no-dev && php yii migrate --interactive=0"

数据库迁移用 Yii2 的 Migration,每次发布自动执行,不用手动改数据库。

4.3 监控告警

  • 服务器监控:阿里云云监控,CPU、内存、磁盘告警
  • 应用监控:自己写了个健康检查接口,定时访问,异常告警
  • 错误日志:Yii2 的日志写到文件,用 Logtail 采集到阿里云 SLS,错误率超阈值告警
  • 业务监控:投递量、注册量等核心指标,每天出报表

五、踩过的坑

1. 微信 code 只能用一次

code2session 的 code 只能用一次,用完就失效。前端如果重复调用登录接口,第二次会失败。要做好前端控制,登录中禁用按钮。

2. OSS STS 权限最小化

STS Token 的权限要最小化,只允许上传到指定目录,不允许删除和下载。否则 Token 泄露后别人可以操作你的 OSS。

3. 小程序审核

微信小程序审核很严,招聘类小程序需要提供人力资源服务许可证。提前准备好资质,否则审核会被拒,耽误上线时间。

4. 并发投递

上线初期没加 Redis 锁,只靠数据库唯一索引。遇到过用户快速点击投递按钮,请求同时到达,都通过了"是否已投递"的检查,然后同时插入,其中一个报唯一索引冲突返回错误。用户体验不好,后来加了 Redis 锁解决。

5. 图片压缩

小程序上传图片如果不压缩,一张几 MB,上传慢还占存储空间。前端上传前要压缩,后端也可以再加一层压缩。我们用了阿里云 OSS 的图片处理服务,实时压缩,不用自己处理。

六、总结

这个企业招聘小程序从 0 到 1 上线,用了大概 2 个月时间。后端 Yii2.0 开发效率确实高,Gii 生成 CRUD,ActiveRecord 操作数据库,Validation 做参数校验,这些基础功能都很成熟,开发者可以专注在业务逻辑上。

核心经验:

  1. 小程序登录用自定义 Token + Redis,比微信 session_key 更灵活
  2. 防重复提交要 Redis 锁 + 数据库唯一索引双重保障
  3. 文件上传用 OSS STS 直传,省带宽省服务器资源
  4. 自动化部署 + 数据库迁移,减少人为操作失误
  5. 监控告警要提前搭好,上线后才能快速发现问题

这个项目后来迭代了很多功能,视频面试、在线笔试、AI 简历解析等等,基础架构一直没动,说明当初的架构设计是合理的。技术选型没有最好的,只有最合适的。对这个项目来说,Yii2.0 + 阿里云就是最合适的组合。