Compare commits

...

2 Commits

Author SHA1 Message Date
c24f5e6621 Merge branch 'main' of ssh://124.243.245.42:2222/zkr/shz-backend 2026-06-25 15:53:03 +08:00
c3de38ee82 feat: add inspect-shz-logs skill for log inspection and monitoring
- Implemented a new skill to inspect and follow SHZ backend logs with bounded read-only commands.
- Added scripts for viewing local and production logs, including options for following logs.
- Created references for log locations and usage guidelines.

feat: add query-shz-highgo skill for database querying

- Introduced a skill to query the SHZ HighGo database through an SSH gateway using a read-only psql workflow.
- Included scripts for executing SQL queries and schema discovery.
- Added connection details and usage instructions.

feat: add OutdoorFairBooth domain and mapper

- Created OutdoorFairBooth domain model with relevant fields and annotations.
- Implemented a mapper interface for physical deletion of records based on fair ID.

feat: create SQL scripts for cms_outdoor_fair_booth

- Added SQL script to create the cms_outdoor_fair_booth table and its associated sequence.
- Included comments for clarity and compatibility with historical data.
- Added a script to drop the cms_venue_booth table after migration confirmation.
2026-06-25 15:53:01 +08:00
19 changed files with 665 additions and 210 deletions

BIN
.codex/skills.zip Normal file

Binary file not shown.

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 Codex 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
.codex/skills/inspect-shz-logs/scripts/view.sh local 200
```
Follow the local log interactively:
```bash
.codex/skills/inspect-shz-logs/scripts/view.sh local 200 --follow
```
## Inspect production logs
Read the main application log:
```bash
.codex/skills/inspect-shz-logs/scripts/view.sh online backend 200
```
Read the current error log:
```bash
.codex/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
.codex/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,4 @@
interface:
display_name: "查看 SHZ 项目日志"
short_description: "安全查看并跟踪 SHZ 项目的本地与线上运行日志内容"
default_prompt: "Use $inspect-shz-logs to inspect recent local or production logs for the SHZ backend."

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,42 @@
---
name: query-shz-highgo
description: Query the SHZ HighGo Security Enterprise Edition database through its SSH gateway using a guarded read-only psql workflow. Use when Codex is working in the shz-backend project and needs to inspect database schemas, tables, columns, row counts, or application data; diagnose data-related behavior; or execute SELECT, WITH, SHOW, or EXPLAIN statements against the project's HighGo database.
---
# Query SHZ HighGo
Use the bundled script to run read-only SQL through the configured SSH gateway.
## Query workflow
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
.codex/skills/query-shz-highgo/scripts/query.sh 'SELECT current_database(), current_user, current_schema()'
```
4. Summarize the result and distinguish returned facts from inferences.
The script accepts only statements beginning with `SELECT`, `WITH`, `SHOW`, or `EXPLAIN`, rejects multiple statements, sets `search_path` to `shz,public`, enforces database-level read-only mode, and applies a 30-second timeout. Never bypass these controls or perform writes unless the user explicitly requests a separate write-capable workflow.
## Schema discovery
List user tables:
```bash
.codex/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
.codex/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,4 @@
interface:
display_name: "查询 SHZ 翰高数据库"
short_description: "通过 SSH 网关安全执行本项目翰高数据库只读 SQL 查询"
default_prompt: "Use $query-shz-highgo to run a read-only query against this project's SHZ HighGo database."

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,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\""

2
.gitignore vendored
View File

@@ -52,5 +52,5 @@ nbdist/
# Local application runtime files
.local/
.codex/
local.sh

View File

@@ -1,23 +1,38 @@
package com.ruoyi.cms.controller.cms;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.cms.domain.FairCompany;
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.VenueInfo;
import com.ruoyi.cms.mapper.FairCompanyMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothBookingMapper;
import com.ruoyi.cms.mapper.CompanyMapper;
import com.ruoyi.cms.service.IOutdoorFairService;
import com.ruoyi.cms.service.IVenueInfoService;
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.domain.entity.Company;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
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.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -45,6 +60,18 @@ public class OutdoorFairController extends BaseController
@Autowired
private IVenueInfoService venueInfoService;
@Autowired
private FairCompanyMapper fairCompanyMapper;
@Autowired
private CompanyMapper companyMapper;
@Autowired
private OutdoorFairBoothBookingMapper outdoorFairBoothBookingMapper;
@Autowired
private OutdoorFairBoothMapper outdoorFairBoothMapper;
/**
* 查询户外招聘会列表
*/
@@ -75,11 +102,16 @@ public class OutdoorFairController extends BaseController
@ApiOperation("新增户外招聘会")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:add')")
@Log(title = "户外招聘会", businessType = BusinessType.INSERT)
@Transactional(rollbackFor = Exception.class)
@PostMapping
public AjaxResult add(@RequestBody OutdoorFair outdoorFair)
{
fillVenueFields(outdoorFair);
return toAjax(outdoorFairService.save(outdoorFair));
boolean saved = outdoorFairService.save(outdoorFair);
if (saved) {
resetBoothSnapshot(outdoorFair, true);
}
return toAjax(saved);
}
/**
@@ -88,11 +120,18 @@ public class OutdoorFairController extends BaseController
@ApiOperation("修改户外招聘会")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Log(title = "户外招聘会", businessType = BusinessType.UPDATE)
@Transactional(rollbackFor = Exception.class)
@PutMapping
public AjaxResult edit(@RequestBody OutdoorFair outdoorFair)
{
OutdoorFair oldFair = outdoorFair.getId() == null ? null : outdoorFairService.getById(outdoorFair.getId());
fillVenueFields(outdoorFair);
return toAjax(outdoorFairService.updateById(outdoorFair));
boolean updated = outdoorFairService.updateById(outdoorFair);
if (updated) {
boolean venueChanged = oldFair == null || !java.util.Objects.equals(oldFair.getVenueId(), outdoorFair.getVenueId());
resetBoothSnapshot(outdoorFair, venueChanged);
}
return toAjax(updated);
}
/**
@@ -119,6 +158,120 @@ public class OutdoorFairController extends BaseController
return toAjax(outdoorFairService.insertOutdoorFairDictData(request.getDictType(), request.getDictLabel(), getUsername()));
}
@ApiOperation("查询户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/{fairId}/companies")
public AjaxResult listCompanies(@PathVariable Long fairId, String companyName, Integer current, Integer pageSize)
{
List<FairCompany> relations = fairCompanyMapper.selectList(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.orderByDesc(FairCompany::getId));
if (relations.isEmpty()) {
AjaxResult result = AjaxResult.success();
result.put("rows", new ArrayList<>());
result.put("total", 0);
return result;
}
List<Long> companyIds = relations.stream().map(FairCompany::getCompanyId).collect(Collectors.toList());
Map<Long, Company> companyMap = companyMapper.selectBatchIds(companyIds).stream()
.collect(Collectors.toMap(Company::getCompanyId, item -> item, (a, b) -> a));
Map<Long, OutdoorFairBoothBooking> bookingMap = outdoorFairBoothBookingMapper.selectList(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId)
.eq(OutdoorFairBoothBooking::getStatus, "reserved"))
.stream()
.filter(item -> item.getCompanyId() != null)
.collect(Collectors.toMap(OutdoorFairBoothBooking::getCompanyId, item -> item, (a, b) -> a));
List<Map<String, Object>> rows = new ArrayList<>();
for (FairCompany relation : relations) {
Company company = companyMap.get(relation.getCompanyId());
if (company == null) {
continue;
}
if (StringUtils.isNotBlank(companyName) && (company.getName() == null || !company.getName().contains(companyName))) {
continue;
}
OutdoorFairBoothBooking booking = bookingMap.get(company.getCompanyId());
Map<String, Object> row = new HashMap<>();
row.put("id", relation.getId());
row.put("fairId", fairId);
row.put("companyId", company.getCompanyId());
row.put("companyName", company.getName());
row.put("industry", company.getIndustry());
row.put("scale", company.getScale());
row.put("contactPerson", company.getContactPerson());
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<>());
rows.add(row);
}
int total = rows.size();
int page = current == null || current < 1 ? 1 : current;
int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
int fromIndex = Math.min((page - 1) * size, total);
int toIndex = Math.min(fromIndex + size, total);
AjaxResult result = AjaxResult.success();
result.put("rows", rows.subList(fromIndex, toIndex));
result.put("total", total);
return result;
}
@ApiOperation("新增户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@PostMapping("/{fairId}/companies")
public AjaxResult addCompany(@PathVariable Long fairId, @RequestBody Map<String, Object> request)
{
Long companyId = Long.valueOf(String.valueOf(request.get("companyId")));
Company company = companyMapper.selectById(companyId);
if (company == null || company.getStatus() == null || company.getStatus() != 1) {
return AjaxResult.error("只能添加审核通过的企业");
}
ensureFairCompany(fairId, companyId);
return success();
}
@ApiOperation("移除户外招聘会参会企业")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@DeleteMapping("/{fairId}/companies/{id}")
public AjaxResult removeCompany(@PathVariable Long fairId, @PathVariable Long id)
{
return toAjax(fairCompanyMapper.delete(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.eq(FairCompany::getId, id)));
}
private void ensureFairCompany(Long fairId, Long companyId)
{
Long count = fairCompanyMapper.selectCount(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.eq(FairCompany::getCompanyId, companyId));
if (count != null && count > 0) {
return;
}
FairCompany fairCompany = new FairCompany();
fairCompany.setJobFairId(fairId);
fairCompany.setCompanyId(companyId);
fairCompanyMapper.insert(fairCompany);
}
private void resetBoothSnapshot(OutdoorFair outdoorFair, boolean forceRecreate)
{
if (outdoorFair.getId() == null) {
return;
}
if (forceRecreate) {
outdoorFairBoothBookingMapper.physicalDeleteByFairId(outdoorFair.getId());
outdoorFairBoothMapper.physicalDeleteByFairId(outdoorFair.getId());
}
Long boothCount = outdoorFairBoothMapper.selectCount(new LambdaQueryWrapper<OutdoorFairBooth>()
.eq(OutdoorFairBooth::getFairId, outdoorFair.getId()));
outdoorFair.setBoothCount(boothCount == null ? 0 : boothCount.intValue());
outdoorFairService.updateById(outdoorFair);
}
private void fillVenueFields(OutdoorFair outdoorFair)
{
if (outdoorFair.getVenueId() == null) {
@@ -129,6 +282,8 @@ public class OutdoorFairController extends BaseController
throw new ServiceException("绑定场地不存在");
}
outdoorFair.setVenueName(venueInfo.getVenueName());
outdoorFair.setBoothCount(venueInfo.getFloorCount() == null ? 0 : venueInfo.getFloorCount());
if (outdoorFair.getBoothCount() == null) {
outdoorFair.setBoothCount(0);
}
}
}

View File

@@ -12,14 +12,16 @@ import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.cms.domain.VenueInfo;
import com.ruoyi.cms.domain.OutdoorFair;
import com.ruoyi.cms.domain.OutdoorFairBooth;
import com.ruoyi.cms.domain.OutdoorFairBoothBooking;
import com.ruoyi.cms.domain.VenueBooth;
import com.ruoyi.cms.domain.FairCompany;
import com.ruoyi.cms.domain.VenueInfoDictDataRequest;
import com.ruoyi.cms.domain.VenueBoothSwapRequest;
import com.ruoyi.cms.mapper.CompanyMapper;
import com.ruoyi.cms.mapper.FairCompanyMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothMapper;
import com.ruoyi.cms.mapper.OutdoorFairBoothBookingMapper;
import com.ruoyi.cms.mapper.OutdoorFairMapper;
import com.ruoyi.cms.mapper.VenueBoothMapper;
import com.ruoyi.cms.service.IVenueInfoService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
@@ -57,18 +59,21 @@ public class VenueInfoController extends BaseController
@Autowired
private IVenueInfoService venueInfoService;
@Autowired
private VenueBoothMapper venueBoothMapper;
@Autowired
private OutdoorFairMapper outdoorFairMapper;
@Autowired
private OutdoorFairBoothBookingMapper outdoorFairBoothBookingMapper;
@Autowired
private OutdoorFairBoothMapper outdoorFairBoothMapper;
@Autowired
private CompanyMapper companyMapper;
@Autowired
private FairCompanyMapper fairCompanyMapper;
/**
* 查询场地信息列表
*/
@@ -143,78 +148,6 @@ public class VenueInfoController extends BaseController
return toAjax(venueInfoService.insertVenueInfoDictData(request.getDictType(), request.getDictLabel(), getUsername()));
}
@ApiOperation("查询场地展位图")
@PreAuthorize("@ss.hasAnyPermi('cms:venueInfo:query,cms:outdoorFair:query')")
@GetMapping("/{venueId}/booths")
public AjaxResult listBooths(@PathVariable Long venueId)
{
List<VenueBooth> list = venueBoothMapper.selectList(new LambdaQueryWrapper<VenueBooth>()
.eq(VenueBooth::getVenueId, venueId)
.orderByAsc(VenueBooth::getPositionY)
.orderByAsc(VenueBooth::getPositionX)
.orderByAsc(VenueBooth::getBoothNumber));
return success(list);
}
@ApiOperation("保存场地展位图")
@PreAuthorize("@ss.hasPermi('cms:venueInfo:edit')")
@Transactional(rollbackFor = Exception.class)
@PostMapping("/{venueId}/booths")
public AjaxResult saveBooths(@PathVariable Long venueId, @RequestBody List<VenueBooth> booths)
{
venueBoothMapper.physicalDeleteDeletedByVenueId(venueId);
List<VenueBooth> existingBooths = venueBoothMapper.selectList(new LambdaQueryWrapper<VenueBooth>()
.eq(VenueBooth::getVenueId, venueId));
Map<Long, VenueBooth> existingById = existingBooths.stream()
.filter(item -> item.getId() != null)
.collect(Collectors.toMap(VenueBooth::getId, item -> item, (a, b) -> a));
Map<String, VenueBooth> existingByNumber = existingBooths.stream()
.filter(item -> StringUtils.isNotBlank(item.getBoothNumber()))
.collect(Collectors.toMap(VenueBooth::getBoothNumber, item -> item, (a, b) -> a));
Set<String> boothNumbers = new HashSet<>();
Set<Long> savedIds = new HashSet<>();
if (booths != null) {
for (VenueBooth booth : booths) {
if (StringUtils.isBlank(booth.getBoothNumber())) {
return AjaxResult.error("展位号不能为空");
}
if (!boothNumbers.add(booth.getBoothNumber())) {
return AjaxResult.error("展位号不能重复:" + booth.getBoothNumber());
}
}
for (VenueBooth booth : booths) {
VenueBooth existing = booth.getId() == null ? null : existingById.get(booth.getId());
if (existing == null) {
existing = existingByNumber.get(booth.getBoothNumber());
}
booth.setVenueId(venueId);
if (existing == null) {
booth.setId(null);
venueBoothMapper.insert(booth);
} else {
booth.setId(existing.getId());
venueBoothMapper.updateById(booth);
}
if (booth.getId() != null) {
savedIds.add(booth.getId());
}
}
VenueInfo venueInfo = venueInfoService.getById(venueId);
if (venueInfo != null) {
venueInfo.setFloorCount(booths.size());
venueInfoService.updateById(venueInfo);
}
}
List<Long> removedIds = existingBooths.stream()
.map(VenueBooth::getId)
.filter(id -> id != null && !savedIds.contains(id))
.collect(Collectors.toList());
if (!removedIds.isEmpty()) {
venueBoothMapper.physicalDeleteByVenueIdAndIds(venueId, removedIds);
}
return success();
}
@ApiOperation("查询户外招聘会展位图")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:query')")
@GetMapping("/fair/{fairId}/booth-map")
@@ -224,16 +157,16 @@ public class VenueInfoController extends BaseController
if (fair == null || fair.getVenueId() == null) {
return success(new ArrayList<>());
}
List<VenueBooth> booths = venueBoothMapper.selectList(new LambdaQueryWrapper<VenueBooth>()
.eq(VenueBooth::getVenueId, fair.getVenueId())
.orderByAsc(VenueBooth::getPositionY)
.orderByAsc(VenueBooth::getPositionX)
.orderByAsc(VenueBooth::getBoothNumber));
List<OutdoorFairBooth> booths = outdoorFairBoothMapper.selectList(new LambdaQueryWrapper<OutdoorFairBooth>()
.eq(OutdoorFairBooth::getFairId, fairId)
.orderByAsc(OutdoorFairBooth::getPositionY)
.orderByAsc(OutdoorFairBooth::getPositionX)
.orderByAsc(OutdoorFairBooth::getBoothNumber));
List<OutdoorFairBoothBooking> bookings = outdoorFairBoothBookingMapper.selectList(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId));
Map<Long, OutdoorFairBoothBooking> bookingMap = bookings.stream().collect(Collectors.toMap(OutdoorFairBoothBooking::getBoothId, item -> item, (a, b) -> a));
List<Map<String, Object>> result = new ArrayList<>();
for (VenueBooth booth : booths) {
for (OutdoorFairBooth booth : booths) {
OutdoorFairBoothBooking booking = bookingMap.get(booth.getId());
Map<String, Object> row = new java.util.HashMap<>();
row.put("id", booth.getId());
@@ -250,8 +183,85 @@ public class VenueInfoController extends BaseController
return success(result);
}
@ApiOperation("保存户外招聘会展位图副本")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Transactional(rollbackFor = Exception.class)
@PostMapping("/fair/{fairId}/booths")
public AjaxResult saveFairBooths(@PathVariable Long fairId, @RequestBody List<OutdoorFairBooth> booths)
{
OutdoorFair fair = outdoorFairMapper.selectById(fairId);
if (fair == null || fair.getVenueId() == null) {
return AjaxResult.error("当前招聘会未绑定场地");
}
List<OutdoorFairBooth> existingBooths = outdoorFairBoothMapper.selectList(new LambdaQueryWrapper<OutdoorFairBooth>()
.eq(OutdoorFairBooth::getFairId, fairId));
Map<Long, OutdoorFairBooth> existingById = existingBooths.stream()
.filter(item -> item.getId() != null)
.collect(Collectors.toMap(OutdoorFairBooth::getId, item -> item, (a, b) -> a));
Map<String, OutdoorFairBooth> existingByNumber = existingBooths.stream()
.filter(item -> StringUtils.isNotBlank(item.getBoothNumber()))
.collect(Collectors.toMap(OutdoorFairBooth::getBoothNumber, item -> item, (a, b) -> a));
Set<String> boothNumbers = new HashSet<>();
Set<Long> savedIds = new HashSet<>();
if (booths != null) {
for (OutdoorFairBooth booth : booths) {
if (StringUtils.isBlank(booth.getBoothNumber())) {
return AjaxResult.error("展位号不能为空");
}
if (!boothNumbers.add(booth.getBoothNumber())) {
return AjaxResult.error("展位号不能重复:" + booth.getBoothNumber());
}
}
for (OutdoorFairBooth booth : booths) {
OutdoorFairBooth existing = booth.getId() == null ? null : existingById.get(booth.getId());
if (existing == null) {
existing = existingByNumber.get(booth.getBoothNumber());
}
booth.setFairId(fairId);
booth.setSourceVenueId(fair.getVenueId());
if (existing == null) {
booth.setId(null);
outdoorFairBoothMapper.insert(booth);
} else {
booth.setId(existing.getId());
if (booth.getSourceBoothId() == null) {
booth.setSourceBoothId(existing.getSourceBoothId());
}
outdoorFairBoothMapper.updateById(booth);
}
if (booth.getId() != null) {
savedIds.add(booth.getId());
List<OutdoorFairBoothBooking> bookings = outdoorFairBoothBookingMapper.selectList(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId)
.eq(OutdoorFairBoothBooking::getBoothId, booth.getId()));
for (OutdoorFairBoothBooking booking : bookings) {
booking.setBoothNumber(booth.getBoothNumber());
outdoorFairBoothBookingMapper.updateById(booking);
}
}
}
}
List<Long> removedIds = existingBooths.stream()
.map(OutdoorFairBooth::getId)
.filter(id -> id != null && !savedIds.contains(id))
.collect(Collectors.toList());
if (!removedIds.isEmpty()) {
Long bookedRemovedCount = outdoorFairBoothBookingMapper.selectCount(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId)
.in(OutdoorFairBoothBooking::getBoothId, removedIds));
if (bookedRemovedCount != null && bookedRemovedCount > 0) {
return AjaxResult.error("不能删除已预定展位,请先取消预定");
}
outdoorFairBoothMapper.physicalDeleteByFairIdAndIds(fairId, removedIds);
}
fair.setBoothCount(booths == null ? 0 : booths.size());
outdoorFairMapper.updateById(fair);
return success();
}
@ApiOperation("预定户外招聘会展位")
@PreAuthorize("@ss.hasPermi('cms:outdoorFair:edit')")
@Transactional(rollbackFor = Exception.class)
@PostMapping("/fair/{fairId}/booth-booking")
public AjaxResult bookBooth(@PathVariable Long fairId, @RequestBody OutdoorFairBoothBooking booking)
{
@@ -262,20 +272,28 @@ public class VenueInfoController extends BaseController
if (company == null || company.getStatus() == null || company.getStatus() != 1) {
return AjaxResult.error("只能预定审核通过的企业");
}
VenueBooth booth = venueBoothMapper.selectById(booking.getBoothId());
OutdoorFairBooth booth = outdoorFairBoothMapper.selectById(booking.getBoothId());
if (booth == null || !fairId.equals(booth.getFairId())) {
return AjaxResult.error("展位不存在或不属于当前招聘会");
}
booking.setFairId(fairId);
booking.setBoothNumber(booth == null ? booking.getBoothNumber() : booth.getBoothNumber());
booking.setBoothNumber(booth.getBoothNumber());
booking.setCompanyName(company.getName());
booking.setStatus(StringUtils.isBlank(booking.getStatus()) ? "reserved" : booking.getStatus());
outdoorFairBoothBookingMapper.physicalDeleteDeletedByFairIdAndBoothId(fairId, booking.getBoothId());
OutdoorFairBoothBooking existing = outdoorFairBoothBookingMapper.selectOne(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId)
.eq(OutdoorFairBoothBooking::getBoothId, booking.getBoothId())
.last("limit 1"));
if (existing == null) {
return toAjax(outdoorFairBoothBookingMapper.insert(booking));
int rows = outdoorFairBoothBookingMapper.insert(booking);
ensureFairCompany(fairId, booking.getCompanyId());
return toAjax(rows);
}
booking.setId(existing.getId());
return toAjax(outdoorFairBoothBookingMapper.updateById(booking));
int rows = outdoorFairBoothBookingMapper.updateById(booking);
ensureFairCompany(fairId, booking.getCompanyId());
return toAjax(rows);
}
@ApiOperation("取消户外招聘会展位预定")
@@ -283,9 +301,7 @@ public class VenueInfoController extends BaseController
@DeleteMapping("/fair/{fairId}/booth-booking/{boothId}")
public AjaxResult cancelBooth(@PathVariable Long fairId, @PathVariable Long boothId)
{
return toAjax(outdoorFairBoothBookingMapper.delete(new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.eq(OutdoorFairBoothBooking::getFairId, fairId)
.eq(OutdoorFairBoothBooking::getBoothId, boothId)));
return toAjax(outdoorFairBoothBookingMapper.physicalDeleteByFairIdAndBoothId(fairId, boothId));
}
@ApiOperation("调换户外招聘会展位")
@@ -313,6 +329,20 @@ public class VenueInfoController extends BaseController
return success();
}
private void ensureFairCompany(Long fairId, Long companyId)
{
Long count = fairCompanyMapper.selectCount(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.eq(FairCompany::getCompanyId, companyId));
if (count != null && count > 0) {
return;
}
FairCompany fairCompany = new FairCompany();
fairCompany.setJobFairId(fairId);
fairCompany.setCompanyId(companyId);
fairCompanyMapper.insert(fairCompany);
}
private void fillHandleFields(VenueInfo venueInfo)
{
if (venueInfo.getHandleDate() == null) {

View File

@@ -9,17 +9,23 @@ import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
@ApiModel("场地展位")
@TableName("cms_venue_booth")
public class VenueBooth extends BaseEntity
@ApiModel("户外招聘会展位副本")
@TableName("cms_outdoor_fair_booth")
public class OutdoorFairBooth extends BaseEntity
{
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@ApiModelProperty("场地ID")
private Long venueId;
@ApiModelProperty("招聘会ID")
private Long fairId;
@ApiModelProperty("来源场地ID")
private Long sourceVenueId;
@ApiModelProperty("来源场地展位ID")
private Long sourceBoothId;
@ApiModelProperty("展位号")
private String boothNumber;

View File

@@ -2,7 +2,17 @@ package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.OutdoorFairBoothBooking;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface OutdoorFairBoothBookingMapper extends BaseMapper<OutdoorFairBoothBooking>
{
@Delete("DELETE FROM cms_outdoor_fair_booth_booking WHERE fair_id = #{fairId} AND booth_id = #{boothId} AND del_flag <> '0'")
int physicalDeleteDeletedByFairIdAndBoothId(@Param("fairId") Long fairId, @Param("boothId") Long boothId);
@Delete("DELETE FROM cms_outdoor_fair_booth_booking WHERE fair_id = #{fairId} AND booth_id = #{boothId}")
int physicalDeleteByFairIdAndBoothId(@Param("fairId") Long fairId, @Param("boothId") Long boothId);
@Delete("DELETE FROM cms_outdoor_fair_booth_booking WHERE fair_id = #{fairId}")
int physicalDeleteByFairId(@Param("fairId") Long fairId);
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.OutdoorFairBooth;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface OutdoorFairBoothMapper extends BaseMapper<OutdoorFairBooth>
{
@Delete("DELETE FROM cms_outdoor_fair_booth WHERE fair_id = #{fairId}")
int physicalDeleteByFairId(@Param("fairId") Long fairId);
@Delete({
"<script>",
"DELETE FROM cms_outdoor_fair_booth WHERE fair_id = #{fairId} AND id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
int physicalDeleteByFairIdAndIds(@Param("fairId") Long fairId, @Param("ids") List<Long> ids);
}

View File

@@ -1,24 +0,0 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.VenueBooth;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface VenueBoothMapper extends BaseMapper<VenueBooth>
{
@Delete("DELETE FROM cms_venue_booth WHERE venue_id = #{venueId} AND del_flag <> '0'")
int physicalDeleteDeletedByVenueId(@Param("venueId") Long venueId);
@Delete({
"<script>",
"DELETE FROM cms_venue_booth WHERE venue_id = #{venueId} AND id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
int physicalDeleteByVenueIdAndIds(@Param("venueId") Long venueId, @Param("ids") List<Long> ids);
}

View File

@@ -0,0 +1,46 @@
-- 户外招聘会展位副本:招聘会绑定场地后,在招聘会详情内独立生成和维护展位图
-- 瀚高数据库 / PostgreSQL 兼容
CREATE SEQUENCE IF NOT EXISTS "cms_outdoor_fair_booth_id_seq" START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS "cms_outdoor_fair_booth" (
"id" BIGINT NOT NULL DEFAULT nextval('"cms_outdoor_fair_booth_id_seq"') PRIMARY KEY,
"fair_id" BIGINT NOT NULL,
"source_venue_id" BIGINT,
"source_booth_id" BIGINT,
"booth_number" VARCHAR(50) NOT NULL,
"position_x" INTEGER NOT NULL DEFAULT 0,
"position_y" INTEGER NOT NULL DEFAULT 0,
"create_by" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP(0),
"update_by" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP(0),
"remark" VARCHAR(500),
"del_flag" CHAR(1) DEFAULT '0'
);
COMMENT ON TABLE "cms_outdoor_fair_booth" IS '户外招聘会展位图副本';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."fair_id" IS '招聘会ID';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."source_venue_id" IS '来源场地ID';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."source_booth_id" IS '来源场地展位ID';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."booth_number" IS '展位号';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."position_x" IS 'X坐标';
COMMENT ON COLUMN "cms_outdoor_fair_booth"."position_y" IS 'Y坐标';
CREATE UNIQUE INDEX IF NOT EXISTS "uk_cms_outdoor_fair_booth_number" ON "cms_outdoor_fair_booth" ("fair_id", "booth_number");
CREATE INDEX IF NOT EXISTS "idx_cms_outdoor_fair_booth_fair" ON "cms_outdoor_fair_booth" ("fair_id");
-- 兼容历史预定:如果已有副本保留了 source_booth_id把旧 booking.booth_id 改为招聘会副本展位ID
UPDATE "cms_outdoor_fair_booth_booking" booking
SET "booth_id" = fair_booth."id",
"booth_number" = fair_booth."booth_number"
FROM "cms_outdoor_fair_booth" fair_booth
WHERE booking."fair_id" = fair_booth."fair_id"
AND booking."booth_id" = fair_booth."source_booth_id"
AND booking."del_flag" = '0'
AND NOT EXISTS (
SELECT 1
FROM "cms_outdoor_fair_booth" snapshot_booth
WHERE snapshot_booth."fair_id" = booking."fair_id"
AND snapshot_booth."id" = booking."booth_id"
);

View File

@@ -1,86 +0,0 @@
-- 场地展位图与户外招聘会展位预定
-- 瀚高数据库 / PostgreSQL 兼容
CREATE SEQUENCE IF NOT EXISTS "cms_venue_booth_id_seq" START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS "cms_venue_booth" (
"id" BIGINT NOT NULL DEFAULT nextval('"cms_venue_booth_id_seq"') PRIMARY KEY,
"venue_id" BIGINT NOT NULL,
"booth_number" VARCHAR(50) NOT NULL,
"position_x" INTEGER NOT NULL DEFAULT 0,
"position_y" INTEGER NOT NULL DEFAULT 0,
"create_by" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP(0),
"update_by" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP(0),
"remark" VARCHAR(500),
"del_flag" CHAR(1) DEFAULT '0'
);
COMMENT ON TABLE "cms_venue_booth" IS '场地展位图';
COMMENT ON COLUMN "cms_venue_booth"."venue_id" IS '场地ID';
COMMENT ON COLUMN "cms_venue_booth"."booth_number" IS '展位号';
COMMENT ON COLUMN "cms_venue_booth"."position_x" IS 'X坐标';
COMMENT ON COLUMN "cms_venue_booth"."position_y" IS 'Y坐标';
CREATE UNIQUE INDEX IF NOT EXISTS "uk_cms_venue_booth_number" ON "cms_venue_booth" ("venue_id", "booth_number");
CREATE INDEX IF NOT EXISTS "idx_cms_venue_booth_venue" ON "cms_venue_booth" ("venue_id");
CREATE SEQUENCE IF NOT EXISTS "cms_outdoor_fair_booth_booking_id_seq" START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS "cms_outdoor_fair_booth_booking" (
"id" BIGINT NOT NULL DEFAULT nextval('"cms_outdoor_fair_booth_booking_id_seq"') PRIMARY KEY,
"fair_id" BIGINT NOT NULL,
"booth_id" BIGINT NOT NULL,
"booth_number" VARCHAR(50) NOT NULL,
"company_id" BIGINT,
"company_name" VARCHAR(200),
"status" VARCHAR(30) DEFAULT 'reserved',
"create_by" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP(0),
"update_by" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP(0),
"remark" VARCHAR(500),
"del_flag" CHAR(1) DEFAULT '0'
);
COMMENT ON TABLE "cms_outdoor_fair_booth_booking" IS '户外招聘会展位预定';
CREATE UNIQUE INDEX IF NOT EXISTS "uk_cms_outdoor_fair_booth_booking" ON "cms_outdoor_fair_booth_booking" ("fair_id", "booth_id");
CREATE INDEX IF NOT EXISTS "idx_cms_outdoor_fair_booth_booking_fair" ON "cms_outdoor_fair_booth_booking" ("fair_id");
WITH target_venue AS (
SELECT "id" AS venue_id
FROM "cms_venue_info"
WHERE "venue_name" = '石河子市人民广场户外招聘区'
LIMIT 1
),
seed AS (
SELECT
target_venue.venue_id,
'KJ' || LPAD(gs::text, 2, '0') AS booth_number,
40 + ((gs - 1) % 20) * 54 AS position_x,
40 + ((gs - 1) / 20) * 54 AS position_y
FROM target_venue
CROSS JOIN generate_series(1, 80) gs
)
INSERT INTO "cms_venue_booth" ("venue_id", "booth_number", "position_x", "position_y", "create_by", "create_time", "del_flag")
SELECT seed.venue_id, seed.booth_number, seed.position_x, seed.position_y, 'system', NOW(), '0'
FROM seed
WHERE NOT EXISTS (
SELECT 1
FROM "cms_venue_booth" existing
WHERE existing."venue_id" = seed.venue_id
AND existing."booth_number" = seed.booth_number
);
UPDATE "cms_venue_info" venue
SET "floor_count" = booth_counts.total
FROM (
SELECT "venue_id", COUNT(*) AS total
FROM "cms_venue_booth"
GROUP BY "venue_id"
) booth_counts
WHERE venue."id" = booth_counts."venue_id";
UPDATE "cms_outdoor_fair" fair
SET "booth_count" = venue."floor_count"
FROM "cms_venue_info" venue
WHERE fair."venue_id" = venue."id";

View File

@@ -0,0 +1,5 @@
-- 移除场地信息维护中的展位图母本表
-- 执行前请确认招聘会展位图已经迁移到 cms_outdoor_fair_booth
DROP TABLE IF EXISTS "cms_venue_booth";
DROP SEQUENCE IF EXISTS "cms_venue_booth_id_seq";