This commit is contained in:
冯辉
2026-06-26 12:39:57 +08:00
31 changed files with 1660 additions and 4 deletions

View File

@@ -0,0 +1,56 @@
---
name: inspect-shz-logs
description: Inspect and follow the SHZ backend's local and production logs through bounded, read-only commands. Use when Claude Code is working in the shz-backend project and needs to diagnose startup failures, exceptions, request behavior, scheduled jobs, or runtime incidents from local backend.log or the production backend, error, info, and user logs on the shz server.
---
# Inspect SHZ Logs
Use the bundled script instead of assembling SSH commands manually. Keep every initial read bounded; follow logs only when the user explicitly asks to monitor live activity.
## Inspect local logs
Read the last 200 lines:
```bash
.claude/skills/inspect-shz-logs/scripts/view.sh local 200
```
Follow the local log interactively:
```bash
.claude/skills/inspect-shz-logs/scripts/view.sh local 200 --follow
```
## Inspect production logs
Read the main application log:
```bash
.claude/skills/inspect-shz-logs/scripts/view.sh online backend 200
```
Read the current error log:
```bash
.claude/skills/inspect-shz-logs/scripts/view.sh online error 200
```
Choose only one production log type: `backend`, `error`, `info`, or `user`. Add `--follow` as the final argument only for an explicit live-monitoring request.
For keyword analysis, fetch a bounded window and filter locally, for example:
```bash
.claude/skills/inspect-shz-logs/scripts/view.sh online error 1000 | rg -i 'exception|error|failed'
```
## Diagnose from logs
1. Establish whether the request concerns local or production behavior.
2. Read 100300 recent lines from the relevant log.
3. Expand up to the script's 5,000-line cap only when timestamps or stack traces require more context.
4. Correlate timestamps, thread names, exception causes, and nearby requests.
5. Report the evidence and likely cause without exposing tokens, passwords, personal data, or unrelated log content.
Treat production access as read-only. Never restart services, edit files, deploy code, truncate logs, or run arbitrary remote commands through this skill.
Read [references/log-locations.md](references/log-locations.md) only when checking server identity, paths, or supported log types.

View File

@@ -0,0 +1,19 @@
# Log locations
## Local
- Main log: `<project>/.local/logs/backend.log`
- Producer: `<project>/local.sh`
## Production: shz
- SSH: `root@39.98.44.136:6022`
- Application: `/opt/service/project/shz-backend/ruoyi-admin/target/ruoyi-admin.jar`
- Profile: `dev`
- HTTP port: `9091`
- Main process log: `/opt/service/project/shz-backend/logs/backend.log`
- Current error log: `/opt/service/project/shz-backend/logs/sys-error.log`
- Current info log: `/opt/service/project/shz-backend/logs/sys-info.log`
- Current user log: `/opt/service/project/shz-backend/logs/sys-user.log`
Use only the bundled read-only viewer. Rotated files use date suffixes but are intentionally not exposed by the default script.

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project_dir="$(cd "${skill_dir}/../../.." && pwd)"
local_log="${project_dir}/.local/logs/backend.log"
ssh_host="root@39.98.44.136"
ssh_port="6022"
remote_log_dir="/opt/service/project/shz-backend/logs"
usage() {
cat <<'EOF'
Usage:
view.sh local [lines] [--follow]
view.sh online [backend|error|info|user] [lines] [--follow]
Examples:
view.sh local 200
view.sh local 100 --follow
view.sh online backend 200
view.sh online error 500 --follow
EOF
}
validate_lines() {
local lines="$1"
if [[ ! "${lines}" =~ ^[0-9]+$ ]] || (( lines < 1 || lines > 5000 )); then
echo "Lines must be an integer between 1 and 5000: ${lines}" >&2
exit 2
fi
}
validate_follow() {
local follow="${1:-}"
if [[ -n "${follow}" && "${follow}" != "--follow" ]]; then
echo "Unknown option: ${follow}" >&2
usage >&2
exit 2
fi
}
view_local() {
local lines="${1:-200}"
local follow="${2:-}"
validate_lines "${lines}"
validate_follow "${follow}"
if [[ ! -f "${local_log}" ]]; then
echo "Local log does not exist: ${local_log}" >&2
echo "Start the project first with: ./local.sh start" >&2
exit 1
fi
if [[ "${follow}" == "--follow" ]]; then
tail -n "${lines}" -F -- "${local_log}"
else
tail -n "${lines}" -- "${local_log}"
fi
}
view_online() {
local kind="${1:-backend}"
local lines="${2:-200}"
local follow="${3:-}"
local remote_file
case "${kind}" in
backend) remote_file="${remote_log_dir}/backend.log" ;;
error) remote_file="${remote_log_dir}/sys-error.log" ;;
info) remote_file="${remote_log_dir}/sys-info.log" ;;
user) remote_file="${remote_log_dir}/sys-user.log" ;;
*)
echo "Unknown online log type: ${kind}" >&2
usage >&2
exit 2
;;
esac
validate_lines "${lines}"
validate_follow "${follow}"
if [[ "${follow}" == "--follow" ]]; then
ssh -p "${ssh_port}" -o BatchMode=yes -o ConnectTimeout=10 "${ssh_host}" \
tail -n "${lines}" -F -- "${remote_file}"
else
ssh -p "${ssh_port}" -o BatchMode=yes -o ConnectTimeout=10 "${ssh_host}" \
tail -n "${lines}" -- "${remote_file}"
fi
}
target="${1:-}"
case "${target}" in
local)
[[ $# -le 3 ]] || { usage >&2; exit 2; }
view_local "${2:-200}" "${3:-}"
;;
online)
[[ $# -le 4 ]] || { usage >&2; exit 2; }
view_online "${2:-backend}" "${3:-200}" "${4:-}"
;;
help|-h|--help)
usage
;;
*)
usage >&2
exit 2
;;
esac

View File

@@ -0,0 +1 @@
Hello@2026

View File

@@ -0,0 +1,66 @@
---
name: query-shz-highgo
description: Run SQL against the SHZ HighGo Security Enterprise Edition database through its SSH gateway. Use when Claude Code is working in the shz-backend project and needs to inspect schemas, tables, columns, row counts, or application data; diagnose data-related behavior; or execute SELECT/WITH/SHOW/EXPLAIN. Also supports write operations — INSERT, UPDATE, DELETE, MERGE, CREATE, ALTER, DROP, TRUNCATE, GRANT, REVOKE — when the user explicitly asks to add, modify, delete, or otherwise change data or schema.
---
# SHZ HighGo SQL
Two bundled scripts run SQL through the configured SSH gateway:
- `scripts/query.sh`**read-only**. Default for inspection and diagnostics.
- `scripts/modify.sh`**write-capable**. Use only when an actual change is intended and the user has asked for it.
Both set `search_path` to `shz,public`, apply a 30-second `statement_timeout`, reject multiple statements in one call, and base64-encode the SQL over the SSH channel. `query.sh` additionally enforces database-level `default_transaction_read_only=on` and accepts only `SELECT/WITH/SHOW/EXPLAIN`.
## Query workflow (read-only)
1. Form the narrowest useful query. Add a small `LIMIT` when inspecting rows.
2. For unfamiliar tables, inspect `information_schema.columns` before selecting business data.
3. Run:
```bash
.claude/skills/query-shz-highgo/scripts/query.sh 'SELECT current_database(), current_user, current_schema()'
```
4. Summarize the result and distinguish returned facts from inferences.
## Modify workflow (writes)
⚠️ These statements change data or schema on a production database. Confirm the exact statement with the user before running it, and prefer a `WHERE` clause or scoped target. Read unfamiliar tables with `query.sh` first.
1. Inspect the target with `query.sh` (columns, existing rows) so the statement is well-formed.
2. Run the write:
```bash
.claude/skills/query-shz-highgo/scripts/modify.sh "INSERT INTO shz.some_table (col) VALUES ('val')"
.claude/skills/query-shz-highgo/scripts/modify.sh "UPDATE shz.some_table SET col = 'val' WHERE id = 1"
.claude/skills/query-shz-highgo/scripts/modify.sh "DELETE FROM shz.some_table WHERE id = 1"
.claude/skills/query-shz-highgo/scripts/modify.sh "CREATE TABLE shz.example (id bigint PRIMARY KEY)"
```
3. Verify the effect with a follow-up `query.sh` `SELECT` and report rows affected.
Notes:
- Only one statement per call. To run several, invoke the script multiple times.
- Transactions across calls are NOT supported (each call is its own session). psql autocommits each statement.
- `DROP`/`TRUNCATE`/`DELETE` without a `WHERE` are destructive — re-state the impact to the user and wait for explicit go-ahead.
## Schema discovery
List user tables:
```bash
.claude/skills/query-shz-highgo/scripts/query.sh "SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY 1, 2"
```
Inspect columns:
```bash
.claude/skills/query-shz-highgo/scripts/query.sh "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'shz' AND table_name = 'lowercase_table_name' ORDER BY ordinal_position"
```
HighGo may expose unquoted identifiers in lowercase and quoted identifiers with exact case. Query `information_schema` first when the spelling is uncertain.
## Connection details
Read [references/connection.md](references/connection.md) only when troubleshooting connectivity or updating the endpoint. Do not print, quote, or expose the password file.

View File

@@ -0,0 +1,12 @@
# Connection reference
- SSH gateway: `root@124.243.245.42`
- HighGo client: `/opt/HighGo4.5.7-see/bin/psql`
- Database endpoint: `39.98.44.136:6023`
- Database: `highgo`
- User: `sysdba`
- Default schemas: `shz,public`
- Detected server: HighGo Security Enterprise Edition Database System 4.5.10
- Password source: `../.password`, mode `600`; override with `HIGHGO_PASSWORD_FILE`
The SSH gateway must be reachable with the workstation's existing SSH key. Keep credentials out of commands, logs, responses, and `SKILL.md`.

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Write-capable counterpart to query.sh. Runs a single DML/DDL statement
# against the SHZ HighGo database through the SSH gateway. Read-only mode
# is NOT applied — only use when an actual change is intended.
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
password_file="${HIGHGO_PASSWORD_FILE:-$skill_dir/.password}"
ssh_host="root@124.243.245.42"
psql_path="/opt/HighGo4.5.7-see/bin/psql"
db_host="39.98.44.136"
db_port="6023"
db_name="highgo"
db_user="sysdba"
if [[ ! -r "$password_file" ]]; then
echo "HighGo password file is missing or unreadable: $password_file" >&2
exit 2
fi
if [[ $# -eq 0 ]]; then
echo "Usage: $0 'INSERT ...' | 'UPDATE ...' | 'DELETE ...' | 'CREATE ...' | 'ALTER ...' | 'DROP ...'" >&2
exit 2
fi
sql="$*"
# Reject multiple statements — prevents a trailing destructive command from
# being smuggled in after the intended one. A single optional trailing
# semicolon is tolerated.
sql_without_final_semicolon="${sql%;}"
if [[ "$sql_without_final_semicolon" == *';'* ]]; then
echo "Rejected: multiple SQL statements are not allowed. Run them one at a time." >&2
exit 3
fi
password="$(<"$password_file")"
sql_base64="$(printf '%s' "$sql" | base64 | tr -d '\n')"
{
printf '%s\n' "$password"
printf '%s\n' "$sql_base64"
} | ssh -o BatchMode=yes -o ConnectTimeout=10 "$ssh_host" \
"read -r PGPASSWORD; read -r SQL_BASE64; export PGPASSWORD; export PGOPTIONS='-c search_path=shz,public -c statement_timeout=30000'; SQL=\$(printf '%s' \"\$SQL_BASE64\" | base64 -d); exec '$psql_path' -X --no-psqlrc -h '$db_host' -p '$db_port' -U '$db_user' -d '$db_name' -v ON_ERROR_STOP=1 -P pager=off -c \"\$SQL\""

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
password_file="${HIGHGO_PASSWORD_FILE:-$skill_dir/.password}"
ssh_host="root@124.243.245.42"
psql_path="/opt/HighGo4.5.7-see/bin/psql"
db_host="39.98.44.136"
db_port="6023"
db_name="highgo"
db_user="sysdba"
if [[ ! -r "$password_file" ]]; then
echo "HighGo password file is missing or unreadable: $password_file" >&2
exit 2
fi
if [[ $# -eq 0 ]]; then
echo "Usage: $0 'SELECT ...'" >&2
exit 2
fi
sql="$*"
if [[ ! "$sql" =~ ^[[:space:]]*(SELECT|select|WITH|with|SHOW|show|EXPLAIN|explain)([[:space:]]|$) ]]; then
echo "Rejected: only SELECT, WITH, SHOW, and EXPLAIN queries are allowed." >&2
exit 3
fi
sql_without_final_semicolon="${sql%;}"
if [[ "$sql_without_final_semicolon" == *';'* ]]; then
echo "Rejected: multiple SQL statements are not allowed." >&2
exit 3
fi
password="$(<"$password_file")"
sql_base64="$(printf '%s' "$sql" | base64 | tr -d '\n')"
{
printf '%s\n' "$password"
printf '%s\n' "$sql_base64"
} | ssh -o BatchMode=yes -o ConnectTimeout=10 "$ssh_host" \
"read -r PGPASSWORD; read -r SQL_BASE64; export PGPASSWORD; export PGOPTIONS='-c default_transaction_read_only=on -c search_path=shz,public -c statement_timeout=30000'; SQL=\$(printf '%s' \"\$SQL_BASE64\" | base64 -d); exec '$psql_path' -X --no-psqlrc -h '$db_host' -p '$db_port' -U '$db_user' -d '$db_name' -v ON_ERROR_STOP=1 -P pager=off -c \"\$SQL\""

Binary file not shown.

View File

@@ -0,0 +1,65 @@
package com.ruoyi.cms.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
/**
* 户外招聘会岗位关联表自动迁移。
*/
@Slf4j
@Component
public class OutdoorFairJobMigration
{
@Autowired
private DataSource dataSource;
@EventListener(ApplicationReadyEvent.class)
public void migrate()
{
try (Connection connection = dataSource.getConnection();
ResultSet tables = connection.getMetaData().getTables(
connection.getCatalog(), "shz", "cms_outdoor_fair_job", new String[]{"TABLE"}))
{
if (tables.next())
{
log.info("户外招聘会岗位关联表已存在");
return;
}
}
catch (Exception e)
{
log.error("检查户外招聘会岗位关联表失败: {}", e.getMessage(), e);
return;
}
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement())
{
statement.execute("CREATE SEQUENCE shz.cms_outdoor_fair_job_id_seq " +
"START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1");
statement.execute("CREATE TABLE shz.cms_outdoor_fair_job (" +
"id BIGINT NOT NULL DEFAULT nextval('shz.cms_outdoor_fair_job_id_seq') PRIMARY KEY, " +
"fair_id BIGINT NOT NULL, company_id BIGINT NOT NULL, " +
"job_id BIGINT NOT NULL, create_by VARCHAR(64), create_time TIMESTAMP, " +
"update_by VARCHAR(64), update_time TIMESTAMP, remark VARCHAR(500), " +
"del_flag CHAR(1) DEFAULT '0')");
statement.execute("CREATE UNIQUE INDEX uk_cms_outdoor_fair_job " +
"ON shz.cms_outdoor_fair_job (fair_id, job_id)");
statement.execute("CREATE INDEX idx_cms_outdoor_fair_job_company " +
"ON shz.cms_outdoor_fair_job (fair_id, company_id)");
log.info("户外招聘会岗位关联表检查完成");
}
catch (Exception e)
{
log.error("户外招聘会岗位关联表迁移失败: {}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,99 @@
package com.ruoyi.cms.controller.cms;
import java.util.Arrays;
import java.util.List;
import com.ruoyi.cms.domain.OutdoorFairAttendee;
import com.ruoyi.cms.service.IOutdoorFairAttendeeService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 户外招聘会签到人员 Controller
*
* @author ruoyi
* @date 2026-06-25
*/
@RestController
@RequestMapping("/cms/outdoor-fair-attendee")
@Api(tags = "后台:户外招聘会签到人员")
public class OutdoorFairAttendeeController extends BaseController
{
@Autowired
private IOutdoorFairAttendeeService outdoorFairAttendeeService;
/**
* 查询签到人员列表(支持按人员姓名、手机号模糊查询)
*/
@ApiOperation("查询签到人员列表")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:list')")
@GetMapping("/list")
public TableDataInfo list(OutdoorFairAttendee query)
{
startPage();
List<OutdoorFairAttendee> list = outdoorFairAttendeeService.selectAttendeeList(query);
return getDataTable(list);
}
/**
* 获取签到人员详细信息
*/
@ApiOperation("获取签到人员详细信息")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(outdoorFairAttendeeService.getById(id));
}
/**
* 新增签到人员
*/
@ApiOperation("新增签到人员")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:add')")
@Log(title = "户外招聘会签到人员", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody OutdoorFairAttendee attendee)
{
return toAjax(outdoorFairAttendeeService.save(attendee));
}
/**
* 修改签到人员
*/
@ApiOperation("修改签到人员")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Log(title = "户外招聘会签到人员", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody OutdoorFairAttendee attendee)
{
return toAjax(outdoorFairAttendeeService.updateById(attendee));
}
/**
* 删除签到人员
*/
@ApiOperation("删除签到人员")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:remove')")
@Log(title = "户外招聘会签到人员", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(outdoorFairAttendeeService.removeByIds(Arrays.asList(ids)));
}
}

View File

@@ -2,22 +2,36 @@ package com.ruoyi.cms.controller.cms;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.cms.domain.FairCompany;
import com.ruoyi.cms.domain.Job;
import com.ruoyi.cms.domain.OutdoorFair;
import com.ruoyi.cms.domain.OutdoorFairBooth;
import com.ruoyi.cms.domain.OutdoorFairBoothBooking;
import com.ruoyi.cms.domain.OutdoorFairDictDataRequest;
import com.ruoyi.cms.domain.OutdoorFairJob;
import com.ruoyi.cms.domain.VenueInfo;
import com.ruoyi.cms.mapper.FairCompanyMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothBookingMapper;
import com.ruoyi.cms.mapper.OutdoorFairJobMapper;
import com.ruoyi.cms.mapper.CompanyMapper;
import com.ruoyi.cms.service.IJobService;
import com.ruoyi.cms.service.IOutdoorFairService;
import com.ruoyi.cms.service.IVenueInfoService;
import com.ruoyi.common.annotation.Log;
@@ -31,6 +45,7 @@ import com.ruoyi.common.utils.StringUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -72,6 +87,12 @@ public class OutdoorFairController extends BaseController
@Autowired
private OutdoorFairBoothMapper outdoorFairBoothMapper;
@Autowired
private OutdoorFairJobMapper outdoorFairJobMapper;
@Autowired
private IJobService jobService;
/**
* 查询户外招聘会列表
*/
@@ -96,6 +117,225 @@ public class OutdoorFairController extends BaseController
return success(outdoorFairService.getById(id));
}
/**
* 招聘会数据统计dashboard
* 按年/周/月/日维度,统计举办时间落在区间内的招聘会:
* - 累计发布招聘会数、参会单位数、职位数;
* - 招聘会数同比(去年同期等长区间)、环比(紧邻的上一等长区间);
* - 各时间桶的招聘会数/参会单位数/职位数序列(用于曲线图)。
* granularity 取值day/week/month/year默认 month。未传时间时统计全部招聘会
* (“累计”口径),曲线图区间按数据实际举办时间的最小/最大值生成。
*/
@ApiOperation("招聘会数据统计")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:list')")
@GetMapping("/statistics")
public AjaxResult statistics(
String granularity,
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date startTime,
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date endTime)
{
String gran = (StringUtils.isEmpty(granularity) ? "month" : granularity.toLowerCase());
boolean hasRange = startTime != null || endTime != null;
// 区间右端含当天start 为当天 00:00end 为次日 00:00开区间
LocalDate startLd = null;
LocalDate endLd = null;
Date start = null;
Date end = null;
if (hasRange) {
endLd = endTime != null ? toLocalDate(endTime) : LocalDate.now();
startLd = startTime != null ? toLocalDate(startTime)
: endLd.minusMonths(11).withDayOfMonth(1);
start = toDate(startLd.atStartOfDay(ZoneId.systemDefault()).toInstant());
end = toDate(endLd.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
}
// 区间内招聘会(未传区间时查全部,用于“累计”口径)
LambdaQueryWrapper<OutdoorFair> fairWrapper = new LambdaQueryWrapper<OutdoorFair>()
.orderByAsc(OutdoorFair::getHoldTime);
if (start != null) {
fairWrapper.ge(OutdoorFair::getHoldTime, start);
}
if (end != null) {
fairWrapper.lt(OutdoorFair::getHoldTime, end);
}
List<OutdoorFair> fairs = outdoorFairService.list(fairWrapper);
// 未传区间时,按数据实际举办时间的最小/最大值生成曲线图区间
if (!hasRange) {
LocalDate minD = null;
LocalDate maxD = null;
for (OutdoorFair f : fairs) {
if (f.getHoldTime() == null) {
continue;
}
LocalDate d = toLocalDate(f.getHoldTime());
if (minD == null || d.isBefore(minD)) {
minD = d;
}
if (maxD == null || d.isAfter(maxD)) {
maxD = d;
}
}
if (minD == null) {
startLd = LocalDate.now().withDayOfMonth(1);
endLd = LocalDate.now();
} else {
startLd = minD;
endLd = maxD;
}
}
// 每场参会单位/职位数(一次查回,内存分组,避免逐场 N 次查询)
Map<Long, Long> companyByFair = new HashMap<>();
Map<Long, Long> jobByFair = new HashMap<>();
if (!fairs.isEmpty()) {
List<Long> fairIds = fairs.stream().map(OutdoorFair::getId).collect(Collectors.toList());
for (FairCompany fc : fairCompanyMapper.selectList(
new LambdaQueryWrapper<FairCompany>().in(FairCompany::getJobFairId, fairIds))) {
companyByFair.merge(fc.getJobFairId(), 1L, Long::sum);
}
for (OutdoorFairJob ojf : outdoorFairJobMapper.selectList(
new LambdaQueryWrapper<OutdoorFairJob>().in(OutdoorFairJob::getFairId, fairIds))) {
jobByFair.merge(ojf.getFairId(), 1L, Long::sum);
}
}
// 生成区间内全部时间桶(含空桶),按桶累加
List<LocalDate> buckets = buildBuckets(startLd, endLd, gran);
Map<LocalDate, long[]> agg = new TreeMap<>();
for (LocalDate b : buckets) {
agg.put(b, new long[]{0, 0, 0});
}
long fairTotal = 0L, companyTotal = 0L, jobTotal = 0L;
for (OutdoorFair f : fairs) {
if (f.getHoldTime() == null) {
continue;
}
LocalDate b = bucketOf(toLocalDate(f.getHoldTime()), gran);
long[] arr = agg.get(b);
if (arr == null) {
continue;
}
long cc = companyByFair.getOrDefault(f.getId(), 0L);
long jc = jobByFair.getOrDefault(f.getId(), 0L);
arr[0] += 1;
arr[1] += cc;
arr[2] += jc;
fairTotal += 1;
companyTotal += cc;
jobTotal += jc;
}
DateTimeFormatter fmt = formatterFor(gran);
List<Map<String, Object>> series = new ArrayList<>();
for (LocalDate b : buckets) {
long[] arr = agg.get(b);
Map<String, Object> row = new LinkedHashMap<>();
row.put("time", b.format(fmt));
row.put("fairCount", arr[0]);
row.put("companyCount", arr[1]);
row.put("jobCount", arr[2]);
series.add(row);
}
// 同比:去年同期等长区间;环比:紧邻的上一等长区间
long yoyPrev = countFairs(startLd.minusYears(1), endLd.minusYears(1));
long rangeDays = ChronoUnit.DAYS.between(startLd, endLd) + 1;
long momPrev = countFairs(startLd.minusDays(rangeDays), endLd.minusDays(rangeDays));
Map<String, Object> result = new LinkedHashMap<>();
result.put("granularity", gran);
result.put("totals", buildTotals(fairTotal, companyTotal, jobTotal));
result.put("yoy", buildRate(fairTotal, yoyPrev));
result.put("mom", buildRate(fairTotal, momPrev));
result.put("series", series);
return AjaxResult.success(result);
}
/** 统计 [startLd, endLd](含当天)区间内的招聘会数 */
private long countFairs(LocalDate startLd, LocalDate endLd) {
Date ds = toDate(startLd.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date de = toDate(endLd.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
return outdoorFairService.count(new LambdaQueryWrapper<OutdoorFair>()
.ge(OutdoorFair::getHoldTime, ds).lt(OutdoorFair::getHoldTime, de));
}
private LocalDate toLocalDate(Date d) {
return d.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private Date toDate(Instant instant) {
return Date.from(instant);
}
/** 按粒度取所属时间桶的起始日期 */
private LocalDate bucketOf(LocalDate ld, String gran) {
switch (gran) {
case "day":
return ld;
case "week":
return ld.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
case "year":
return ld.withDayOfYear(1);
default:
return ld.withDayOfMonth(1);
}
}
/** 生成区间内按粒度的全部时间桶(含空桶) */
private List<LocalDate> buildBuckets(LocalDate startLd, LocalDate endLd, String gran) {
List<LocalDate> out = new ArrayList<>();
LocalDate cur = bucketOf(startLd, gran);
LocalDate endBucket = bucketOf(endLd, gran);
while (!cur.isAfter(endBucket)) {
out.add(cur);
switch (gran) {
case "day":
cur = cur.plusDays(1);
break;
case "week":
cur = cur.plusWeeks(1);
break;
case "year":
cur = cur.plusYears(1);
break;
default:
cur = cur.plusMonths(1);
break;
}
}
return out;
}
private DateTimeFormatter formatterFor(String gran) {
switch (gran) {
case "day":
return DateTimeFormatter.ofPattern("yyyy-MM-dd");
case "week":
return DateTimeFormatter.ofPattern("yyyy-'W'ww");
case "year":
return DateTimeFormatter.ofPattern("yyyy");
default:
return DateTimeFormatter.ofPattern("yyyy-MM");
}
}
private Map<String, Object> buildTotals(long fair, long company, long job) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("fairCount", fair);
m.put("companyCount", company);
m.put("jobCount", job);
return m;
}
private Map<String, Object> buildRate(long current, long previous) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("fairCount", current);
m.put("previousFairCount", previous);
m.put("rate", previous == 0 ? null : (double) (current - previous) / (double) previous);
return m;
}
/**
* 新增户外招聘会
*/
@@ -161,7 +401,8 @@ public class OutdoorFairController extends BaseController
@ApiOperation("查询户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{fairId}/companies")
public AjaxResult listCompanies(@PathVariable Long fairId, String companyName, Integer current, Integer pageSize)
public AjaxResult listCompanies(@PathVariable Long fairId, String companyName,
Integer current, Integer pageSize)
{
List<FairCompany> relations = fairCompanyMapper.selectList(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
@@ -182,6 +423,11 @@ public class OutdoorFairController extends BaseController
.stream()
.filter(item -> item.getCompanyId() != null)
.collect(Collectors.toMap(OutdoorFairBoothBooking::getCompanyId, item -> item, (a, b) -> a));
Map<Long, Long> jobCountMap = outdoorFairJobMapper.countJobsByCompany(fairId).stream()
.collect(Collectors.toMap(
item -> Long.valueOf(String.valueOf(item.get("companyId"))),
item -> Long.valueOf(String.valueOf(item.get("jobCount"))),
(a, b) -> a));
List<Map<String, Object>> rows = new ArrayList<>();
for (FairCompany relation : relations) {
@@ -204,7 +450,7 @@ public class OutdoorFairController extends BaseController
row.put("contactPhone", company.getContactPersonPhone());
row.put("boothId", booking == null ? null : booking.getBoothId());
row.put("boothNumber", booking == null ? null : booking.getBoothNumber());
row.put("jobList", new ArrayList<>());
row.put("jobCount", jobCountMap.getOrDefault(company.getCompanyId(), 0L));
rows.add(row);
}
@@ -219,6 +465,35 @@ public class OutdoorFairController extends BaseController
return result;
}
@ApiOperation("分页查询户外招聘会参会岗位")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{fairId}/jobs")
public AjaxResult listJobs(@PathVariable Long fairId, Long companyId, String companyName,
String jobTitle, String education, String experience,
Integer current, Integer pageSize)
{
int page = current == null || current < 1 ? 1 : current;
int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
int offset = (page - 1) * size;
List<Job> rows = outdoorFairJobMapper.selectFairJobPage(
fairId, companyId, companyName, jobTitle, education, experience, offset, size);
Long total = outdoorFairJobMapper.countFairJobs(
fairId, companyId, companyName, jobTitle, education, experience);
AjaxResult result = AjaxResult.success();
result.put("rows", rows);
result.put("total", total == null ? 0 : total);
return result;
}
@ApiOperation("获取户外招聘会岗位详情")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{fairId}/jobs/{jobId}")
public AjaxResult getCompanyJob(@PathVariable Long fairId, @PathVariable Long jobId)
{
ensureFairJobExists(fairId, jobId);
return success(jobService.selectJobByJobId(jobId));
}
@ApiOperation("新增户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@PostMapping("/{fairId}/companies")
@@ -236,11 +511,90 @@ public class OutdoorFairController extends BaseController
@ApiOperation("移除户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@DeleteMapping("/{fairId}/companies/{id}")
@Transactional(rollbackFor = Exception.class)
public AjaxResult removeCompany(@PathVariable Long fairId, @PathVariable Long id)
{
return toAjax(fairCompanyMapper.delete(new LambdaQueryWrapper<FairCompany>()
FairCompany relation = fairCompanyMapper.selectById(id);
if (relation == null || !fairId.equals(relation.getJobFairId())) {
return AjaxResult.error("参会企业不存在");
}
outdoorFairJobMapper.delete(new LambdaQueryWrapper<OutdoorFairJob>()
.eq(OutdoorFairJob::getFairId, fairId)
.eq(OutdoorFairJob::getCompanyId, relation.getCompanyId()));
return toAjax(fairCompanyMapper.deleteById(id));
}
@ApiOperation("新增户外招聘会岗位")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Transactional(rollbackFor = Exception.class)
@PostMapping("/{fairId}/companies/{companyId}/jobs")
public AjaxResult addCompanyJob(@PathVariable Long fairId, @PathVariable Long companyId,
@RequestBody Job job)
{
ensureFairCompanyExists(fairId, companyId);
Company company = companyMapper.selectById(companyId);
job.setCompanyId(companyId);
job.setCompanyName(company.getName());
if (job.getIsPublish() == null) {
job.setIsPublish(1);
}
int inserted = jobService.insertJob(job);
if (inserted <= 0) {
return AjaxResult.error("岗位新增失败");
}
OutdoorFairJob relation = new OutdoorFairJob();
relation.setFairId(fairId);
relation.setCompanyId(companyId);
relation.setJobId(job.getJobId());
relation.setCreateBy(getUsername());
outdoorFairJobMapper.insert(relation);
return success(job);
}
@ApiOperation("修改户外招聘会岗位")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@PutMapping("/{fairId}/jobs/{jobId}")
public AjaxResult updateCompanyJob(@PathVariable Long fairId, @PathVariable Long jobId,
@RequestBody Job job)
{
ensureFairJobExists(fairId, jobId);
job.setJobId(jobId);
// 当前弹窗不修改发布状态,避免重复触发岗位发布流程。
job.setIsPublish(null);
return toAjax(jobService.updateJob(job));
}
@ApiOperation("删除户外招聘会岗位")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Transactional(rollbackFor = Exception.class)
@DeleteMapping("/{fairId}/jobs/{jobId}")
public AjaxResult removeCompanyJob(@PathVariable Long fairId, @PathVariable Long jobId)
{
ensureFairJobExists(fairId, jobId);
outdoorFairJobMapper.delete(new LambdaQueryWrapper<OutdoorFairJob>()
.eq(OutdoorFairJob::getFairId, fairId)
.eq(OutdoorFairJob::getJobId, jobId));
return toAjax(jobService.deleteJobByJobIds(new Long[]{jobId}));
}
private void ensureFairCompanyExists(Long fairId, Long companyId)
{
Long count = fairCompanyMapper.selectCount(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.eq(FairCompany::getId, id)));
.eq(FairCompany::getCompanyId, companyId));
if (count == null || count == 0) {
throw new ServiceException("该企业未参加当前招聘会");
}
}
private void ensureFairJobExists(Long fairId, Long jobId)
{
Long count = outdoorFairJobMapper.selectCount(new LambdaQueryWrapper<OutdoorFairJob>()
.eq(OutdoorFairJob::getFairId, fairId)
.eq(OutdoorFairJob::getJobId, jobId));
if (count == null || count == 0) {
throw new ServiceException("当前招聘会不存在该岗位");
}
}
private void ensureFairCompany(Long fairId, Long companyId)

View File

@@ -0,0 +1,108 @@
package com.ruoyi.cms.controller.cms;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.ruoyi.cms.domain.OutdoorFairDevice;
import com.ruoyi.cms.service.IOutdoorFairDeviceService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 户外招聘会设备码 Controller
*
* @author ruoyi
* @date 2026-06-26
*/
@RestController
@RequestMapping("/cms/outdoor-fair-device")
@Api(tags = "后台:户外招聘会设备码")
public class OutdoorFairDeviceController extends BaseController
{
@Autowired
private IOutdoorFairDeviceService outdoorFairDeviceService;
/**
* 查询设备码列表(支持按设备码、企业名称模糊查询)
*/
@ApiOperation("查询设备码列表")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:list')")
@GetMapping("/list")
public TableDataInfo list(OutdoorFairDevice query)
{
startPage();
List<OutdoorFairDevice> list = outdoorFairDeviceService.selectDeviceList(query);
return getDataTable(list);
}
/**
* 获取设备码详细信息
*/
@ApiOperation("获取设备码详细信息")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(outdoorFairDeviceService.getById(id));
}
/**
* 批量生成设备码UUID
* 请求体:{ fairId, quantity }
*/
@ApiOperation("批量生成设备码")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:add')")
@Log(title = "户外招聘会设备码", businessType = BusinessType.INSERT)
@PostMapping("/batch")
public AjaxResult batch(@RequestBody Map<String, Object> request)
{
Long fairId = request.get("fairId") == null ? null : Long.valueOf(String.valueOf(request.get("fairId")));
Integer quantity = request.get("quantity") == null ? null : Integer.valueOf(String.valueOf(request.get("quantity")));
List<OutdoorFairDevice> devices = outdoorFairDeviceService.batchGenerate(fairId, quantity);
AjaxResult result = AjaxResult.success("生成成功");
result.put("data", devices);
return result;
}
/**
* 绑定/解绑参会企业
* 请求体:{ companyId }companyId 为空表示解绑
*/
@ApiOperation("绑定/解绑参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Log(title = "户外招聘会设备码", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/bind")
public AjaxResult bind(@PathVariable("id") Long id, @RequestBody Map<String, Object> request)
{
Long companyId = request.get("companyId") == null ? null : Long.valueOf(String.valueOf(request.get("companyId")));
return toAjax(outdoorFairDeviceService.bindCompany(id, companyId));
}
/**
* 删除设备码
*/
@ApiOperation("删除设备码")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:remove')")
@Log(title = "户外招聘会设备码", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(outdoorFairDeviceService.removeByIds(Arrays.asList(ids)));
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.cms.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import java.util.Date;
/**
* 户外招聘会签到人员。
*/
@Data
@TableName("shz.cms_outdoor_fair_attendee")
public class OutdoorFairAttendee extends BaseEntity
{
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 招聘会ID */
private Long fairId;
/** 人员姓名 */
private String name;
/** 手机号 */
private String phone;
/** 住址 */
private String address;
/** 签到时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date checkInTime;
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.cms.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import java.util.Date;
/**
* 户外招聘会设备码。
*/
@Data
@TableName("shz.cms_outdoor_fair_device")
public class OutdoorFairDevice extends BaseEntity
{
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 招聘会ID */
private Long fairId;
/** 设备码UUID */
private String deviceCode;
/** 绑定的参会企业ID */
private Long companyId;
/** 绑定的参会企业名称(快照) */
private String companyName;
/** 绑定时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date bindTime;
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.cms.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
/**
* 户外招聘会岗位关联。
*/
@Data
@TableName("shz.cms_outdoor_fair_job")
public class OutdoorFairJob extends BaseEntity
{
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long fairId;
private Long companyId;
private Long jobId;
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.OutdoorFairAttendee;
/**
* 户外招聘会签到人员 Mapper。
*/
public interface OutdoorFairAttendeeMapper extends BaseMapper<OutdoorFairAttendee>
{
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.OutdoorFairDevice;
/**
* 户外招聘会设备码 Mapper。
*/
public interface OutdoorFairDeviceMapper extends BaseMapper<OutdoorFairDevice>
{
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.Job;
import com.ruoyi.cms.domain.OutdoorFairJob;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 户外招聘会岗位关联 Mapper。
*/
public interface OutdoorFairJobMapper extends BaseMapper<OutdoorFairJob>
{
List<Job> selectFairJobPage(@Param("fairId") Long fairId,
@Param("companyId") Long companyId,
@Param("companyName") String companyName,
@Param("jobTitle") String jobTitle,
@Param("education") String education,
@Param("experience") String experience,
@Param("offset") int offset,
@Param("pageSize") int pageSize);
Long countFairJobs(@Param("fairId") Long fairId,
@Param("companyId") Long companyId,
@Param("companyName") String companyName,
@Param("jobTitle") String jobTitle,
@Param("education") String education,
@Param("experience") String experience);
List<Map<String, Object>> countJobsByCompany(@Param("fairId") Long fairId);
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.cms.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.cms.domain.OutdoorFairAttendee;
import java.util.List;
/**
* 户外招聘会签到人员 Service。
*/
public interface IOutdoorFairAttendeeService extends IService<OutdoorFairAttendee>
{
/**
* 查询签到人员列表(支持按人员姓名、手机号模糊查询)。
*/
List<OutdoorFairAttendee> selectAttendeeList(OutdoorFairAttendee query);
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.cms.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.cms.domain.OutdoorFairDevice;
import java.util.List;
/**
* 户外招聘会设备码 Service。
*/
public interface IOutdoorFairDeviceService extends IService<OutdoorFairDevice>
{
/**
* 查询设备码列表(支持按设备码、企业名称模糊查询)。
*/
List<OutdoorFairDevice> selectDeviceList(OutdoorFairDevice query);
/**
* 批量生成设备码UUID
*
* @param fairId 招聘会ID
* @param quantity 生成数量
* @return 已生成的设备码列表
*/
List<OutdoorFairDevice> batchGenerate(Long fairId, Integer quantity);
/**
* 绑定/解绑参会企业companyId 为空表示解绑)。
*/
boolean bindCompany(Long id, Long companyId);
}

View File

@@ -806,6 +806,7 @@ public class ESJobSearchImpl implements IESJobSearchService
@Override
public void deleteJob(Long jobId) {
ensureJobDocumentIndexExists();
LambdaEsQueryWrapper<ESJobDocument> lambdaEsQueryWrapper = new LambdaEsQueryWrapper();
lambdaEsQueryWrapper.eq(ESJobDocument::getJobId,jobId);
esJobDocumentMapper.delete(lambdaEsQueryWrapper);
@@ -847,12 +848,31 @@ public class ESJobSearchImpl implements IESJobSearchService
if(StringUtil.isEmptyOrNull(job.getCompanyNature())){
esJobDocument.setCompanyNature("6");
}
ensureJobDocumentIndexExists();
LambdaEsQueryWrapper<ESJobDocument> lambdaEsQueryWrapper = new LambdaEsQueryWrapper();
lambdaEsQueryWrapper.eq(ESJobDocument::getJobId,job.getJobId());
esJobDocumentMapper.delete(lambdaEsQueryWrapper);
esJobDocumentMapper.insert(esJobDocument);
}
/**
* 确保job_document索引存在缺失时懒创建仅建空索引与映射不重建全量数据
* process-index-mode: manual 下easy-es不会自动建索引缺失时delete/update等写操作
* 会抛 index_not_found_exception 导致业务接口500并回滚事务故在写操作前调用以自愈。
*/
private void ensureJobDocumentIndexExists() {
try {
if (!esJobDocumentMapper.existsIndex("job_document")) {
esJobDocumentMapper.createIndex();
logger.info("job_document索引不存在已自动创建");
}
} catch (Exception e) {
// 并发场景下其他请求可能已创建索引,忽略“索引已存在”类异常
logger.warn("自动创建job_document索引异常可能已被其他请求创建忽略", e);
}
}
public List<ESJobDocument> selectByIds(Long[] jobIds) {
LambdaEsQueryWrapper<ESJobDocument> wrapper = new LambdaEsQueryWrapper<>();
wrapper.in(ESJobDocument::getJobId,jobIds);

View File

@@ -0,0 +1,32 @@
package com.ruoyi.cms.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.cms.domain.OutdoorFairAttendee;
import com.ruoyi.cms.mapper.OutdoorFairAttendeeMapper;
import com.ruoyi.cms.service.IOutdoorFairAttendeeService;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 户外招聘会签到人员 Service 实现。
*/
@Service
public class OutdoorFairAttendeeServiceImpl
extends ServiceImpl<OutdoorFairAttendeeMapper, OutdoorFairAttendee>
implements IOutdoorFairAttendeeService
{
@Override
public List<OutdoorFairAttendee> selectAttendeeList(OutdoorFairAttendee query)
{
LambdaQueryWrapper<OutdoorFairAttendee> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(query.getFairId() != null, OutdoorFairAttendee::getFairId, query.getFairId());
wrapper.like(StringUtils.isNotBlank(query.getName()), OutdoorFairAttendee::getName, query.getName());
wrapper.like(StringUtils.isNotBlank(query.getPhone()), OutdoorFairAttendee::getPhone, query.getPhone());
wrapper.orderByDesc(OutdoorFairAttendee::getCheckInTime);
wrapper.orderByDesc(OutdoorFairAttendee::getId);
return list(wrapper);
}
}

View File

@@ -0,0 +1,112 @@
package com.ruoyi.cms.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.cms.domain.FairCompany;
import com.ruoyi.cms.domain.OutdoorFairDevice;
import com.ruoyi.cms.mapper.CompanyMapper;
import com.ruoyi.cms.mapper.FairCompanyMapper;
import com.ruoyi.cms.mapper.OutdoorFairDeviceMapper;
import com.ruoyi.cms.service.IOutdoorFairDeviceService;
import com.ruoyi.common.core.domain.entity.Company;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
/**
* 户外招聘会设备码 Service 实现。
*/
@Service
public class OutdoorFairDeviceServiceImpl
extends ServiceImpl<OutdoorFairDeviceMapper, OutdoorFairDevice>
implements IOutdoorFairDeviceService
{
@Autowired
private FairCompanyMapper fairCompanyMapper;
@Autowired
private CompanyMapper companyMapper;
@Override
public List<OutdoorFairDevice> selectDeviceList(OutdoorFairDevice query)
{
LambdaQueryWrapper<OutdoorFairDevice> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(query.getFairId() != null, OutdoorFairDevice::getFairId, query.getFairId());
wrapper.eq(query.getCompanyId() != null, OutdoorFairDevice::getCompanyId, query.getCompanyId());
wrapper.like(StringUtils.isNotBlank(query.getDeviceCode()), OutdoorFairDevice::getDeviceCode, query.getDeviceCode());
wrapper.like(StringUtils.isNotBlank(query.getCompanyName()), OutdoorFairDevice::getCompanyName, query.getCompanyName());
wrapper.orderByDesc(OutdoorFairDevice::getId);
return list(wrapper);
}
@Override
public List<OutdoorFairDevice> batchGenerate(Long fairId, Integer quantity)
{
if (fairId == null) {
throw new ServiceException("招聘会ID不能为空");
}
int count = quantity == null ? 0 : quantity;
if (count <= 0) {
throw new ServiceException("生成数量必须大于0");
}
if (count > 1000) {
throw new ServiceException("单次生成数量不能超过1000");
}
List<OutdoorFairDevice> devices = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
OutdoorFairDevice device = new OutdoorFairDevice();
device.setFairId(fairId);
// 去掉中划线,得到 32 位十六进制设备码
device.setDeviceCode(UUID.randomUUID().toString().replace("-", ""));
devices.add(device);
}
saveBatch(devices);
return devices;
}
@Override
public boolean bindCompany(Long id, Long companyId)
{
OutdoorFairDevice device = getById(id);
if (device == null) {
throw new ServiceException("设备码不存在");
}
// 使用 UpdateWrapper 显式 set避免 updateById 默认跳过 null 字段导致解绑失效
UpdateWrapper<OutdoorFairDevice> wrapper = new UpdateWrapper<>();
wrapper.eq("id", id);
if (companyId != null) {
// 仅允许绑定已参加当前招聘会的企业
Long count = fairCompanyMapper.selectCount(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, device.getFairId())
.eq(FairCompany::getCompanyId, companyId));
if (count == null || count == 0) {
throw new ServiceException("该企业未参加当前招聘会");
}
// 同一招聘会下,一个企业只能绑定一个设备码
Long bound = baseMapper.selectCount(new LambdaQueryWrapper<OutdoorFairDevice>()
.eq(OutdoorFairDevice::getFairId, device.getFairId())
.eq(OutdoorFairDevice::getCompanyId, companyId));
if (bound != null && bound > 0) {
throw new ServiceException("该企业已绑定其他设备码");
}
Company company = companyMapper.selectById(companyId);
wrapper.set("company_id", companyId)
.set("company_name", company == null ? null : company.getName())
.set("bind_time", new Date());
}
else {
// 解绑:显式置空
wrapper.set("company_id", null)
.set("company_name", null)
.set("bind_time", null);
}
return baseMapper.update(null, wrapper) > 0;
}
}

View File

@@ -40,6 +40,7 @@ public class OutdoorFairServiceImpl extends ServiceImpl<OutdoorFairMapper, Outdo
queryWrapper.eq(StringUtils.isNotBlank(outdoorFair.getFairType()), OutdoorFair::getFairType, outdoorFair.getFairType());
queryWrapper.eq(StringUtils.isNotBlank(outdoorFair.getRegion()), OutdoorFair::getRegion, outdoorFair.getRegion());
queryWrapper.eq(outdoorFair.getVenueId() != null, OutdoorFair::getVenueId, outdoorFair.getVenueId());
queryWrapper.eq(outdoorFair.getOnlineApply() != null, OutdoorFair::getOnlineApply, outdoorFair.getOnlineApply());
queryWrapper.orderByDesc(OutdoorFair::getHoldTime);
queryWrapper.orderByDesc(OutdoorFair::getCreateTime);
return list(queryWrapper);

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.cms.mapper.OutdoorFairJobMapper">
<sql id="fairJobWhere">
WHERE relation.fair_id = #{fairId}
AND (relation.del_flag IS NULL OR relation.del_flag = '0')
AND job.del_flag = '0'
<if test="companyId != null">
AND relation.company_id = #{companyId}
</if>
<if test="companyName != null and companyName != ''">
AND job.company_name LIKE concat(
'%',
cast(#{companyName, jdbcType=VARCHAR} as varchar),
'%'
)
</if>
<if test="jobTitle != null and jobTitle != ''">
AND job.job_title LIKE concat(
'%',
cast(#{jobTitle, jdbcType=VARCHAR} as varchar),
'%'
)
</if>
<if test="education != null and education != ''">
AND job.education = #{education}
</if>
<if test="experience != null and experience != ''">
AND job.experience = #{experience}
</if>
</sql>
<select id="selectFairJobPage" resultType="com.ruoyi.cms.domain.Job">
SELECT job.*
FROM shz.cms_outdoor_fair_job relation
INNER JOIN shz.job job ON job.job_id = relation.job_id
<include refid="fairJobWhere"/>
ORDER BY relation.id DESC
LIMIT #{pageSize} OFFSET #{offset}
</select>
<select id="countFairJobs" resultType="java.lang.Long">
SELECT COUNT(*)
FROM shz.cms_outdoor_fair_job relation
INNER JOIN shz.job job ON job.job_id = relation.job_id
<include refid="fairJobWhere"/>
</select>
<select id="countJobsByCompany" resultType="java.util.Map">
SELECT company_id AS "companyId", COUNT(*) AS "jobCount"
FROM shz.cms_outdoor_fair_job
WHERE fair_id = #{fairId}
AND (del_flag IS NULL OR del_flag = '0')
GROUP BY company_id
</select>
</mapper>

View File

@@ -0,0 +1,136 @@
BEGIN;
WITH companies(company_id, company_name, target_count) AS (
VALUES
(4412::BIGINT, '瑜兴辉煌科技有限公司5', 6),
(3013::BIGINT, '瑜兴辉煌科技有限公司', 15),
(93001::BIGINT, '未知企业', 12),
(93000::BIGINT, '钵施然智能', 11),
(4414::BIGINT, '阿里巴巴', 8),
(4410::BIGINT, 'test616', 15),
(93002::BIGINT, '新疆石达赛特科技有限公司', 13),
(93005::BIGINT, '新疆智图信息科技有限公司', 5),
(93004::BIGINT, '新疆中汇新能环保科技有限公司', 15),
(93003::BIGINT, '新疆微诊医学检验有限公司', 14),
(93007::BIGINT, '街景梦工厂', 7),
(93009::BIGINT, '石河子市稼乐农业生产资料有限责任公司', 9)
),
templates(
seq_no, job_title, min_salary, max_salary, education, experience,
area_code, job_type, vacancies, job_location, description
) AS (
VALUES
(1, '行政专员', 4000, 6000, '3', '0', '17', '0', 2, '石河子市东城街道', '负责日常行政事务、资料整理及部门协作。'),
(2, '人事专员', 4500, 6500, '4', '4', '16', '0', 2, '石河子市新城街道', '负责招聘、员工关系及人事档案管理。'),
(3, '财务会计', 5000, 7500, '4', '4', '15', '0', 2, '石河子市红山街道', '负责账务处理、报表编制和税务资料整理。'),
(4, '销售顾问', 4500, 9000, '3', '0', '19', '0', 6, '石河子市向阳街道', '负责客户开发、产品介绍、合同跟进和客户维护。'),
(5, '客户服务专员', 4000, 6000, '3', '0', '18', '0', 4, '石河子市老街街道', '负责客户咨询、售后问题处理及服务记录维护。'),
(6, '市场运营专员', 5000, 8000, '4', '4', '17', '0', 3, '石河子市东城街道', '负责市场活动策划、渠道运营和数据分析。'),
(7, '新媒体运营', 5000, 7500, '4', '2', '16', '0', 2, '石河子市新城街道', '负责公众号、短视频平台内容策划与运营。'),
(8, '软件开发工程师', 7000, 12000, '4', '4', '17', '0', 3, '石河子市开发区', '参与业务系统开发、接口联调、测试和技术文档编写。'),
(9, '实施运维工程师', 6000, 9000, '3', '4', '17', '0', 3, '石河子市开发区', '负责系统部署、用户培训、故障处理和日常运维。'),
(10, '设备维修技术员', 5500, 8500, '3', '4', '20', '0', 4, '石河子镇', '负责生产设备巡检、维护保养和故障维修。'),
(11, '质量检验员', 4500, 6500, '3', '0', '20', '0', 4, '石河子镇', '负责原料及产品质量检测、记录和异常反馈。'),
(12, '生产操作员', 4500, 7000, '2', '0', '20', '0', 10, '石河子镇', '按照生产规范完成操作、设备点检及现场整理。'),
(13, '农业技术员', 5000, 8000, '3', '4', '9', '0', 5, '石总场', '提供农业生产技术指导,开展田间巡查和技术服务。'),
(14, '仓储物流专员', 4500, 6500, '3', '0', '21', '0', 4, '北泉镇', '负责货物入库、盘点、出库及物流信息维护。'),
(15, '项目经理', 8000, 13000, '4', '5', '17', '0', 2, '石河子市开发区', '负责项目计划、资源协调、进度控制和交付验收。')
)
INSERT INTO shz.job (
job_title, min_salary, max_salary, education, experience,
company_name, job_location, posting_date, vacancies,
del_flag, create_by, create_time, update_by, update_time,
company_id, is_hot, apply_num, job_location_area_code,
description, is_publish, data_source, job_type, job_address,
review_status
)
SELECT
templates.job_title,
templates.min_salary,
templates.max_salary,
templates.education,
templates.experience,
companies.company_name,
templates.job_location,
CURRENT_TIMESTAMP,
templates.vacancies,
'0',
'admin',
CURRENT_TIMESTAMP,
'admin',
CURRENT_TIMESTAMP,
companies.company_id,
'0',
'0',
templates.area_code,
templates.description,
1,
'户外招聘会8模拟数据',
templates.job_type,
templates.job_location,
NULL
FROM companies
JOIN templates ON templates.seq_no <= companies.target_count
WHERE NOT EXISTS (
SELECT 1
FROM shz.job existing
WHERE existing.company_id = companies.company_id
AND existing.job_title = templates.job_title
AND existing.data_source = '户外招聘会8模拟数据'
AND existing.del_flag = '0'
);
INSERT INTO shz.cms_outdoor_fair_job (
fair_id, company_id, job_id, create_by, create_time,
update_by, update_time, del_flag
)
SELECT
8,
job.company_id,
job.job_id,
'admin',
CURRENT_TIMESTAMP,
'admin',
CURRENT_TIMESTAMP,
'0'
FROM shz.job job
WHERE job.data_source = '户外招聘会8模拟数据'
AND job.del_flag = '0'
AND EXISTS (
SELECT 1
FROM shz.fair_company fair_company
WHERE fair_company.job_fair_id = 8
AND fair_company.company_id = job.company_id
)
AND NOT EXISTS (
SELECT 1
FROM shz.cms_outdoor_fair_job relation
WHERE relation.fair_id = 8
AND relation.job_id = job.job_id
);
INSERT INTO shz.job_contact (
job_id, contact_person, contact_person_phone, position,
del_flag, create_by, create_time, update_by, update_time
)
SELECT
job.job_id,
'招聘负责人',
'13909930008',
'人力资源',
'0',
'admin',
CURRENT_TIMESTAMP,
'admin',
CURRENT_TIMESTAMP
FROM shz.job job
WHERE job.data_source = '户外招聘会8模拟数据'
AND job.del_flag = '0'
AND NOT EXISTS (
SELECT 1
FROM shz.job_contact contact
WHERE contact.job_id = job.job_id
AND contact.del_flag = '0'
);
COMMIT;

View File

@@ -0,0 +1,42 @@
-- 户外招聘会签到人员表
-- 依赖shz.cms_outdoor_fairfair_id 外键逻辑关联,不建物理外键)
CREATE SEQUENCE IF NOT EXISTS shz.cms_outdoor_fair_attendee_id_seq
START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS shz.cms_outdoor_fair_attendee (
id BIGINT NOT NULL DEFAULT nextval('shz.cms_outdoor_fair_attendee_id_seq'::regclass) PRIMARY KEY,
fair_id BIGINT NOT NULL,
name VARCHAR(64) NOT NULL,
phone VARCHAR(20),
address VARCHAR(255),
check_in_time TIMESTAMP,
create_by VARCHAR(64),
create_time TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP,
remark VARCHAR(500),
del_flag CHAR(1) DEFAULT '0'
);
CREATE INDEX IF NOT EXISTS idx_cms_outdoor_fair_attendee_fair
ON shz.cms_outdoor_fair_attendee (fair_id);
COMMENT ON TABLE shz.cms_outdoor_fair_attendee IS '户外招聘会签到人员表';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.id IS '主键';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.fair_id IS '招聘会ID';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.name IS '人员姓名';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.phone IS '手机号';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.address IS '住址';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.check_in_time IS '签到时间';
COMMENT ON COLUMN shz.cms_outdoor_fair_attendee.del_flag IS '删除标志0存在 2删除';
-- mock 5 条签到人员数据,归属 id=8 的招聘会
INSERT INTO shz.cms_outdoor_fair_attendee
(fair_id, name, phone, address, check_in_time, create_by, create_time, del_flag)
VALUES
(8, '张伟', '13800000001', '石河子市东城街道北一路上段', '2026-06-25 09:05:00', 'admin', now(), '0'),
(8, '李娜', '13900000002', '石河子市新城街道西一路社区', '2026-06-25 09:12:00', 'admin', now(), '0'),
(8, '王强', '13700000003', '石河子市红山街道三小区', '2026-06-25 09:20:00', 'admin', now(), '0'),
(8, '陈静', '13600000004', '石河子市向阳街道一社区', '2026-06-25 09:31:00', 'admin', now(), '0'),
(8, '刘洋', '13500000005', '石河子市老街街道五小区', '2026-06-25 09:45:00', 'admin', now(), '0');

View File

@@ -0,0 +1,42 @@
-- 户外招聘会设备码表
-- 依赖shz.cms_outdoor_fairfair_id 外键逻辑关联,不建物理外键)
CREATE SEQUENCE IF NOT EXISTS shz.cms_outdoor_fair_device_id_seq
START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS shz.cms_outdoor_fair_device (
id BIGINT NOT NULL DEFAULT nextval('shz.cms_outdoor_fair_device_id_seq'::regclass) PRIMARY KEY,
fair_id BIGINT NOT NULL,
device_code VARCHAR(64) NOT NULL,
company_id BIGINT,
company_name VARCHAR(255),
bind_time TIMESTAMP,
create_by VARCHAR(64),
create_time TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP,
remark VARCHAR(500),
del_flag CHAR(1) DEFAULT '0'
);
CREATE INDEX IF NOT EXISTS idx_cms_outdoor_fair_device_fair
ON shz.cms_outdoor_fair_device (fair_id);
COMMENT ON TABLE shz.cms_outdoor_fair_device IS '户外招聘会设备码表';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.id IS '主键';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.fair_id IS '招聘会ID';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.device_code IS '设备码UUID';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.company_id IS '绑定的参会企业ID';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.company_name IS '绑定的参会企业名称';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.bind_time IS '绑定时间';
COMMENT ON COLUMN shz.cms_outdoor_fair_device.del_flag IS '删除标志0存在 2删除';
-- mock 数据:为 id=8 的招聘会生成 5 个设备码,其中 2 个绑定参会企业
INSERT INTO shz.cms_outdoor_fair_device
(fair_id, device_code, company_id, company_name, bind_time, create_by, create_time, del_flag)
VALUES
(8, 'a1b2c3d4e5f6471a9b8c1d2e3f4a5b6c', null, null, null, 'admin', now(), '0'),
(8, 'b2c3d4e5f6a7482bac9d0e1f2a3b4c5d', null, null, null, 'admin', now(), '0'),
(8, 'c3d4e5f6a7b8493cbd0e1f2a3b4c5d6e', 101, '示例科技有限公司', '2026-06-25 10:00:00', 'admin', now(), '0'),
(8, 'd4e5f6a7b8c9404dce1f2a3b4c5d6e7f', 102, '示范商贸有限公司', '2026-06-25 10:05:00', 'admin', now(), '0'),
(8, 'e5f6a7b8c9d0415edf2a3b4c5d6e7f8a', null, null, null, 'admin', now(), '0');

View File

@@ -0,0 +1,23 @@
CREATE SEQUENCE IF NOT EXISTS shz.cms_outdoor_fair_job_id_seq
START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS shz.cms_outdoor_fair_job (
id BIGINT NOT NULL DEFAULT nextval('shz.cms_outdoor_fair_job_id_seq'::regclass) PRIMARY KEY,
fair_id BIGINT NOT NULL,
company_id BIGINT NOT NULL,
job_id BIGINT NOT NULL,
create_by VARCHAR(64),
create_time TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP,
remark VARCHAR(500),
del_flag CHAR(1) DEFAULT '0'
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_cms_outdoor_fair_job
ON shz.cms_outdoor_fair_job (fair_id, job_id);
CREATE INDEX IF NOT EXISTS idx_cms_outdoor_fair_job_company
ON shz.cms_outdoor_fair_job (fair_id, company_id);
COMMENT ON TABLE shz.cms_outdoor_fair_job IS '户外招聘会岗位关联表';

View File

@@ -0,0 +1,10 @@
-- 隐藏并停用左侧菜单“室内固定摊位”。
-- 保留菜单及角色关联数据,便于需要时恢复。
UPDATE sys_menu
SET visible = '1',
status = '1',
update_by = 'admin',
update_time = CURRENT_TIMESTAMP,
remark = '已移除左侧菜单入口'
WHERE menu_id = 2112
AND menu_name = '室内固定摊位';