Job Hunting(职位猎人) - 一款协助找工作的浏览器插件

logo

为什么要做这个项目

当前国内使用率较高的招聘平台(排名不分先后)分别有 BOSS 直聘,前程无忧,智联招聘,猎聘网,拉勾网,其提供了各个行业的职位招聘信息的展示。但在实际使用过程中发现其展示职位信息的策略对于求职者有诸多不便,包括不仅限于:职位发布时间久远(俗称僵尸岗),不能简单识别普通职位,职位发布时间被隐藏或乱序显示,职位的公司名不是全称,没有职位公司的风险提示。

项目做了什么

为了提高使用这些招聘平台找工作的用户体验,项目会对目标平台网站页面进行增强展示;对出现过的职位进行本地历史快照,跨平台本地检索;对职位数据进行多维度的分析,并以可视化的手段呈现;通过内置的讨论区,对职位进行评论,为职位打上标签等方式进行中立的职位交流;

最近主要改动/新增特性

  1. 新增服务端

插件

运行截图

招聘/企业信息网站页面

搜索页(前程无忧)
51job
推荐页(BOSS 直聘)
boss

详情页

job-snapshot-51job

职位快照

job-snapshot-history-51job
爱企查
aiqicha

管理页面

打开管理页面
chrome_extension_sidepanel_open
管理页面(需点击插件图标打开)
sidepanel_admin_home

插件安装

插件的开发请跳转到extension 开发目录

  1. 打开 Release 页 或 直接访问 最新发布
  2. 点击下载 Assets 下的 job-hunting-extension-chrome-xxx.zip
  3. 打开浏览器,安装插件,下面是针对不同浏览器的安装步骤
    1. chrome:地址栏输入 chrome://extensions/,打开开发者模式,将 zip 文件拖进页面里
    2. edge,地址栏输入 edge://extensions/,打开开发人员模式,将 zip 文件拖进页面里

招聘平台支持列表

招聘平台访问地址备注
BOSS 直聘https://www.zhipin.com/web/geek/jobs推荐页/搜索页[账号未登录]
https://www.zhipin.com/web/geek/jobs推荐页/搜索页[账号已登录]
前程无忧https://we.51job.com/pc/search搜索页
智联招聘https://sou.zhaopin.com/搜索页
拉勾网https://www.lagou.com/wn/zhaopin搜索页
猎聘网https://www.liepin.com/zhaopin搜索页,需点击搜索按钮才有效果
就业在线https://www.jobonline.cn/position搜索页
广东公共求职招聘服务平台https://ggfw.hrss.gd.gov.cn/recruitment/internet/main/#/search?type=1搜索页

企业搜索平台支持列表

企业搜索平台访问地址备注
爱企查https://aiqicha.baidu.com/s

浏览器支持

Edge
Edge
Chrome
Chrome
last versionlast version

服务端

插件端受限于浏览器本地数据库性能,服务端作为服务端数据处理方案的技术示例,演示了大规模职位数据的存储、检索与分析实现。

首页访问地址:http://localhost:3000/

后台访问地址:http://localhost:3000/login

  • 账户/密码: root/Change_Me

注意事项

服务端仅供个人学习与技术研究使用。

  • 本服务端为技术学习与研究工具,禁止用于任何商业用途。不推荐公网部署,仅限本地或内网环境使用。
  • 使用者在导入或采集数据时须遵守中华人民共和国相关法律法规,包括但不限于《网络安全法》《数据安全法》《个人信息保护法》,不得从事任何侵犯他人合法权益的行为。
  • 首次部署后请立即修改默认账户密码(root / Change_Me),并更换 auth_access_token_secretauth_refresh_token_secret 为随机字符串,避免未授权访问与 JWT 伪造。
  • 对于因使用本服务端而引起的任何法律责任,本项目开发者不承担责任。使用即表示您已阅读并同意免责声明的全部条款。

服务端运行截图

仪表板

server_dashboard

数据同步

server_data_sync

教程

快速开始

职位猎人插件

  1. 打开 Release 页 或 直接访问 最新发布
  2. 点击下载 Assets 下的 job-hunting-extension-chrome-xxx.zip
  3. 打开浏览器,安装插件,下面是针对不同浏览器的安装步骤
    1. chrome:地址栏输入 chrome://extensions/,打开开发者模式,将 zip 文件拖进页面里
    2. edge,地址栏输入 edge://extensions/,打开开发人员模式,将 zip 文件拖进页面里
  4. 打开页面

服务器端

运行服务器

从源码构建

nix develop .#server
cd apps/server
cargo build --release
./target/release/job-hunting-server --config=application.toml

预编译下载

服务器端二进制将随 Release 发布,届时可从 Releases 下载。

可用平台:Linux(amd64/arm64)、macOS(Intel/Apple Silicon/通用)、Windows amd64

配置文件

创建 application.toml

# 监听地址
address = "0.0.0.0"
port = 3000

# 数据库(SQLite 默认即可)
database_url = "sqlite://db.sqlite?mode=rwc"

# 生产环境务必修改以下三项
auth_access_token_secret = "更换为随机字符串"
auth_refresh_token_secret = "更换为另一随机字符串"
./job-hunting-server-linux-amd64 --config=application.toml

环境变量命名规则:配置字段名转大写 + JH_ 前缀,如 database_urlJH_DATABASE_URLaddressJH_ADDRESS。也可用 -O key=value 直接覆盖。

Docker

docker run -e JH_ADDRESS=0.0.0.0 \
  -e JH_DATABASE_URL="sqlite:///data/db.sqlite?mode=rwc" \
  -v ./data:/data \
  -p 127.0.0.1:3000:3000 \
  lastsunday/job-hunting:latest

docker-compose(完整部署示例)

仓库中的 docker-compose.yml 仅含 PostgreSQL,用于本地开发。以下为完整部署示例。

services:
  postgresql:
    image: postgres:18.4
    environment:
      - POSTGRES_PASSWORD=changeme
    ports:
      - 5432:5432
    volumes:
      - pgdata:/var/lib/postgresql/data

  server:
    image: lastsunday/job-hunting:latest
    ports:
      - 127.0.0.1:3000:3000
    environment:
      - JH_ADDRESS=0.0.0.0
      - JH_DATABASE_URL=postgres://postgres:changeme@postgresql:5432/postgres
    depends_on:
      - postgresql

volumes:
  pgdata:

访问

首次部署后请立即修改默认密码,并更换 auth_access_token_secretauth_refresh_token_secret 为随机字符串(可在配置文件中设置,或通过 JH_AUTH_ACCESS_TOKEN_SECRET / JH_AUTH_REFRESH_TOKEN_SECRET 环境变量覆盖)。否则持有默认值者可伪造 JWT。

开发

浏览器插件

[!IMPORTANT] 项目根目录: apps/extension

编译

  1. 安装,编译
    pnpm i
    pnpm run build
  1. 打开 chrome,选择加载已解压的扩展程序,选择当前项目的 .output/chrome-mv3 目录

  2. 打开页面

开发

  1. 安装,编译

    pnpm i
    pnpm run dev
    
  2. chrome 浏览器打开 chrome://extensions/ 页面

  3. 点击加载已解压的扩展程序

  4. 选择项目中生成的 .output/chrome-mv3-dev 文件夹即可

  5. 每次保存都会重新编译,扩展程序需要**重新点一次刷新按钮**才生效

测试

https://vitalets.github.io/playwright-bdd/

深入开发

架构

block
  columns 1
  招聘网站
  桌面浏览器
  职位猎人浏览器插件
  block
    数据同步服务
    公司信息查询服务
    LLM服务
    Oauth服务
    BBS服务
  end
  1. 招聘网站

    • 支持桌面版的招聘网站版面
    • 提供职位信息列表
  2. 桌面浏览器

    • Chrome,Edge
  3. 职位猎人浏览器插件

  4. 外部服务

    1. 数据同步服务

      Git
      
      • 基础信息
        • 职位信息
        • 公司信息
        • 职位标签
        • 公司标签
      • 数据源
    2. 公司信息查询服务

      • 基础信息
      • 风评
    3. 公司评论查询服务

    4. LLM 服务

      内嵌 LLM(Web llm),远程 LLM
      
      • 职位分析
    5. Oauth 服务

      Github Oauth2
      
    6. BBS 服务

      Github Issues API
      
      • 讨论区
      • 评论(职位,公司)

职位猎人浏览器插件

职位展示页面

  1. 数据获取
    • 职位
    • 公司
  2. 数据持久化
  3. 自定义职位卡片信息
    • 职位发布/初见时间
    • 公司特性检测(外包,培训机构等...)
    • 年龄限制检测
    • 职位浏览/展示次数
    • 职位匹配度
    • HR 活跃状态
    • 标签
      • 职位
      • 公司
    • 评论
      • 职位
      • 公司
    • 公司风评检测
    • 公司官网
      • 可达性检测
      • 备案信息检测

后台管理

  1. 职位搜索
    • 列表
    • 地图
  2. 职位浏览历史
  3. 职位详情快照
  4. 讨论区
  5. 数据
    • 职位
    • 公司
    • 标签
    • 职位标签
    • 公司标签
    • 公司评论
  6. 数据源
    • 列表
    • 元数据
  7. 任务
    • 统计
    • 详情
  8. 系统
    • 设置
      • 数据备份
    • 数据管理
      • 数据导入
      • 数据导出
    • 数据库
      • 概况
      • 调试
    • 文件

核心逻辑

从网站获取数据和渲染额外信息

职位数据

sequenceDiagram
  participant browser as 浏览器
  participant extension as 插件
  autonumber
  extension ->> browser: 注册待侦测目标页面
  browser ->> browser: 打开职位列表页面
  browser ->> extension: 触发侦测目标页面事件
  extension ->> browser: 注入脚本(拦截XMLHttpRequest)到目标页面
  extension ->> extension: 初始化Extension Bridge
  browser ->> extension: 发送职位列表数据
  extension ->> extension: 监听查找职位列表界面元素
  extension ->> extension: 解析职位列表数据
  extension ->> extension: 保存职位信息到持久层
  extension ->> extension: 从持久层获取保存的职位信息
  extension ->> browser: 在职位项界面上进行自定义职位信息渲染
  extension ->> browser: 在职位项界面上进行自定义公司信息界面框架渲染
  extension ->> browser: 渲染职位评论框架
  opt
    browser ->> extension: 获取和渲染公司信息
    alt 持久层含有指定未过期的公司信息
      extension ->> extension: 从持久层获取保存的公司信息
    else
      extension ->> extension: 向公司信息服务查询公司信息
      extension ->> extension: 保存公司信息到持久层
      extension ->> extension: 从持久层获取保存的公司信息
    end
    extension ->> browser: 渲染自定义公司信息
    extension ->> browser: 渲染公司评论框架
    opt
      browser ->> extension: 获取和渲染公司评论信息
      extension ->> browser: 渲染公司评论信息
    end
  end
  opt
    browser ->> extension: 获取和渲染职位评论信息
    extension ->> browser: 渲染职位评论信息
  end

公司数据

sequenceDiagram
  participant browser as 浏览器
  participant extension as 插件
  autonumber
  extension ->> browser: 注册待侦测目标页面
  browser ->> browser: 打开公司列表页面
  browser ->> extension: 触发侦测目标页面事件
  extension ->> browser: 注入脚本(拦截XMLHttpRequest)到目标页面
  extension ->> extension: 初始化Extension Bridge
  browser ->> extension: 发送公司列表数据
  extension ->> extension: 监听查找公司列表界面元素
  extension ->> extension: 解析公司列表数据
  alt 持久层含有指定未过期的公司信息
    extension ->> extension: 从持久层获取保存的公司信息
  else
    extension ->> extension: 向公司信息服务查询公司信息
    extension ->> extension: 保存公司信息到持久层
    extension ->> extension: 从持久层获取保存的公司信息
  end
  extension ->> browser: 在公司项界面上进行自定义公司信息渲染
  extension ->> browser: 渲染自定义公司信息
  extension ->> browser: 渲染公司评论框架
  opt
    browser ->> extension: 获取和渲染公司评论信息
    extension ->> browser: 渲染公司评论信息
  end

自定义职位卡片渲染

┌────────────────────────────────────────────────────────────────┐
│                                                │ Job extra info│
│────────────────────────────────────────────────────────────────│
│                     Job info                                   │
│                                                                │
│────────────────────────────────────────────────────────────────│
│                     Company info                               │
│                                                                │
│                                                                │
│                                                                │
│────────────────────────────────────────────────────────────────│
│ Company evaluation checking                                    │
│────────────────────────────────────────────────────────────────│
│ Company tag                                                    │
│────────────────────────────────────────────────────────────────│
│                   Othre|Company comment| Online company comment│
│────────────────────────────────────────────────────────────────│
│ Job tag                                                        │
│────────────────────────────────────────────────────────────────│
│ Job browse statistics ===                           Job Comment│
└────────────────────────────────────────────────────────────────┘
  1. 监听职位列表元素 ▲

    平台职位列表元素定位备注
    前程无忧.joblist
    BOSS 直聘.rec-job-list
    猎聘网.job-list-box
    智联招聘.positionlist__list
    拉勾网.list__YibNq
    就业在线.position-wrap
    广东公共求职招聘服务平台.ant-list-items
  2. 为职位列表容器设置 flex 布局(使得在不改变 dom 结构的情况下,通过设置 css 的 order 属性来达到改变职位项前后位置)

  3. 渲染加载中元素

  4. 渲染职位额外信息

    1. 进行年龄限制的检测渲染
    2. 对职位时间进行处理渲染(初见时间/发布时间)
    3. Hr 活跃时间渲染
    4. 职位的公司信息(外包/培训机构)渲染
    5. 对职位时间进行染色
    6. 职位分析检测和渲染
  5. 隐藏加载中元素

  6. 修改职位卡片 css 的 order 属性,对职位卡片进行排序

  7. 渲染底部功能栏

    1. 渲染 logo
    2. 公司信息渲染
      1. 查询公司信息按钮渲染
      2. 对公司名处理
      3. 公司详情渲染
      4. 公司标签渲染(其他人/我)
      5. 公司风评渲染
      6. 其他途径查询弹窗渲染
      7. 在线公司评论按钮渲染
    3. 职位标签渲染(其他人/我)
    4. 职位查看和展示次数渲染
  8. 最终渲染

    1. 职位评论按钮渲染
    2. 职位卡片渲染完成标识

内部 API

内部 API 注册与使用

1. Service 注册(worker.js)

const ACTION_FUNCTION = new Map();

export const WorkerBridge = {
  ping: function (message, param) {
    postSuccessMessage(message, 'pong');
  },
};

const { mergeServiceMethod } = useService();

mergeServiceMethod(ACTION_FUNCTION, WorkerBridge);
mergeServiceMethod(ACTION_FUNCTION, DataSourceMetadataService);

2. API 注册(api/index.js)

export const DataSourceMetadataApi = {
  dataSourceMetadataSearch: mockFunction,
  dataSourceMetadataAddOrUpdate: mockFunction,
  dataSourceMetadataBatchAddOrUpdate: mockFunction,
  dataSourceMetadataGetById: mockFunction,
  dataSourceMetadataGetByIds: mockFunction,
  dataSourceMetadataDeleteById: mockFunction,
  dataSourceMetadataDeleteByIds: mockFunction,
};
fillBridgeApi({ api: DataSourceMetadataApi });

3. API 使用

[!IMPORTANT] 通过 API 方法来调用 Service 方法

const param = new DataSourceMetadataSearchBO();
param.enable = true;
param.orderByColumn = 'seq';
param.orderBy = 'ASC';
const result = await DataSourceMetadataApi.dataSourceMetadataSearch(param);

约束

  1. 方法名约定: ClassName+MethodName,如 DataSourceMetadataApi
    • ClassName: DataSourceMetadata
    • MethodName: Search
    • 结果为: dataSourceMetadataSearch
  2. 方法传入参数类型约定: JSONObject 或其他基本数据类型
  3. 方法都为 async function

原理

  • 利用 JSONObject 的 keys,value 进行 Service 方法的绑定
const fillBridgeApi = ({ api = {} } = {}) => {
  const keys = Object.keys(api);
  keys.forEach((invokeName) => {
    api[invokeName] = async (param) => {
      const result = await invoke(invokeName, param);
      return result.data;
    };
  });
  return api;
};
  • 手动声明进行 Service 方法绑定
export const JobSnapshotApi = {
  jobSnapshotDeleteByIds: async function (param) {
    const result = await invoke(this.jobSnapshotDeleteByIds.name, param);
    return result.data;
  },
};
  • 利用方法名来定位 Service 和 Service 方法: className+MethodName

内部 API 调用原理

[!IMPORTANT] action = className+MethodName

从 ContentScript 调用

sequenceDiagram
  participant Api
  participant ContentScript
  participant Background
  participant Offscreen
  participant WebWorker
  autonumber
  ContentScript ->>  Api: 调用Api方法
  Api ->> ContentScript: 调用invoke方法,传递action,param
  ContentScript ->> ContentScript: 生成callbackId和Promise
  ContentScript ->> ContentScript: 关联callbackId和当前生成的Promise
  ContentScript ->> Background: 发送Message
  alt 如果Background可以处理该Message
    Background ->> Background: 处理Message
    Background -->> ContentScript: 返回处理后的Message
  else
    Background ->> Offscreen: 转发Message
    Offscreen ->> WebWorker: 转发Message
    WebWorker ->> WebWorker: 处理Message
    WebWorker -->> Offscreen: 返回处理后的Message
    Offscreen -->> Background: 转发处理后的Message
    Background -->> ContentScript: 转发处理后的Message
  end
  ContentScript ->> ContentScript:根据返回Message的callbadkId查找Promise
  alt 如果Message含有error
    ContentScript ->> Api:调用Promise.reject返回错误
  else
    ContentScript ->> Api:调用Promise.resolve返回结果
  end

从 Background 调用

sequenceDiagram
  participant Api
  participant Background
  participant Offscreen
  participant WebWorker
  autonumber
  Background ->>  Api: 调用Api方法
  Api ->> Background: 调用invoke方法,传递action,param
  Background ->> Background: 生成callbackId和Promise
  Background ->> Background: 关联callbackId和当前生成的Promise
  Background ->> Offscreen: 发送Message
  Offscreen ->> WebWorker: 转发Message
  WebWorker ->> WebWorker: 处理Message
  WebWorker -->> Offscreen: 返回处理后的Message
  Offscreen -->> Background: 转发处理后的Message
  Background ->> Background:根据返回Message的callbadkId查找Promise
  alt 如果Message含有error
    Background ->> Api:调用Promise.reject返回错误
  else
    Background ->> Api:调用Promise.resolve返回结果
  end

从 WebWorker 调用

[!IMPORTANT]

  • Webworker 发起的 Api 调用不会经过 ContentScript,Background,Offscreen 这些模块的处理流程
  • 调用的方法仅限于 Github Api (仅执行网络访问)

对大 Message 传递的处理

[!IMPORTANT]

https://github.com/lastsunday/job-hunting/commit/ae0cee1

内嵌数据库

SQL Class

---
title: Database
---
classDiagram
    class Database
    Database: +initDb$({ dataDir } = {})
    Database: +getOne$(sql, bind, obj, { connection = null } = {})
    Database: +getAll$(sql, bind, obj, { connection = null } = {})
    Database: +batchInsert$(obj, tableName, params, { overrideCreateDatetime = false, overrideUpdateDatetime = false, connection = null } = {})
    Database: +batchInsertOrReplace$(obj, tableName, tableIdColumn, params, { replace = true, overrideCreateDatetime = false, overrideUpdateDatetime = false, connection = null } = {})
    Database: +one$(entity, tableName, idColumn, id, { connection = null } = {})
    Database: +all$(entity, tableName, orderBy, { connection = null } = {})
    Database: +batchGet$(obj, tableName, idColumnName, ids, { connection = null } = {})
    Database: +del$(tableName, idColumn, id, { otherCondition = null, connection = null } = {})
    Database: +batchDel$(tableName, idColumn, ids, { otherCondition = null, connection = null } = {})
    Database: +search$(entity, tableName, param, whereConditionFunction, { connection = null } = {})
    Database: +searchCount$(entity, tableName, param, whereConditionFunction, { connection = null } = {})
    Database: +sort$(tableName, idColumnName, param, { connection = null } = {})
    Database: +innerInit({ dataDir } = {})
    Database: +dbExport(message, param)
    Database: +dbImport(message, param)
    Database: +dbClose(message, param)
    Database: +dbDelete(message, param)
    Database: +dbSize(message, param)
    Database: +dbSchemaVersion(message, param)
    Database: +dbExec(message, param)
    Database: +dbGetAllTableName(message, param)

    class BaseService
    BaseService: +constructor(tableName, tableIdColumn, entityClassCreateFunction, searchDTOCreateFunction, whereConditionFunction)
    BaseService: +search(message, param, { detailInjectAsyncCallback = null, entityClassCreateFunction = null } = {})
    BaseService: +count(message, param)
    BaseService: +getOne(message, param, column)
    BaseService: +getById(message, param)
    BaseService: +getByIds(message, param)
    BaseService: +addOrUpdate(message, param)
    BaseService: +deleteById(message, id, column)
    BaseService: +deleteByIds(message, ids, column)
    BaseService: #_search(param, { detailInjectAsyncCallback = null, connection = null, entityClassCreateFunction = null } = {})
    BaseService: #_count()
    BaseService: #_getOne(param, column)
    BaseService: #_getById(param, { connection = null } = {})
    BaseService: #_getByIds(param, { connection = null } = {})
    BaseService: #_deleteById(id, column, { otherCondition, connection = null } = {})
    BaseService: #_deleteByIds(ids, column, { connection = null, otherCondition = null } = {})
    BaseService: #_updateByIds(ids, column, { otherCondition })
    BaseService: #_addOrUpdate(param, { overrideUpdateDatetime = false, overrideCreateDatetime = false, connection = null } = {})
    BaseService: #_batchAddOrUpdate(params, { connection = null, overrideCreateDatetime = false, overrideUpdateDatetime = false, genIdFunction = null, entityClassCreateFunction = null } = {})

    class BaseBridgeService
    BaseBridgeService: +constructor(baseServiceInstance, serviceName)
    BaseBridgeService: +getMethodName(name)
    BaseBridgeService: +getMethodNameMap()
    BaseBridgeService: +addServiceMethod$({ bridgeService = null, methodName = null, methodFunction = async ({ param = null } = {}) => { } } = {})
    BaseBridgeService: +addTransactionServiceMethod$({ bridgeService = null, methodName = null, methodFunction = async ({ param = null, tx = null } = {}) => { } } = {})
    BaseBridgeService: +fillBaseServiceMethod$({ bridgeService = null, overrideUpdateDatetime = false, overrideCreateDatetime = false } = {})

    BaseService ..> Database
    BaseBridgeService ..> BaseService

Service 实现

import { DataSourceMetadataSearchBO } from '@/common/data/bo/dataSourceMetadataSearchBO';
import { DataSourceMetadata } from '@/common/data/domain/dataSourceMetadata';
import BaseBridgeService, { fillBaseServiceMethod } from './baseBridgeService';
import { BaseService } from './baseService';
import {
  genEqValueConditionSql,
  genInTextSql,
  genLikeSql,
  genRangeDatetimeConditionSql,
} from './sqlUtil';
const TABLE_NAME = 'data_source_metadata';
const TABLE_ID_COLUMN = 'id';
const SERVICE_NAME = 'dataSourceMetadata';
export const SERVICE_INSTANCE = new BaseService(
  TABLE_NAME,
  TABLE_ID_COLUMN,
  () => {
    return new DataSourceMetadata();
  },
  () => {
    return new DataSourceMetadataSearchBO();
  },
  (param) => {
    let whereCondition = ''.concat(
      genInTextSql(param.id, 'id'),
      genLikeSql(param.name, 'name'),
      genEqValueConditionSql(param.enable, 'enable'),
      genInTextSql(param.type, 'type'),
      genEqValueConditionSql(param.autoUpdateEnable, 'auto_update_enable'),
      genRangeDatetimeConditionSql(
        param.startDatetimeForCreate,
        param.endDatetimeForCreate,
        'create_datetime'
      ),
      genRangeDatetimeConditionSql(
        param.startDatetimeForUpdate,
        param.endDatetimeForUpdate,
        'update_datetime'
      )
    );
    return whereCondition;
  }
);
const DataSourceMetadataService = new BaseBridgeService(
  SERVICE_INSTANCE,
  SERVICE_NAME
);
fillBaseServiceMethod({
  bridgeService: DataSourceMetadataService,
  overrideCreateDatetime: true,
  overrideUpdateDatetime: true,
});

export default DataSourceMetadataService;

ORM 机制

  1. 例子
const company = new Company();
await CompanyApi.addOrUpdateCompany(company);

//companyService
const SERVICE_INSTANCE = new BaseService(
  'company',
  'company_id',
  () => {
    return new Company();
  },
  () => {
    return new SearchCompanyDTO();
  },
  null
);

export const CompanyService = {
  addOrUpdateCompany: async function (message, param) {
    try {
      await SERVICE_INSTANCE._batchAddOrUpdate([param]);
      postSuccessMessage(message, {});
    } catch (e) {
      postErrorMessage(
        message,
        '[worker] addOrUpdateCompany error : ' + e.message
      );
    }
  },
};
  1. 当前实现的特性

    1. 根据 JSONObject 进行 SQL 查询,新增,更新,删除
  2. 底层原理

    1. 利用 JSONObject 的 keys,value 来识别列名,字段类型和内容。通过此来进行 SQL 的生成和返回结果 JSONObject 的生成
    2. JSONObject 属性名采用小驼峰命名法(lowerCamelCase)
    3. 表字段名采用下划线命名法(Snake Case)
    4. SQL 插入字段的值位置采用下标定位的方式,如$1
    5. 分页采用 LIMIT,OFFSET

Schema Changes

const changelogList = getChangeLogList();
let oldVersion = 0;
const newVersion = changelogList.length;
try {
  await db.transaction(async (tx) => {
    const SQL_CREATE_TABLE_VERSION = `
          CREATE TABLE IF NOT EXISTS version(
          num INTEGER
        )
      `;
    await tx.exec(SQL_CREATE_TABLE_VERSION);
    const SQL_QUERY_VERSION = 'SELECT num FROM version';
    const result = await tx.query(SQL_QUERY_VERSION);
    const rows = result.rows;
    if (rows.length > 0) {
      oldVersion = rows[0].num;
    } else {
      const SQL_INSERT_VERSION = `INSERT INTO version(num) values($1)`;
      await tx.query(SQL_INSERT_VERSION, [0]);
    }
    infoLog(
      '[DB] schema oldVersion = ' + oldVersion + ', newVersion = ' + newVersion
    );
    if (newVersion > oldVersion) {
      infoLog('[DB] schema upgrade start');
      for (let i = oldVersion; i < newVersion; i++) {
        const currentVersion = i + 1;
        const changelog = changelogList[i];
        const sqlList = changelog.getSqlList();
        infoLog(
          '[DB] schema upgrade changelog version = ' +
            currentVersion +
            ', sql total = ' +
            sqlList.length
        );
        for (let seq = 0; seq < sqlList.length; seq++) {
          infoLog(
            '[DB] schema upgrade changelog version = ' +
              currentVersion +
              ', execute sql = ' +
              (seq + 1) +
              '/' +
              sqlList.length
          );
          const sql = sqlList[seq];
          await tx.exec(sql);
        }
      }
      const SQL_UPDATE_VERSION = `UPDATE version SET num = $1`;
      await tx.query(SQL_UPDATE_VERSION, [newVersion]);
      infoLog('[DB] schema upgrade finish to version = ' + newVersion);
      infoLog('[DB] current schema version = ' + newVersion);
    } else {
      infoLog('[DB] skip schema upgrade');
      infoLog('[DB] current schema version = ' + oldVersion);
    }
  });
} catch (e) {
  errorLog('[DB] schema upgrade fail,' + e.message);
}
  1. ChangeLog 文件

    1. 文件格式: ChangeLog+V+版本号 = ChangeLogVxxx.js

    2. 例子

      export class ChangeLogV1 extends ChangeLog {
        getSqlList() {
          let sqlList = [
            SQL_CREATE_TABLE_JOB,
            SQL_CREATE_TABLE_JOB_BROWSE_HISTORY,
          ];
          return sqlList;
        }
      }
      
  2. ChangeLog 执行规则

    1. 将需要执行的 ChangeLog 存放到列表中
    2. 开启事务,以确保 ChangeLog 的执行的原子性
    3. 通过计算 ChangeLog 列表的总数与当前数据库版本号(版本号为上一次执行 ChangLog 的数量)进行对比计算,来获取当前需要执行的 ChangeLog
    4. 执行完成后,更新数据库版本号(版本号即为已执行 ChangeLog 的数量)

备份

[!IMPORTANT] 当前使用 pglite dump 备份大量数据的数据库会出错

TODO

内部任务系统

---
title: Task System
---
sequenceDiagram
  participant Service
  participant Database
  participant GitHubApi
  participant GitService

  Note right of Service: 计算和保存下载和上传任务
  Service ->> Database: 获取数据同步配置
  Database -->> Service: 返回数据同步配置
  opt if 开启私有数据同步
    Service ->> Database: 获取用户信息
    Database -->> Service: 返回用户信息
    Service ->> Service: 获取私有数据上传任务类型
    opt if 需要同步私有的数据
      Service ->> Service: 获取私有数据仓库名
      Service ->> GitHubApi: 尝试根据用户名和仓库名创建私有数据仓库
      loop 私有数据上传任务类型
        rect rgb(191, 223, 255)
          Note right of Service: 计算并保存上传任务
          Service ->> Database: 根据用户名,仓库名获取指定任务类型最近上传任务截至时间
          Database -->> Service: 返回最近上传任务截至时间
          opt if 最近上传任务截至时间不为今天
            Service ->> GitService: 根据用户名,仓库名和获取指定任务类型最近上传文件时间
            GitService -->> Service: 返回最近上传文件时间
            Service ->> Service: 在最近上传任务截至时间和最近上传文件时间取最小值,作为任务开始时间
            Service ->> Database: 根据用户名,仓库名,任务类型,任务开始时间,任务结束时间(今天)保存任务记录
          end
        end
      end
      Service ->> Service: 获取私有数据下载任务类型
      Service ->> Service: 将私有数据下载任务保存到下载列表
    end
  end
  alt if 是否开启公开数据同步
    Service ->> Database: 获取用户信息
    Database -->> Service: 返回用户信息
    Service ->> Service: 获取公开数据上传任务类型
    opt if 有需要同步公开的数据
      Service ->> Service: 获取公开数据仓库名
      Service ->> GitHubApi: 尝试根据用户名和仓库名创建公开数据仓库
      loop 公开数据上传任务类型
        rect rgb(191, 223, 255)
          Note right of Service: 计算并保存上传任务
        end
      end
      Service ->> Service: 获取公开数据下载任务类型
      Service ->> Service: 将公开数据下载任务保存到下载列表
    end
  end
  Service ->> Database: 获取数据共享伙伴列表
  Database -->> Service: 返回数据共享伙伴列表
  Service ->> Service: 将数据共享伙伴数据的下载任务保存到下载列表
  loop 下载列表
    Service ->> Service: 根据下载列表获取伙伴信息
    Service ->> Service: 根据下载列表获取下载任务类型
    loop 下载任务类型
      rect rgb(191, 255, 223)
        Note right of Service: 计算并保存下载任务
        Service ->> GitService: 根据任务类型或文件名查询日期目录列表
        GitService -->> Service: 返回日期目录列表
        Service ->> Service: 根据下载历史文件保留天数和当前日期过滤和升序日期列表,获得仓库文件日期列表
        Service ->> Service: 根据仓库文件日期列表,获取开始时间(列表中最小时间)和结束时间(列表中最大时间)
        Service ->> Database: 根据开始时间和结束时间查询指定类型的下载任务列表
        Database -->> Service: 返回下载任务列表
        Service ->> Service: 根据仓库文件日期列表和下载任务列表的日期计算得出本地缺失日期列表
        Service ->> Database: 根据本地缺失日期列表,新增指定类型的下载任务
      end
    end
  end
  Service ->> Database: 获取数据源元数据自动更新列表
  Database -->> Service: 返回数据源元数据自动更新列表
  loop 数据源元数据自动更新列表
      rect rgb(255, 223,191)
        Note right of Service: 计算并保存元数据下载任务
        Service ->> Database: 根据任务类型编号获取最近下载任务列表
        Database -->> Service: 返回下载任务列表
        opt if 任务列表中含有非今天未完成的任务
          Service ->> Database: 将未完成的任务状态设置为取消
        end
        opt if 任务列表中没有今天未完成的任务
          Service ->> Database: 新增数据源元数据下载任务
        end
      end
  end
  Note right of Service: 执行下载和上传任务
  Service ->> Database: 查询需要执行的任务
  Database -->> Service: 返回需要执行的任务
  Service ->> Service: 执行查询到的需要执行的任务
  Note right of Service: 执行定时任务
  Service ->> Database: 查询已合并但未删除的文件
  Database -->> Service: 返回已合并但未删除的文件
  Service ->> Service: 根据历史文件保留数量和最大历史文件保留容量计算需要删除的文件列表
  Service ->> Database: 根据需要删除的文件列表删除文件

任务状态图

---
title: Task state
---
stateDiagram-v2
    [*] --> READY
    READY --> RUNNING
    READY --> CANCEL
    RUNNING --> FINISHED
    RUNNING --> FINISHED_BUT_ERROR
    RUNNING --> ERROR
    RUNNING --> CANCEL
    FINISHED --> [*]
    FINISHED_BUT_ERROR --> [*]
    ERROR --> [*]
    CANCEL --> [*]

数据关系图

---
title: Task ER
---
erDiagram
    task ||--|| task_data_upload: owns
    task {
      string(255) id PK "编号"
      string(255) type "任务类型"
      string(255) data_id FK "任务详情编号"
      string(255) status "任务状态"
      string error_reason "错误原因"
      int cost_time "耗时"
      int retry_count "重试次数"
      datetime create_datetime "创建时间"
      datetime update_datetime "更新时间"
    }
    task_data_upload {
      string(255) id PK "编号"
      string(255) type "任务类型"
      string(255) username "用户名"
      string(255) reponame "仓库名"
      datetime start_datetime "开始时间"
      datetime end_datetime "结束时间"
      int data_count "数据量"
      int data_page_num "页数"
      int data_page_size "页大小"
      datetime create_datetime "创建时间"
      datetime update_datetime "更新时间"
    }
    task ||--|| task_data_download: owns
    task_data_download {
      string(255) id PK "编号"
      string(255) type "任务类型"
      string(255) username "用户名"
      string(255) reponame "仓库名"
      datetime datetime "日期"
      int seq "序号"
      datetime create_datetime "创建时间"
      datetime update_datetime "更新时间"
      string(255) type_id "类型标识,用于识别特定文件"
      json config "配置"
      string(255) data_id FK "文件编号"
    }
    task ||--|| task_data_merge: owns
    task_data_merge {
      string(255) id PK "编号"
      string(255) type "任务类型"
      string(255) username "用户名"
      string(255) reponame "仓库名"
      datetime datetime "日期"
      string(255) data_id FK "文件编号"
      int data_count "数据量"
      int data_page_num "页数"
      int data_page_size "页大小"
      datetime create_datetime "创建时间"
      datetime update_datetime "更新时间"
      string(255) type_id "类型标识,用于识别特定文件"
      json config "配置"
    }
    task_data_merge |o--|| file: has
    file {
      string(255) id PK "编号"
      string(255) name "文件名"
      string(255) sha "散列值"
      string(255) encoding "编码"
      string content "文件内容"
      int size "文件尺寸"
      string type "文件类型"
      is_delete boolean "是否刪除"
      datetime create_datetime "创建时间"
      datetime update_datetime "更新时间"
    }

任务类型

export const TASK_TYPE_JOB_DATA_UPLOAD = 'JOB_DATA_UPLOAD';
export const TASK_TYPE_JOB_DATA_DOWNLOAD = 'JOB_DATA_DOWNLOAD';
export const TASK_TYPE_JOB_DATA_MERGE = 'JOB_DATA_MERGE';

export const TASK_TYPE_COMPANY_DATA_UPLOAD = 'COMPANY_DATA_UPLOAD';
export const TASK_TYPE_COMPANY_DATA_DOWNLOAD = 'COMPANY_DATA_DOWNLOAD';
export const TASK_TYPE_COMPANY_DATA_MERGE = 'COMPANY_DATA_MERGE';

export const TASK_TYPE_COMPANY_TAG_DATA_UPLOAD = 'COMPANY_TAG_DATA_UPLOAD';
export const TASK_TYPE_COMPANY_TAG_DATA_DOWNLOAD = 'COMPANY_TAG_DATA_DOWNLOAD';
export const TASK_TYPE_COMPANY_TAG_DATA_MERGE = 'COMPANY_TAG_DATA_MERGE';

export const TASK_TYPE_JOB_TAG_DATA_UPLOAD = 'JOB_TAG_DATA_UPLOAD';
export const TASK_TYPE_JOB_TAG_DATA_DOWNLOAD = 'JOB_TAG_DATA_DOWNLOAD';
export const TASK_TYPE_JOB_TAG_DATA_MERGE = 'JOB_TAG_DATA_MERGE';

export const TASK_TYPE_JOB_PUBLIC_DATA_UPLOAD = 'JOB_PUBLIC_DATA_UPLOAD';
export const TASK_TYPE_JOB_PUBLIC_DATA_DOWNLOAD = 'JOB_PUBLIC_DATA_DOWNLOAD';
export const TASK_TYPE_JOB_PUBLIC_DATA_MERGE = 'JOB_PUBLIC_DATA_MERGE';

export const TASK_TYPE_METADATA_DATA_DOWNLOAD = 'METADATA_DATA_DOWNLOAD';
export const TASK_TYPE_METADATA_DATA_MERGE = 'METADATA_DATA_MERGE';

export const TASK_TYPE_COMPANY_COMMENT_DATA_DOWNLOAD =
  'COMPANY_COMMENT_DATA_DOWNLOAD';
export const TASK_TYPE_COMPANY_COMMENT_DATA_MERGE =
  'COMPANY_COMMENT_DATA_MERGE';

数据同步

数据仓库文件结构

  ├── YYYY                              //年份,格式YYYY
  │   └── MM-DD                         //月日,格式MM-DD
  │       ├─── company.zip
  │       ├─── job.zip
  │       ├─── job_tag.zip
  │       ├─── company_tag.zip

Git 读取数据仓库文件结构

https://git-scm.com/docs/gitprotocol-v2

sequenceDiagram
    participant GitService
    participant GitServer

    GitService ->> GitServer: 发送ls-refs请求
    GitServer -->> GitService: 返回refs
    GitService ->> GitService: 根据refs获取commitHash
    GitService ->> GitServer: 根据commitHash获取treesIdx
    GitServer -->> GitService: 返回treesIdx
    GitService ->> GitServer: 根据treesIdx遍历仓库目录
    GitServer -->> GitService: 返回仓库目录
    GitService -->> GitService: 根据仓库目录过滤符合日期目录的指定文件的文件列表

数据合并文件

  1. 文件格式: excel

  2. 文件内容格式

    字段名字段类型备注
    编号string记录唯一标识
    xxxxxx
    __VERSION_int版本号,如果该字段不存在,则会被认为第 0 版
  3. 版本识别规则

    1. 通过版本号字段读取文件的文件字段列表
    2. 根据文件字段列表识别文件是否合法

数据递增规则

  1. 以记录创建时间为数据增量规则

  2. 各数据格式增量更新规则字段

    1. 职位

      1. createDatetime
      2. updateDatetime
      3. isFullCompanyName
    2. 职位公开数据

      1. createDatetime
    3. 职位快照

      1. updateDatetime
    4. 职位标签

      未正确实现覆盖更新

      1. updateDatetime
    5. 公司

      1. sourceRefreshDatetime
    6. 公司标签

      未正确实现覆盖更新

      1. updateDatetime
    7. 公司评论

      1. createDatetime

各数据类型版本

职位

字段类型生效版本格式
职位自编号string0
发布平台string0
职位访问地址string0
职位string0
公司string0
公司是否为全称bool0
地区string0
地址string0
经度number0
纬度number0
职位描述string0
学历string0
所需经验string0
技能string1
福利string1
最低薪资number0
最高薪资number0
几薪number0
首次发布时间datetime0rfc3339/yyyy-MM-dd HH:mm:ss
招聘人string0
招聘公司string0
招聘者职位string0
首次扫描日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss
记录更新日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss

职位标签

字段类型生效版本格式
职位编号string0
标签string0
记录更新日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss

职位公开数据

字段类型生效版本格式
职位自编号string0
首次扫描日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss
记录更新日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss

职位快照

字段类型生效版本格式
编号string0
职位编号string0
职位链接string0
内容string0
招聘平台string0
创建日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss
更新日期datetime0rfc3339/yyyy-MM-dd HH:mm:ss

公司

字段类型生效版本格式
公司string0
公司描述string0
成立时间string0
经营状态string0
法人string0
统一社会信用代码string0
官网string0
社保人数number0
自身风险数number0
关联风险数number0
地址string0
经营范围string0
纳税人识别号string0
所属行业string0
工商注册号string0
经度number0
纬度number0
注册资本string2
注册资本货币string2
数据来源地址string0
数据来源平台string0
数据来源记录编号string0
数据来源更新时间datetime0rfc3339/yyyy-MM-dd HH:mm:ss
记录创建日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss
记录更新日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss

公司标签

字段类型生效版本格式
公司string0
标签string0
记录更新日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss

公司评论

字段类型生效版本格式
公司string0
评论string0
情感string1
数据集string1
创建日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss
更新日期datetime1rfc3339/yyyy-MM-dd HH:mm:ss

数据导入与导出

[!IMPORTANT] 数据导入逻辑与数据同步逻辑一样

  1. 数据拆分导出

Oauth

Github Oauth2 流程

sequenceDiagram
  participant ContentScript
  participant Background
  participant GithubWebsite
  participant GithubServer

  ContentScript ->> Background: authOauth2Login
  activate GithubWebsite
  Background ->> GithubWebsite: chrome.tabs.create
  Note over GithubWebsite: https://github.com/login/oauth/authorize?client_id=
  GithubWebsite ->> GithubWebsite: github login
  GithubWebsite ->> GithubWebsite: redirect to callback url
  Note over GithubWebsite: https://github.com/lastsunday/job-hunting-github-app/blob/main/INSTALL?code=
  GithubWebsite -->> Background: notify url with code
  Background ->> Background: chrome.tabs.onUpdated
  Background ->> GithubServer: http request access_token
  Note over GithubWebsite: https://github.com/login/oauth/access_token?client_id=&client_secret=&code=
  GithubServer -->> Background: return oauth info
  Background ->> GithubWebsite: chrome.tabs.remove
  deactivate GithubWebsite

BBS 系统

  1. 采用 Github Issues 作为服务端

  2. 地区选择实现原理

    1. 利用标题作为搜索字段

    2. targetId = sha256 地区名: sha256(省)-sha256(市)-sha256(区)

         search(query:"${targetId} in:title sort:created-desc is:issue is:open repo:${GITHUB_APP_REPO}", type: ISSUE, first: ${first ?? null}, after: ${after ? "\"" + after + "\"" : null},last:${last ?? null},before:${before ? "\"" + before + "\"" : null})
      
  3. 使用的 Github API

    1. HQL 查询(可查询 Issues): https://api.github.com/graphql
    2. 新增 Issues: POST https://api.github.com/repos/lastsunday/job-hunting-github-app/issues
    3. 查询 Issues Comment: GET https://api.github.com/repos/lastsunday/job-hunting-github-app/issues/${issueNumber}/comments?per_page=${pageSize}&page=${pageNum}
    4. 新增 Issues Comment: POST https://api.github.com/repos/lastsunday/job-hunting-github-app/issues/${issueNumber}/comments

自动化

  1. 采用 puppeteer 类库在 debug 模式下运行
  2. 当前用于自动浏览职位页面

LLM

  1. 外部 LLM

    1. ollama

      https://docs.ollama.com/api/introduction

    2. openai

      https://platform.openai.com/docs/api-reference/chat

    3. siliconflow

      https://docs.siliconflow.com/en/api-reference/chat-completions/chat-completions

  2. 内嵌 LLM

    1. web-llm

      https://github.com/mlc-ai/web-llm

职位分析组件

[!IMPORTANT] 项目根目录: libs/analysis

编译

pnpm exec nx run analysis:build

文档

pnpm exec nx run analysis:storybook

服务器端

服务器端是一个基于 Axum 的 Rust Web 服务,提供 REST API 和后台任务调度能力。

核心功能

  • REST API: 职位/公司数据 CRUD、搜索、分页排序
  • 认证授权: JWT(access token + refresh token,HS256)
  • OpenAPI 文档: 基于 utoipa + Scalar UI,访问 /docs
  • 数据同步: 定时从远程仓库拉取数据,解析并合并入库
  • 任务调度: 基于 cron 表达式的任务计划系统,支持失败重试
  • 静态文件服务: 内嵌管理后台 SPA(server-ui

Crate 结构

graph TD
    binary["job-hunting-server<br/>(binary)"]
    api["api<br/>(路由层 + 配置)"]
    service["service<br/>(业务逻辑)"]
    entity["entity<br/>(Sea-ORM 模型)"]
    migration["migration<br/>(数据库迁移)"]
    web["web<br/>(静态文件服务)"]
    framework["framework<br/>(基础设施)"]
    macros["macros / framework-macros<br/>(proc-macro)"]
    build_metadata["build-metadata<br/>(构建元数据)"]

    binary --> api
    api --> framework
    api --> service
    api --> entity
    api --> migration
    api --> web
    api --> macros
    framework --> macros
    framework --> build_metadata
Crate说明
job-hunting-server二进制入口:CLI 解析、Tokio runtime、日志、信号处理
apiREST API 路由定义、路由组装、配置结构体、AppState
service业务逻辑层:任务调度、数据同步、文件解析、Git 操作
entitySea-ORM 实体模型(自动生成 + 手动补充)
migrationSea-ORM 数据库迁移脚本
web使用 rust-embed 内嵌 server-ui 前端产物
framework共享基础设施:认证、错误处理、数据提取器、数据库连接、中间件
macrosproc-macro 工具(#[error] 错误码宏等)

技术栈

  • Web 框架: Axum 0.8
  • 中间件: tower-http(CORS、timeout、body limit、tracing、compression)
  • API 文档: utoipa-axum + Scalar
  • ORM: Sea-ORM(PostgreSQL / SQLite)
  • Auth: jsonwebtoken(HS256)+ bcrypt
  • 配置: figment(TOML 文件 + 环境变量 + CLI 覆盖)
  • ID 生成: xid
  • 日志: tracing + tracing-subscriber(支持 console、文件、flamegraph、tokio-console)
  • 测试: cucumber BDD + testcontainers

深入开发

架构概览

Crate 依赖关系

graph TD
    binary["job-hunting-server<br/>(二进制入口)"]
    api["api<br/>(路由 + 配置)"]
    service["service<br/>(业务逻辑)"]
    entity["entity<br/>(Sea-ORM 实体)"]
    migration["migration<br/>(迁移)"]
    web["web<br/>(静态文件)"]
    framework["framework<br/>(基础设施)"]
    macros["macros<br/>(proc-macro)"]

    binary --> api
    binary --> framework
    api --> framework
    api --> service
    api --> entity
    api --> migration
    api --> web
    framework --> macros
    service --> entity
    service --> framework

启动流程

sequenceDiagram
    participant CLI as CLI / Config
    participant Runtime as Tokio Runtime
    participant Server as Server
    participant DB as Database
    participant Router as Axum Router
    participant Scheduler as Scheduler

    CLI ->> Runtime: 解析 CLI 参数,创建 Runtime
    Runtime ->> Server: Server::new(args)
    Server ->> Server: 加载配置(figment)
    Server ->> Server: 初始化日志(tracing)
    Runtime ->> DB: establish_connection(database_url)
    DB -->> Runtime: DatabaseConnection
    Runtime ->> DB: migration::Migrator::up()
    DB -->> Runtime: 迁移完成
    Runtime ->> Runtime: Jwt::init(auth_config)
    par 并发启动
        Runtime ->> Router: start_app(state)
        Router ->> Router: create_router() + bind socket
    and
        Runtime ->> Scheduler: start_scheduler()
        Scheduler ->> Scheduler: cron 循环执行任务
    end

路由组装

所有 API 模块通过 create_routes(state: AppState) -> OpenApiRouter 导出路由,在 create_router() 中统一组装:

// api/src/lib.rs
pub fn create_router(state: AppState, ct: CancellationToken) -> Router {
    let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
        // 嵌套各模块路由
        .nest("/api", job::create_routes(state.clone()))
        .nest("/api", company::create_routes(state.clone()))
        .nest("/api", auth::create_routes(state.clone()))
        // ... 更多模块
        .split_for_parts();

    router
        // Scalar API 文档
        .merge(Scalar::with_url("/docs", api))
        // 静态文件
        .route("/assets/{*file}", get(web::assets_handler))
        // SPA fallback
        .fallback(web::index_handler)
        // 全局中间件
        .layer((
            TimeoutLayer::new(Duration::from_secs(300)),
            DefaultBodyLimit::max(100 * 1024 * 1024),
            TraceLayer::new_for_http(),
            CorsLayer::permissive(),
        ))
}

Handler 编写规范

每个 API handler 遵循统一模式:

// 1. 标签常量
const TAG: &str = "Company";

// 2. handler 函数
#[debug_handler]
#[utoipa::path(
    post,
    path = "/company",
    tag = TAG,
    security(("AccessToken" = [])),
    request_body = CreateCompanyParam,
    responses(
        (status = 200, description = "创建成功", body = ApiResponse<Company>),
    )
)]
async fn create(
    State(AppState { conn, .. }): State<AppState>,
    ValidJson(param): ValidJson<CreateCompanyParam>,
) -> ApiResult<ApiResponse<Company>> {
    let result = CompanyService::create(&conn, param).await?;
    Ok(ApiResponse::ok(result))
}

// 3. 路由导出
pub fn create_routes(state: AppState) -> OpenApiRouter {
    OpenApiRouter::new()
        .routes(routes!(create, update, search, detail, delete))
        .with_state(state)
}

关键约定:

  • #[debug_handler] 提供编译期类型匹配错误提示
  • #[utoipa::path] 同时生成 Axum 路由和 OpenAPI 文档
  • ValidJson<T> / ValidQuery<T> 自动校验请求体/查询参数
  • 响应统一用 ApiResult<ApiResponse<T>> 包装
  • routes! 宏来自 utoipa_axum,批量注册路由

中间件链

全局中间件按顺序应用:

中间件说明
TimeoutLayer300 秒请求超时
DefaultBodyLimit100 MiB 请求体上限
TraceLayertracing 日志注入
CorsLayer允许所有来源跨域

模块级中间件:

  • JwtAuth — 通过 route_layer(get_auth_layer()) 添加到需要认证的路由组
  • CompressionLayer — 对静态资源路由启用 gzip 压缩

AppState

AppState 通过 Axum State 提取器在 handler 间共享:

pub struct AppState {
    pub conn: DatabaseConnection,
    pub auth_config: AuthConfig,
}

ErrorCode 设计

使用教程

1. 定义错误码枚举

使用 #[error] 宏在 framework/src/error/ 下定义枚举,或在各业务模块中定义。

命名规则 - 枚举名称必须以 ErrorCode 结尾,前缀即为模块名

枚举名称模块名
FrameworkErrorCodeframework
UserErrorCodeuser

框架级错误码 - 定义在 framework/src/error/

#[error]
pub enum FrameworkErrorCode {
    // 3xx 框架错误: 参数校验失败、无效查询等
    ValidationInvalid = 301001,
    QueryInvalid = 301002,
}

业务级错误码 - 定义在各业务模块中

#[error]
pub enum UserErrorCode {
    // 5xx 业务错误
    AccountNotFound = 503001,
    OldPasswordIncorrect = 503002,
}

默认 message - 从模块名 + 变体名自动生成(空格分隔)

模块名变体名默认消息
userAccountNotFound"user account not found"
userOldPasswordIncorrect"user old password incorrect"
frameworkValidationInvalid"framework validation invalid"

自定义 message - 使用宏属性

#[error]
pub enum UserErrorCode {
    #[error(message = "用户不存在")]
    AccountNotFound = 503001,
}

2. 在业务代码中使用

基本用法 - 使用 err! 宏自动注入调用位置

return Err(err!(UserErrorCode::AccountNotFound));
// message = "user account not found"
// 自动注入调用位置 (file:line)

添加 extra_message

// 需要在 ErrorCode 定义中添加 with_extra 方法
return Err(err!(UserErrorCode::AccountNotFound.with_extra("user_id=123")));
// message = "user account not found"
// extra_message = "user_id=123"
// 自动注入调用位置

使用 ? 运算符

let user = User::find_by_id(id).await?
    .ok_or(err!(UserErrorCode::AccountNotFound))?;

使用 with_extra

.ok_or(err!(UserErrorCode::AccountNotFound.with_extra("user_id not found")))?;

3. API 响应格式

{
  "code": 503001,
  "message": "account not found"
}

  1. error_tests! 宏中添加新类型:

    error_tests! {
        FrameworkErrorCode,
        UserErrorCode,
        CompanyErrorCode,
        JobErrorCode,
        NewErrorCode,  // 添加新类型
    }

设计原理

错误码分类

分类范围说明日志级别API 错误响应(StatusCode/Code/Message)
1xxyyy101001-199999底层错误,预留error500/内部错误 code/内部错误
2xxyyy201001-299999第三方错误error500/内部错误 code/内部错误
3xxyyy301001-399999框架错误:如参数校验失败warn500/内部错误 code/内部错误
4xxyyy401001-499999关键业务错误:如多次登录错误,数据不完整warn500/内部错误 code/内部错误
5xxyyy501001-599999业务错误info400/直出/直出

注意事项

  • 登录错误: API 错误响应,按常规方式处理
  • 参数校验失败: API 错误响应,按常规方式处理

编码规则

6 位数字:类别(1-5) + 模块(01-99) + 序号(001-999)

err! 宏

使用 err! 宏可以自动注入调用位置(file:line)。

原理

  • 自动注入调用位置(file!() + line!()
  • 存储在 ApiErrorfileline 字段中

日志输出

[503001]account not found at src/user.rs:86

使用前提

  1. 在模块中引入 prelude:
use framework::prelude::*;
  1. 开启 DEBUG 日志级别:
RUST_LOG=DEBUG

注意:文件和行号只在 DEBUG 级别下打印

核心逻辑与数据结构设计

数据同步流程

[!WIP]

数据同步流程以任务形式执行

流程:计算数据下载任务 -> 数据下载 -> 数据贮藏与合并

  • 计算数据下载任务
    • (task_data_plan,task_data_source_plan)task_plan <-> task,task_data_download
  • 数据下载
    • task,task_data_download <-> file,task,task_data_merge
  • 数据贮藏与合并
    • task,task_data_merge <-> task,task_data_merge,job_source,company_source,job_tag_source,company_tag_source,job,company,job_tag,company_tag

数据合并

sequenceDiagram
  participant out_data as 外部数据
  participant logic as 数据处理器
  participant source as 来源表
  participant main as 主表

  out_data ->> logic: 发送外部数据
  logic ->> logic: 提取外部数据中的唯一标识(业务主键+uri)列表
  logic ->> source: 根据唯一标识列表查询现存来源数据
  source ->> logic: 返回现存来源数据
  logic ->> logic: 去掉外部数据中重复的现存来源数据,得到去重外部数据
  logic ->> source: 插入去重外部数据
  source ->> logic: 插入外部去重数据成功
  logic ->> logic: 提取去重外部数据的业务主键列表
  logic ->> main: 根据业务主键列表查询现存主数据
  main ->> logic: 返回现存主数据
  logic ->> logic: 根据现存主数据和去重外部数据,计算出主数据插入列表和主数据更新列表
  logic ->> main: 插入主数据插入列表
  main ->> logic: 插入主数据插入列表成功
  logic ->> main: 更新主数据更新列表
  main ->> logic: 更新主数据更新列表成功

核心数据模型

URI 规范

参考:RFC 3986

  • 第三方数据来源格式:data://[username[@]]host/path
  • 本地文件导入数据来源格式:data://[username[@]]system/[file_hash]
  • 界面新增或编辑数据来源格式:data://[username[@]]system-ui/[version]/[file_hash]/[rfc3339]

字段说明:

  • username:数据贡献者/上传者标识

示例:

data://lastsunday@github.com/lastsunday/job-hunting-data/2026/03-03/job.zip
data://lastsunday@github.com/lastsunday/job-hunting-data/2024/12-20/company.zip
data://admin@system/c38036efd481b06a5b0a4292ac7dfbcfb93e9eb0d1c4de0aef0f4d685cf6b3a9
data://admin@system-ui/1/c38036efd481b06a5b0a4292ac7dfbcfb93e9eb0d1c4de0aef0f4d685cf6b3a9/2026-04-16T14:08:17+00:00

职位相关表

设计说明:

  • job_source:多渠道原始数据来源表,存储来自不同用户/渠道的原始数据
  • job:标准化表,去重后的一手数据
---
title: Job ER
---
erDiagram
    job_source{
      id varchar(255) PK "编号"
      job_id varchar(255) "职位编号"
      platform varchar(255) "发布平台"
      url varchar(255) "链接"
      name varchar(255) "名称"
      company_name varchar(255) "公司名"
      location_name varchar(255) "地区"
      address varchar(255) "地址"
      longitude numeric(16_13) "经度"
      latitude numeric(16_13) "纬度"
      description text "描述"
      degree_name varchar(255) "学历"
      year int2 "所需经验"
      salary_min numeric(12_2) "最低薪资"
      salary_max numeric(12_2) "最高薪资"
      salary_total_month int2 "几薪"
      first_publish_datetime timestamptz "首次发布时间"
      boss_name varchar(255) "招聘人名称"
      boss_company_name varchar(255) "招聘公司"
      boss_position varchar(255) "招聘者职位"
      is_full_company_name bool "公司名是否为全称"
      skill_tag text "技能标签: 逗号作为分隔符"
      welfare_tag text "福利标签: 逗号作为分隔符"
      first_scan_datetime timestamptz "首次扫描时间"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    job_source }|..|| job : "源自(最新)"
    job {
      id varchar(255) PK "职位编号"
      platform varchar(255) "发布平台"
      url varchar(255) "链接"
      name varchar(255) "名称"
      company_name varchar(255) "公司名"
      location_name varchar(255) "地区"
      address varchar(255) "地址"
      longitude numeric(16_13) "经度"
      latitude numeric(16_13) "纬度"
      description text "描述"
      degree_name varchar(255) "学历"
      year int2 "所需经验"
      salary_min numeric(12_2) "最低薪资"
      salary_max numeric(12_2) "最高薪资"
      salary_total_month int2 "几薪"
      first_publish_datetime timestamptz "首次发布时间"
      boss_name varchar(255) "招聘人名称"
      boss_company_name varchar(255) "招聘公司"
      boss_position varchar(255) "招聘者职位"
      is_full_company_name bool "公司名是否为全称"
      skill_tag text "技能标签: 逗号作为分隔符"
      welfare_tag text "福利标签: 逗号作为分隔符"
      first_scan_datetime timestamptz "首次扫描时间"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }

公司相关表

设计说明:

  • company_source:多渠道原始数据来源表,存储来自不同用户/渠道的原始数据
  • company:标准化表,去重后的一手数据
---
title: Company ER
---
erDiagram
    company_source{
      id varchar(255) PK "编号"
      company_id varchar(255) "公司编号"
      name varchar(255) "名称"
      desc text "描述"
      start_date timestamptz "成立时间"
      status varchar(255) "经营状态"
      legal_person varchar(255) "法人"
      unified_code varchar(255) "统一社会信用代码"
      web_site text "官网"
      insurance_num int4 "社保人数"
      self_risk int4 "自身风险数"
      union_risk int4 "关联风险数"
      address text "地址"
      scope text "经营范围"
      tax_no varchar(255) "纳税人识别号"
      industry varchar(255) "所属行业"
      license_number varchar(255) "工商注册号"
      longitude numeric(16_13) "经度"
      latitude numeric(16_13) "纬度"
      source_url text "数据来源地址"
      source_platform varchar(255) "数据来源平台"
      source_record_id varchar(255) "数据来源记录编号"
      source_refresh_datetime timestamptz "数据来源更新时间"
      reg_capital_value numeric(17_2) "注册资本数值"
      reg_capital_currency varchar(255) "注册资本货币"
      paidin_capital_value numeric(17_2) "实缴资本数值"
      paidin_capital_currency varchar(255) "实缴资本货币"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    company_source }|..|| company : "源自(最新)"
    company{
      id varchar(255) PK "公司编号"
      name varchar(255) UK "名称"
      desc text "描述"
      start_date timestamptz "成立时间"
      status varchar(255) "经营状态"
      legal_person varchar(255) "法人"
      unified_code varchar(255) "统一社会信用代码"
      web_site text "官网"
      insurance_num int4 "社保人数"
      self_risk int4 "自身风险数"
      union_risk int4 "关联风险数"
      address text "地址"
      scope text "经营范围"
      tax_no varchar(255) "纳税人识别号"
      industry varchar(255) "所属行业"
      license_number varchar(255) "工商注册号"
      longitude numeric(16_13) "经度"
      latitude numeric(16_13) "纬度"
      source_url text "数据来源地址"
      source_platform varchar(255) "数据来源平台"
      source_record_id varchar(255) "数据来源记录编号"
      source_refresh_datetime timestamptz "数据来源更新时间"
      reg_capital_value numeric(17_2) "注册资本数值"
      reg_capital_currency varchar(255) "注册资本货币"
      paidin_capital_value numeric(17_2) "实缴资本数值"
      paidin_capital_currency varchar(255) "实缴资本货币"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }

标签数据模型

---
title: Tag ER
---
erDiagram
    tag{
      id varchar(255) PK "编号"
      name text "名称"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    tag ||--o{ job_tag_source: "关联"
    job_tag_source{
      id varchar(255) PK "编号"
      job_id varchar(255) "职位编号"
      tag_id varchar(255) "标签编号"
      seq int4 "序号"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    tag ||--o{ company_tag_source: "关联"
    company_tag_source{
      id varchar(255) PK "编号"
      company_id varchar(255) "公司编号"
      company_name varchar(255) "公司名称"
      tag_id varchar(255) "标签编号"
      seq int4 "序号"
      uri varchar(255) "来源"
      publish_datetime timestamptz "数据发布时间"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    tag ||--o{ job_tag: "关联"
    job ||--o{ job_tag: "关联"
    job_tag_source }|..|| job_tag : "源自(最新)"
    job_tag{
      id varchar(255) PK "编号"
      job_id varchar(255) "职位编号"
      tag_id varchar(255) "标签编号"
      seq int4 "序号"
      uri varchar(255) "来源"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    tag ||--o{ company_tag: "关联"
    company ||--o{ company_tag: "关联"
    company_tag_source }|..|| company_tag : "源自(最新)"
    company_tag{
      id varchar(255) PK "编号"
      company_id varchar(255) "公司编号"
      company_name varchar(255) "公司名称"
      tag_id varchar(255) "标签编号"
      seq int4 "序号"
      uri varchar(255) "来源"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }

任务系统设计

任务关系图

设计说明:

  • task_data_plan / task_data_source_plan:用于生成 task_plan 的配置表,便于从界面上描述任务计划,实际参与任务生成的是 task_plan
---
title: Task ER
---
erDiagram
    task_plan{
      id varchar(255) PK "编号"
      type int4 "任务计划类型: 0: DATA_DOWNLOAD,1: METADATA_DOWNLOAD"
      enable bool "开关"
      config jsonb "配置"
      cron varchar(255) "cron定时任务表达式"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task_plan ||..o| task : "触发"
    task{
      id varchar(255) PK "编号"
      plan_id varchar(255) "任务计划编号"
      type varchar(255) "任务类型"
      data_id varchar(255) "任务编号"
      status varchar(255) "状态"
      error_reason text "错误原因"
      cost_time int4 "耗时"
      retry_count int4 "重试次数"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task_data_plan ||--|| task_plan: owns
    task_data_plan{
      id varchar(255) PK "编号"
      plan_id varchar(255) "任务计划编号"
      username varchar(255) "用户名"
      repo_name varchar(255) "仓库名"
      repo_type varchar(255) "仓库类型"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task_data_source_plan ||--|| task_plan: owns
    task_data_source_plan{
      id varchar(255) PK "编号"
      plan_id varchar(255) "任务计划编号"
      username varchar(255) "用户名"
      repo_name varchar(255) "仓库名"
      repo_type varchar(255) "仓库类型"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task ||--|| task_data_download: owns
    task_data_download{
      id varchar(255) PK "编号"
      type varchar(255) "下载数据任务类型"
      username varchar(255) "用户名"
      repo_name varchar(255) "仓库名"
      datetime timestamptz "时间"
      config jsonb "配置"
      data_id varchar(255) "文件编号"
      seq int4 "序号"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task ||--|| task_data_merge: owns
    task_data_merge{
      id varchar(255) PK "编号"
      type varchar(255) "合并数据任务类型"
      username varchar(255) "用户名"
      repo_name varchar(255) "仓库名"
      datetime timestamptz "时间"
      data_id varchar(255) "文件编号"
      data_count int4 "合并后的数据总数"
      config jsonb "配置"
      data_page_num int4 "分页页码,控制批量合并的页索引"
      data_page_size int4 "分页大小,控制每批合并的数据量"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }
    task_data_download ||..|| file : "生成"
    task_data_merge ||..|| file : "读取"
    file{
      id varchar(255) PK "编号"
      name varchar(255) "名称"
      sha varchar(255) "sha"
      content BLOB "内容"
      size int8 "尺寸"
      is_delete bool "是否删除"
      create_datetime timestamptz "创建时间"
      update_datetime timestamptz "更新时间"
    }

任务类型

  • JOB_DATA_DOWNLOAD
  • JOB_DATA_STORAGE_AND_MERGE
  • COMPANY_DATA_DOWNLOAD
  • COMPANY_DATA_STORAGE_AND_MERGE
  • COMPANY_TAG_DATA_DOWNLOAD
  • COMPANY_TAG_DATA_STORAGE_AND_MERGE
  • JOB_TAG_DATA_DOWNLOAD
  • JOB_TAG_DATA_STORAGE_AND_MERGE

任务状态

状态值:

  • READY:等待执行
  • RUNNING:执行中
  • CANCEL:已取消
  • FINISHED:执行成功
  • FINISHED_BUT_ERROR:执行完成但有错误
  • ERROR:执行失败
---
title: Task state
---
stateDiagram-v2
    [*] --> READY
    READY --> RUNNING
    READY --> CANCEL
    RUNNING --> FINISHED
    RUNNING --> FINISHED_BUT_ERROR
    RUNNING --> ERROR
    RUNNING --> CANCEL
    FINISHED --> [*]
    FINISHED_BUT_ERROR --> [*]
    ERROR --> RUNNING
    CANCEL --> [*]

状态转换说明:

  • READY → RUNNING:任务被调度器领取
  • READY → CANCEL:任务被取消
  • RUNNING → FINISHED:任务执行成功
  • RUNNING → FINISHED_BUT_ERROR:任务执行完成但有错误(如部分数据处理失败、超出重试次数)
  • RUNNING → ERROR:任务执行失败(如网络异常、数据解析错误)
  • RUNNING → CANCEL:任务执行中被取消
  • ERROR → RUNNING:任务重试执行

任务编排

流程串联

流程一:计算数据下载任务

  • task_data_plan / task_data_source_plan 生成 task_plan
  • task_plan 触发创建 tasktask_data_download

流程二:数据下载

  • task + task_data_download 执行下载
  • 下载完成后生成 file 文件
  • 下载任务完成后,根据预设的最大数据量生成多个合并任务,避免一次超大合并带来的性能问题

流程三:数据贮藏与合并

  • task + task_data_merge 执行合并
  • 根据 data_page_numdata_page_size 读取对应批次数据
  • 合并流程:
    1. 写入 source 表(job_source, company_source, job_tag_source, company_tag_source),遇到重复数据则忽略
    2. 将最新数据写入标准化表(job, company, job_tag, company_tag)
  • data_count 记录合并后的数据总数

失败重试策略

  • ERROR 状态可触发重试,回到 RUNNING
  • retry_count 字段记录当前重试次数
  • 最大重试次数由系统配置设定
  • 超出最大重试次数后状态置为 FINISHED_BUT_ERROR

配置与部署

配置加载机制

配置通过 figment 加载,支持多层来源,后者覆盖前者:

  1. JH_CONFIG 环境变量 → 指向一个 TOML 文件路径
  2. --config / -c CLI 参数 → 指定 TOML 配置文件路径
  3. JH_* 环境变量 → 字段名转大写加 JH_ 前缀,如 database_urlJH_DATABASE_URL
  4. -O key=value CLI 参数 → 直接覆盖(TOML 语法)
# 方式一:指定配置文件
./server --config=application-dev.toml

# 方式二:环境变量
JH_CONFIG=/etc/job-hunting/server.toml ./server

# 方式三:覆盖单个字段
./server -O database_url="postgres://localhost/mydb" -O port=8080

配置项参考

以下为 config 结构体中的全部字段,默认值即不做配置时的值。

Server

字段类型默认值说明
server_namestring"localhost"服务器名称
addressstring / string[]["127.0.0.1", "::1"]监听地址,支持多个
portu16 / u16[]3000监听端口

Database

字段类型默认值说明
database_urlstring"sqlite://db.sqlite?mode=rwc"数据库连接 URL(PostgreSQL 或 SQLite)

Auth

字段类型默认值说明
auth_access_token_secretstring"QLjJTeVblAlM47de"Access Token 签名密钥
auth_access_token_expires_inu3228800Access Token 过期时间(秒)
auth_refresh_token_secretstring"N8lI0uitNzJl6vYK"Refresh Token 签名密钥
auth_refresh_token_expires_inu3215897600Refresh Token 过期时间(秒)
auth_audiencestring"audience"JWT audience
auth_issuerstring"issuer"JWT issuer
auth_client_idstring"d1aicsr57dijo7h963ig"客户端 ID
auth_client_secretstring"ujTgh2lEQYy0PXhK"客户端密钥

生产环境必须修改: auth_access_token_secretauth_refresh_token_secret

Task

字段类型默认值说明
history_file_max_sizeu645368709120历史文件最大总大小(字节,默认 5 GiB)

Logging

字段类型默认值说明
log_console_enabledbooltrue启用控制台日志
log_console_levelstring"info"控制台日志级别(trace/debug/info/warn/error)
log_console_formatstring"text"控制台输出格式(text/json/compact/pretty)
log_file_enabledboolfalse启用文件日志
log_file_levelstring"info"文件日志级别
log_file_formatstring"json"文件输出格式
log_file_directorystring"./logs"日志目录
log_file_namestring"server"日志文件名前缀
log_file_max_filesu3210最多保留的日志文件数
log_file_rotationstring"daily"日志轮转策略(daily/hourly/never)
log_tokio_console_enabledboolfalse启用 tokio-console(性能分析)
log_flame_enabledboolfalse启用火焰图输出
log_flame_directorystring"./flame"火焰图输出目录

完整配置示例

server_name = "job-hunting"
address = "0.0.0.0"
port = 3000

database_url = "postgres://user:password@localhost:5432/job_hunting"

# 生产环境务必修改以下两项
auth_access_token_secret = "your-long-secret-string"
auth_access_token_expires_in = 28800
auth_refresh_token_secret = "your-another-secret-string"
auth_refresh_token_expires_in = 15897600

log_console_enabled = true
log_console_level = "info"
log_file_enabled = true
log_file_level = "debug"
log_file_directory = "/var/log/job-hunting"
log_file_rotation = "daily"

数据库迁移

迁移由 Sea-ORM 管理,在服务器启动时自动执行,无需手动干预:

// api/src/lib.rs - api::start() 中
migration::Migrator::up(&conn, None).await?;

也可手动执行迁移 CLI:

# 在 migration 目录下
cargo run -- up

迁移文件位于 migration/src/,按时间命名:

  • m20260000_000000_init.rs — 初始建表
  • m20260605_000001_add_indexes.rs — 索引补充

Docker 部署

构建镜像

# 交叉编译二进制(由 CI 负责)
# 构建 Docker 镜像
docker build -t job-hunting-server -f apps/server/Dockerfile .

Dockerfile(简化版)

FROM alpine:3.20.2
WORKDIR /app
COPY target/release/job-hunting-server /app/server
EXPOSE 3000
CMD ["./server"]

docker-compose(PostgreSQL 本地开发)

项目中提供了 docker-compose.yml,仅包含 PostgreSQL 服务:

cd apps/server
docker compose up -d    # 启动 PostgreSQL

修改配置指向本地 PostgreSQL:

./server -O database_url="postgres://postgres:changeme@127.0.0.1:5432/postgres"

运行时控制

CLI 参数

./server --help

# 可选参数:
#   -c, --config <FILE>      指定配置文件(可多次指定)
#   -O <KEY=VALUE>          覆盖配置项(TOML 语法)

信号(Linux/macOS)

信号行为
SIGINT优雅关闭
SIGTERM / SIGQUIT立即关闭
SIGUSR1重新加载日志配置
SIGUSR2循环切换控制台日志级别

moon 任务

命令说明
moon run server:build编译二进制
moon run server:dev开发模式运行(使用 application-dev.toml
moon run server:run正式运行(使用 application.toml
moon run server:test运行测试
moon run server:lint运行 clippy
moon run server:format格式检查
moon run server:bump版本升级 + 生成 CHANGELOG

测试

测试架构

测试分布在三个 crate 中,按层次划分:

Crate测试类型工具位置
framework纯单元测试#[cfg(test)] 内联src/*.rs
service业务逻辑集成测试testcontainers + SQLiteservice/tests/
apiAPI BDD 测试 + 接口集成测试cucumber + tower::oneshotapi/tests/
graph TD
    subgraph "api/tests/"
        BDD["Cucumber BDD<br/>(feature 文件 + step 定义)"]
        INT["HTTP 集成测试<br/>(oneshot 发请求)"]
    end
    subgraph "service/tests/"
        SVC["业务逻辑测试<br/>(DAO / Git / 任务流)"]
    end
    subgraph "framework/src/"
        UNIT["纯单元测试<br/>(工具函数)"]
    end
    BDD -->|调用 handlers| Router["Axum Router<br/>(完整路由栈)"]
    INT -->|调用 handlers| Router
    SVC -->|直接操作| DB["Sea-ORM Connection<br/>(SQLite in-memory)"]

Cucumber BDD 测试

BDD 测试是 API 层的主要测试方式,使用 cucumber crate。

目录结构

api/tests/
├── common/
│   └── mod.rs              # 测试辅助函数:HTTP 客户端、DB 初始化
├── features/               # Gherkin 场景文件
│   ├── auth/auth.feature
│   ├── company/company.feature
│   └── job/get_info/search.feature
├── feature_auth_test.rs    # Auth step 定义
├── feature_company_test.rs # Company step 定义
├── feature_job_test.rs     # Job search step 定义
├── company_test.rs         # 纯单元测试(CSV 转换)
└── index_test.rs           # 冒烟测试

World 结构体

每个 feature 文件对应一个 World 结构体,实现 cucumber::World trait:

#[derive(Debug, Default, cucumber::World)]
struct TestWorld {
    // 数据库连接
    conn: Option<DatabaseConnection>,
    // 当前登录用户的 token
    access_token: Option<String>,
    // 某场景下的状态(如创建的公司 ID)
    company_id: Option<String>,
    // ...
}

Feature 文件示例

Feature: Company CRUD
  Background:
    Given insert tables

  Scenario: Create company
    Given access_token without token
    When I create the #companyName company
    Then I should see company created

  Scenario: Search companies
    Given access_token without token
    And company #companyName created
    When I search companies without params
    Then I should see company #companyName in the search results

Step 定义示例

#[given(expr = "access_token {word} token")]
async fn setup_token(world: &mut TestWorld, token_type: String) {
    // 根据 token_type 设置 world.access_token
    match token_type.as_str() {
        "without" => world.access_token = None,
        "with" => {
            // 登录获取 token
            let result = login(&world.conn, "root", "root").await;
            world.access_token = Some(result.access_token);
        }
        _ => unreachable!(),
    }
}

#[when(expr = "I create the {string} company")]
async fn create_company(world: &mut TestWorld, name: String) {
    use common::post_json_with_token;

    let resp = post_json_with_token(
        &world.app, "/api/company", &create_params(&name), world.access_token.as_deref()
    ).await;
    let body = common::response_to_json(resp).await;
    world.company_id = Some(common::get_from_value(&body, "data.id"));
}

#[then(expr = "I should see company {string} in the search results")]
async fn check_company_in_results(world: &mut TestWorld, name: String) {
    let resp = get_json_with_token(
        &world.app, "/api/company/search?name={name}", world.access_token.as_deref()
    ).await;
    let body = common::response_to_json(resp).await;
    let items = common::get_json_paging_result_items(&body);
    assert!(items.iter().any(|i| i["name"] == name));
}

场景执行入口

每个 BDD 测试文件有一个 #[tokio::test] 入口:

#[tokio::test]
async fn main() {
    TestWorld::cucumber()
        .before(|_feature, _rule, _scenario, world| {
            // 每个场景前执行:初始化数据库、组装 Router
            Box::pin(async {
                let (container, conn) = common::setup_database().await;
                world.conn = Some(conn.clone());
                world.app = Some(create_test_router(conn).await);
                world.container = container;
            })
        })
        .after(|_feature, _rule, _scenario, world| {
            // 每个场景后执行:清理数据库
            Box::pin(async {
                common::tear_down(world.container.take()).await;
            })
        })
        .run("tests/features/company/company.feature")
        .await;
}

运行 BDD 测试

# 运行所有 API 测试(含 BDD 和集成测试)
cargo test --package job-hunting-server

# 仅运行 API crate 的测试
moon run server:test

数据库:测试隔离

每个测试场景获得独立的 SQLite in-memory 数据库,通过 setup_database() 创建:

pub async fn setup_database() -> (Option<ContainerAsync<Postgres>>, DatabaseConnection) {
    // 当前使用 SQLite in-memory(快速、隔离)
    let container = None;
    let conn = framework::database::establish_connection("sqlite::memory:")
        .await
        .unwrap();
    // 运行全部迁移
    migration::Migrator::up(&conn, None).await.unwrap();
    (container, conn)
}

设计预留了切换到 PostgreSQL 的能力:取消注释 testcontainers 部分即可,Option<ContainerAsync<Postgres>> 保证了兼容。

测试辅助工具

api/tests/common/mod.rs 提供了一套 HTTP 测试工具函数,通过 tower::ServiceExt::oneshot 直接向 Axum Router 发请求(不走 TCP 网络):

函数说明
setup_database()初始化 SQLite 内存库 + 运行迁移
tear_down()如需则停止 testcontainers 容器
response_to_json()Response<Body>serde_json::Value
get_json_result()从 API 响应中提取 data 字段
get_json_paging_result_items()从分页响应中提取 data.items
get_from_value()从 JSON Value 中按类型提取字段
post_json()POST JSON body
post_json_with_token()POST JSON body + Bearer token
get_json() / get_json_with_token()GET + 可选 token
put_json() / put_json_with_token()PUT + 可选 token
delete_json() / delete_json_with_token()DELETE + 可选 token

服务层测试

service/tests/ 下的测试直接操作 Sea-ORM 连接和业务逻辑:

  • dao_test.rs: 实体 CRUD、分页查询
  • file_parser_test.rs: Excel 文件解析、表头校验
  • import_job_test.rs: 从 Excel 行导入职位数据
  • import_company_test.rs: 从 Excel 行导入公司数据
  • task_test.rs: 完整的下载+合并任务流程
  • scheduler_test.rs: 任务计划调度 + 任务队列消费
  • git_test.rs / git_lite_test.rs: Git 操作(需 Gitea testcontainers)

服务层测试也提供了一套公共工具(service/tests/common/mod.rs),包括:

  • setup_gitea_with_test_data() — 启动 Gitea 容器并推送测试 fixture 数据
  • create_test_file_map() — 映射测试资源文件

测试用 Fixture 数据

service/tests/resources/data/
├── job-v0.zip / job-v1.zip         # 职位数据
├── company-v0.zip / company-v1.zip / company-v2.zip  # 公司数据
├── job-v1.xlsx                     # 职位 Excel
└── company-v2.xlsx                 # 公司 Excel

新增测试场景

新增 API BDD 场景

  1. features/ 对应子目录下编写 .feature 文件
  2. feature_*_test.rs 中添加 #[given] / #[when] / #[then] step 定义
  3. 在 step 定义中使用 common::* 工具函数发送 HTTP 请求、提取响应

新增服务层测试

  1. service/tests/ 下新建 _test.rs 文件
  2. 使用 common::setup_database() 初始化数据库
  3. 直接调用 service crate 中的函数并断言结果

数据源

数据源仓库说明

  1. 仓库目录结构
├── 2025                              //年份,格式YYYY
│   └── 07-01                         //月日,格式MM-DD
│       └── 深圳避雷公司.zip          //数据文件,格式xxx.zip(包含文件xxx.xlsx)备注:zip文件和xlsx文件的文件名必须相同
├── metadata.json                     //源数据,包含数据源元数据,可用的数据源列表
  1. metadata.json 文件格式
{
  "data": {
    "source": [
      {
        "name": ""    //数据源名称
        "config": {
          "taskTypeList": [
            {
              "name": "",                                 //名称
              "type": "COMPANY_COMMENT_DATA_DOWNLOAD",    //固定值,代表公司评论数据类型
              "emotion": "NEGATIVE",                      //情感导向,负面:NEGATIVE,积极:POSITIVE,正常:其他值
              "fileName": "深圳避雷公司",                             //数据文件名
              "description": "",                          //描述
              "retentionDay": 3650                      //数据保留时间,单位:天
            }
          ]
        },
        "repoType": "GITHUB",                         //固定值
        "reponame": "",                               //仓库名
        "username": "",                               //仓库用户名
        "description": ""                             //数据源描述
      }
    ]
  },
  "icon":"",                                          //元数据图标,格式为svg,注意转移字符
  "name": "",                                         //元数据名称
  "type": "GIT_METADATA",                             //固定值,GIT元数据类型
  "config": {
    "config": {
      "url": "",                                      //元数据git仓库地址,以http或https开头的地址
      "filePath": "metadata.json"                     //元数据文件名,一般取metadata.json
    }
  },
  "enable": true,                                   //是否开启
  "description": "",                                  //元数据描述
  "autoUpdateEnable": true                          //是否自动更新元数据
}
  1. 公司评论数据文件格式

    公司评论
    xxxxyyyy

    备注:

    1. 第一行的标题必须保留,插件会以标题来标识字段列的数据
    2. 公司:可以同时填写多家公司,换行隔开
    3. 公司或评论为空的行将被跳过
  2. Q&A

    1. 如何更新数据? 根据仓库目录结构,新建年月日目录,将整理后的数据文件zip 文件上传到新建的年月日目录里即可

    2. 如何快速测试文件格式是否正确 打开插件后台页面 ->系统 ->数据管理 ->公司评论数据导入

      如果能正常导入 xlsx 文件,则代表该文件正常(当前不支持导入 zip 文件)

    3. 如何使用?

      1. 新增元数据:打开插件后台页面 ->数据源 ->元数据 ->从网络导入

        1.从网络导入,填入 metadata.json 的访问地址: https://github.com/用户名/仓库名/raw/refs/heads/main/metadata.json

      2. 添加数据源: 打开插件后台页面 ->数据源 ->列表 ->搜寻数据源 ->选中新增的元数据 ->选中并添加数据源

    4. 数据同步的逻辑是怎样的? 插件会遍历整个仓库的年月日目录,根据 metadata.json 的规则进行数据的增量同步,公司评论信息采取 散列(公司名+评论)作为唯一标识进行去重逻辑。

Commit Message

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

提交说明包含了下面的结构化元素,以向类库使用者表明其意图:

    fix: 类型 为 fix 的提交表示在代码库中修复了一个 bug(这和语义化版本中的 PATCH 相对应)。
    feat: 类型 为 feat 的提交表示在代码库中新增了一个功能(这和语义化版本中的 MINOR 相对应)。
    BREAKING CHANGE: 在脚注中包含 BREAKING CHANGE: 或 <类型>(范围) 后面有一个 ! 的提交,表示引入了破坏性 API 变更(这和语义化版本中的 MAJOR 相对应)。 破坏性变更可以是任意 类型 提交的一部分。
    除 fix: 和 feat: 之外,也可以使用其它提交 类型 ,例如 @commitlint/config-conventional(基于 Angular 约定)中推荐的 build:、chore:、 ci:、docs:、style:、refactor:、perf:、test:,等等。
    build: 用于修改项目构建系统,例如修改依赖库、外部接口或者升级 Node 版本等;
    chore: 用于对非业务性代码进行修改,例如修改构建流程或者工具配置等;
    ci: 用于修改持续集成流程,例如修改 Travis、Jenkins 等工作流配置;
    docs: 用于修改文档,例如修改 README 文件、API 文档等;
    style: 用于修改代码的样式,例如调整缩进、空格、空行等;
    refactor: 用于重构代码,例如修改代码结构、变量名、函数名等但不修改功能逻辑;
    perf: 用于优化性能,例如提升代码的性能、减少内存占用等;
    test: 用于修改测试用例,例如添加、删除、修改代码的测试用例等。
    脚注中除了 BREAKING CHANGE: <description> ,其它条目应该采用类似 git trailer format 这样的惯例。
    其它提交类型在约定式提交规范中并没有强制限制,并且在语义化版本中没有隐式影响(除非它们包含 BREAKING CHANGE)。 可以为提交类型添加一个围在圆括号内的范围,以为其提供额外的上下文信息。例如 feat(parser): adds ability to parse arrays.。
    特别地:
        bump: v1.1.0,代表发布版本

Version

版本格式:主版本号.次版本号.修订号,版本号递增规则如下:

主版本号:当你做了不兼容的 API 修改,
次版本号:当你做了向下兼容的功能性新增,
修订号:当你做了向下兼容的问题修正。
先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。

Reference

auto_explain

   db = new PGlite(`opfs-ahp://${JOB_DB_PATH}`, {
       extensions: { auto_explain },
       debug:1,
   });
 await db.exec(`
   LOAD 'auto_explain';
   SET auto_explain.log_min_duration = '0';
   SET auto_explain.log_analyze = 'true';
   `);

打开offscreen的console,可看到详细的分析日志

Transaction

Chrome Extension下的持久层事务问题(Transaction)

  1. 当前采用PGLite,其事务的调用采用异步调用下的在回调函数里进行,如
await pg.transaction(async (tx) => {
  await tx.query(
    'INSERT INTO test (name) VALUES ('$1');',
    [ 'test' ]
  );
  return await tx.query('SELECT * FROM test;');
});
  1. Chrome Extension的调用链为
ContentScript(或SidePanel) -> Background -> OffScreen -> WebWorker

而持久层(即PGLite)是在WebWorker进行调用,而运行逻辑部份是放在Background或ContentScript(或SidePanel)里,这样会出现一个问题,怎样使得逻辑的调用链处在事务的回调函数里?

可能的解决方案

  1. 传递tx对象

伪代码:

let idAndTxMap = new Map();
let idAndTxPromiseResolveReject = new Map()

async function startTransaction(){
    await pg.transaction(async (tx) => {
    let id = genId();
    idAndTxMap.set(id,tx);
    postMessage(message,id);
    return new Promise((resolve,reject)=>{
        idAndTxPromiseResolveReject.set(id,{resolve,reject});
    });
    });
}

async function endTransaction(id){
    idAndTxPromiseResolveReject.get(id).resolve();
}

async function update(param,{transactionId=null}={}){
    let connection = idAndTxMap.get(transactionId)??await getDb();
    connection.query("update....",param);
}

async main(){
    let id = await startTransaction();
    update(param,{transactionId:id})
    await endTransaction(id);
}

  1. 将逻辑都放到WebWorker里

选择的解决方案

  1. 选择将逻辑都放到WebWorker里。

Dump

数据库备份

https://github.com/electric-sql/pglite/tree/main/packages/pglite-tools

https://github.com/electric-sql/pglite/blob/6b60fbc55c2d59eb7642d1d3f560999ec5b4ec40/packages/pglite-tools/README.md

这里有pgDump的工具,但整合后报错(pg_dump failed with exit code 1),不能正常导出

  1. 导出

package.json

"@electric-sql/pglite-tools": "^0.2.2",

wxt.config.ts

copyFileSync(resolve(srcDir, "node_modules", "@electric-sql", "pglite-tools", "dist", "pg_dump.wasm"), resolve(outDir, "assets", "pg_dump.wasm"));

database.js

import { PGlite } from '@electric-sql/pglite'
import { pgDump } from '@electric-sql/pglite-tools/pg_dump'

const pg = await PGlite.create()

// Create a table and insert some data
await pg.exec(`
  CREATE TABLE test (
    id SERIAL PRIMARY KEY,
    name TEXT
  );
`)
await pg.exec(`
  INSERT INTO test (name) VALUES ('test');
`)

// Dump the database to a file
const dump = await pgDump({ pg })
  1. 导入

Data Share Plan 数据共享计划

文档版本:2024-10-10

背景

招聘网站展示给应聘者的岗位存在某些问题

  1. 职位并不是最新的,有可能是挂职几个月的,一页展示的只有大概三分之一是最近的
  2. 部分职位因为使用某种手段,使其展示的优先级往上靠
  3. 某些招聘网站没有提供按职位发布时间进行排序的功能
  4. 招聘网站提供的职位搜索条件较少,一般只通过职位关键字来进行搜索

基于上述的原因,实现数据共享计划并结合 JobHutting 内置的职位偏好功能是部分痛点的解决方案

数据字段

职位数据字段

  职位自编号
  发布平台
  职位访问地址
  职位
  公司
  公司是否为全称
  地区
  地址
  经度
  纬度
  职位描述
  学历
  所需经验
  最低薪资
  最高薪资
  首次发布时间
  招聘人
  招聘公司
  招聘者职位
  首次扫描日期
  记录更新日期

公司数据字段

  公司
  公司描述
  成立时间
  经营状态
  法人
  统一社会信用代码
  官网
  社保人数
  自身风险数
  关联风险数
  地址
  经营范围
  纳税人识别号
  所属行业
  工商注册号
  经度
  纬度
  数据来源地址
  数据来源平台
  数据来源记录编号
  数据来源更新时间

公司标签数据字段

  公司
  标签

数据流向

官方数据流(数据上传)

    招聘网站 -> JobHunting Extension -> Git(个人GitHub仓库)

共享数据流(数据下载)

    共享数据仓库列表 -> Git(个人GitHub仓库) -> JobHunting Extension

数据提交流程

最多每天提交一次,插件启动时开启定时检查任务
查询仓库最近一次提交时间
提交的记录的范围条件:记录更新时间 < 今天0点0分 和记录更新时间 >= 最近一次提交时间
    备注:针对公司标签,现在是全量提交
提交的目录
    提交时间(YYYY)
        提交时间(MM-DD)
            job.zip
            company.zip
            company_tag.zip

数据获取和同步流程

1. 查询仓库60天内的记录
2. 下载60天内缺失的数据文件
3. 根据数据文件进行数据同步
4. 针对不同类型数据进行处理
    职位数据
        如果是新数据
            新增记录
        如果是重复数据
            根据创建时间来处理,并且需要处理公司名全称问题
    公司数据
        如果是新数据
            新增记录
        如果是重复数据
            根据数据来源更新时间来处理
    公司标签数据
        如果是新数据
            新增记录
        如果是重复数据
            合并标签

关键组件及行为

相关表

task 任务表
    id 编号
    type 任务类型
    data_id 数据编号
    status 任务状态
    error_reason 异常原因
    cost_time 最近一次任务执行耗时
    retry_count 重试次数
    create_datetime 创建时间
    update_datetime 更新时间
task_data_upload 任务数据表(上传)
    id 编号
    type 任务类型
    username 用户名
    reponame 仓库名
    start_datetime 数据开始时间
    end_datetime 数据结束时间
    data_count 数据总量
    create_datetime 创建时间
    update_datetime 更新时间
task_data_download 任务数据表(下载)
    id 编号
    type 任务类型
    username 用户名
    reponame 仓库名
    datetime 文件日期
    create_datetime 创建时间
    update_datetime 更新时间
file 文件表
    id 编号
    name 文件名
    sha 散列值
    encoding 文件内容编码,
    content 文件内容,
    size 文件尺寸,
    type 类型(当前只有file)
    create_datetime 创建时间
    update_datetime 更新时间
task_data_merge 任务数据表(数据合并)
    id 编号
    type 任务类型
    username 用户名
    reponame 仓库名
    datetime 文件日期
    data_id 文件编号
    data_count 数据总量
    create_datetime 创建时间
    update_datetime 更新时间
data_share_partner 数据共享伙伴信息表
    id 编号
    username 用户名
    reponame 仓库名
    repo_type 仓库类型(当前只有GITHUB)
    create_datetime 创建时间
    update_datetime 更新时间

附表:
任务类型
    职位数据上传:TASK_TYPE_JOB_DATA_UPLOAD
    公司数据上传:TASK_TYPE_COMPANY_DATA_UPLOAD
    公司标签数据上传:TASK_TYPE_COMPANY_TAG_DATA_UPLOAD
    职位数据下载:TASK_TYPE_JOB_DATA_DOWNLOAD
    公司数据下载:TASK_TYPE_COMPANY_DATA_DOWNLOAD
    公司标签数据下载:TASK_TYPE_COMPANY_TAG_DATA_DOWNLOAD
    职位数据合并:TASK_TYPE_JOB_DATA_MERGE
    公司数据合并:TASK_TYPE_COMPANY_DATA_MERGE
    公司标签数据合并:TASK_TYPE_COMPANY_TAG_DATA_MERGE
状态
    准备:TASK_STATUS_READY
    运行中:TASK_STATUS_RUNNING
    完成:TASK_STATUS_FINISHED
    异常完成:TASK_STATUS_FINISHED_BUT_ERROR
    错误:TASK_STATUS_ERROR
    取消:TASK_STATUS_CANCEL

数据存储目录结构

提交时间(YYYY)
    提交时间(MM-DD)
        job.zip
        company.zip
        company_tag.zip

GitHub

    仓库的建立
        所需权限:"Administration" repository permissions (write)
        https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#create-a-repository-for-the-authenticated-user
            /user/repos

    仓库目录的提交&数据文件的提交
        所需权限:"Contents" repository permissions (write)
        https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents
            /repos/{owner}/{repo}/contents/{path}

    仓库目录的查询&仓库文件的下载
        所需权限:无
        特别事项:This API has an upper limit of 1,000 files for a directory. If you need to retrieve more files,
        https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
            /repos/{owner}/{repo}/contents/{path}

FAQ

1. 报错 No more file handles available in the pool

如果在 Linux 下,请使用命令 ulimit -n 检查 soft file descriptor 的值,一般默认为 1024 或 2048,请设定一个较高的值如 9001

免责声明

1. 项目目的与性质

本项目(以下简称“本项目”)是作为一个技术研究与学习工具而创建的,旨在探索和学习网络数据采集技术。本项目专注于招聘平台的数据爬取与分析技术研究,旨在提供给学习者和研究者作为技术交流之用。

2. 法律合规性声明

本项目开发者(以下简称“开发者”)郑重提醒用户在下载、安装和使用本项目时,严格遵守中华人民共和国相关法律法规,包括但不限于《中华人民共和国网络安全法》、《中华人民共和国反间谍法》等所有适用的国家法律和政策。用户应自行承担一切因使用本项目而可能引起的法律责任。

3. 使用目的限制

本项目严禁用于任何非法目的或非学习、非研究的商业行为。本项目不得用于任何形式的非法侵入他人计算机系统,不得用于任何侵犯他人知识产权或其他合法权益的行为。用户应保证其使用本项目的目的纯属个人学习和技术研究,不得用于任何形式的非法活动。

4. 免责声明

开发者已尽最大努力确保本项目的正当性及安全性,但不对用户使用本项目可能引起的任何形式的直接或间接损失承担责任。包括但不限于由于使用本项目而导致的任何数据丢失、设备损坏、法律诉讼等。

5. 知识产权声明

本项目的知识产权归开发者所有。本项目受到著作权法和国际著作权条约以及其他知识产权法律和条约的保护。用户在遵守本声明及相关法律法规的前提下,可以下载和使用本项目。

6. 最终解释权

关于本项目的最终解释权归开发者所有。开发者保留随时更改或更新本免责声明的权利,恕不另行通知。