Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79690518e | ||
|
|
863b16b118 | ||
|
|
9010592c9e | ||
|
|
d136c23874 | ||
|
|
a337336a50 | ||
|
|
d0a084b80e | ||
|
|
582485ba2b | ||
|
|
ad4567fc01 | ||
|
|
4b3c00270f | ||
|
|
70cb86176e | ||
|
|
ebc507ee6c | ||
|
|
06484774ad | ||
|
|
5e517a2d95 | ||
|
|
abd81486ad | ||
|
|
3d24dbd8cb | ||
|
|
e868c1ef9c | ||
|
|
0569c70690 | ||
|
|
e18b25f32a | ||
|
|
743683efdd | ||
|
|
1d451499b9 | ||
|
|
a85a520af7 | ||
|
|
f81ce64fdd | ||
|
|
8721d01028 | ||
|
|
8b11893d35 | ||
|
|
1e28e02dc1 | ||
|
|
ea50261cb8 | ||
|
|
856d62f9c7 | ||
|
|
4445b8e8bf | ||
|
|
a2f60c3410 | ||
|
|
69137f366c | ||
|
|
b439c4d89c | ||
|
|
8950dc83e9 | ||
|
|
361e063b80 | ||
|
|
4aca0c7f6f | ||
|
|
ded75e1bed | ||
|
|
78756a849f | ||
|
|
2ed31f012e | ||
|
|
45f9098e89 | ||
|
|
6e3ef67709 | ||
|
|
061dbde8ce | ||
|
|
43fa79686d | ||
|
|
1d632065fe | ||
|
|
75e56dae43 | ||
|
|
8fdb25e6bd | ||
|
|
5cd8f0f551 | ||
|
|
82b1ad2b5c | ||
|
|
1486bb903f | ||
|
|
8d5f2c65ae |
@ -1,4 +1,9 @@
|
|||||||
# deployed by gitea act_runner - trigger on push v5
|
# service.xpcool.com 自动部署:Gitea act_runner(push main/dev 触发)
|
||||||
|
# 注意点(与前端差异):
|
||||||
|
# 1. 后端不是拷贝静态文件,而是 Go 交叉编译 + Docker 镜像重建容器(runner 已挂载 docker.sock)。
|
||||||
|
# 2. job 基础镜像 node:20-bullseye 无 Go,需下载 Go 1.24 工具链(aliyun 镜像,GOPROXY 走 goproxy.cn)。
|
||||||
|
# 3. 容器 env 通过 Gitea Actions secrets 注入(DB_DSN 必须带 mysql: 类型前缀,勿写死在仓库)。
|
||||||
|
# 4. 数据库结构变更请手动执行 manifest/sql/ 下脚本(003→004→004b→007),workflow 不做自动 DDL。
|
||||||
name: Build and Deploy (service.xpcool.com)
|
name: Build and Deploy (service.xpcool.com)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@ -10,54 +15,71 @@ jobs:
|
|||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install git & docker CLI
|
- name: Install git & download Go toolchain
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
|
# apt 换腾讯云源(deb.debian.org 国内直连超时)
|
||||||
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list 2>/dev/null || true
|
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||||
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true
|
sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g; s|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true
|
||||||
apt-get update
|
apt-get update -qq
|
||||||
apt-get install -y git docker.io
|
apt-get install -y -qq git >/dev/null
|
||||||
|
# aliyun golang 镜像(实测 200,比 go.dev 稳定,go.dev 偶发 SSL 错误 exitcode 35)
|
||||||
|
for i in 1 2 3; do
|
||||||
|
curl -fsSL https://mirrors.aliyun.com/golang/go1.24.5.linux-amd64.tar.gz -o /tmp/go.tar.gz && break
|
||||||
|
echo "aliyun 下载失败,第 $i 次重试..."; sleep 5
|
||||||
|
done
|
||||||
|
mkdir -p /usr/local && tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
|
export PATH="/usr/local/go/bin:$PATH"
|
||||||
|
echo "/usr/local/go/bin" >> "$GITHUB_ENV"
|
||||||
|
go version
|
||||||
|
|
||||||
- name: Checkout (local Gitea)
|
- name: Checkout (local Gitea)
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch "${{ github.ref_name }}" https://oauth2:${{ github.token }}@git.xpcool.com/${{ github.repository }}.git .
|
git clone --depth 1 --branch "${{ github.ref_name }}" https://oauth2:${{ github.token }}@git.xpcool.com/${{ github.repository }}.git .
|
||||||
git checkout ${{ github.sha }}
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
- name: Install Go 1.23
|
- name: Cross compile (linux/amd64, CGO disabled)
|
||||||
run: |
|
|
||||||
curl -fsSL https://mirrors.aliyun.com/golang/go1.23.0.linux-amd64.tar.gz -o /tmp/go.tgz
|
|
||||||
rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz
|
|
||||||
/usr/local/go/bin/go version
|
|
||||||
|
|
||||||
- name: Build (linux/amd64)
|
|
||||||
env:
|
env:
|
||||||
GOPROXY: https://goproxy.cn,direct
|
GOPROXY: https://goproxy.cn,direct
|
||||||
|
GOFLAGS: -mod=mod
|
||||||
run: |
|
run: |
|
||||||
export PATH=$PATH:/usr/local/go/bin
|
export PATH="/usr/local/go/bin:$PATH"
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o temp/linux_amd64/main .
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/deploy/main main.go
|
||||||
|
ls -lh /tmp/deploy/main
|
||||||
|
|
||||||
- name: Docker build & deploy
|
- name: Assemble deploy dir (binary + config + resource + Dockerfile)
|
||||||
run: |
|
run: |
|
||||||
docker build -f manifest/docker/Dockerfile -t service.xpcool.com:latest .
|
set -e
|
||||||
docker rm -f service.xpcool.com 2>/dev/null || true
|
mkdir -p /tmp/deploy/manifest/config /tmp/deploy/resource
|
||||||
docker run -d \
|
# 配置:基础 config.yaml + 环境化 config.prod.yaml(GF_GCFG_ENV=prod 时生效)
|
||||||
--name service.xpcool.com \
|
cp manifest/config/config.prod.yaml /tmp/deploy/manifest/config/config.prod.yaml
|
||||||
--restart unless-stopped \
|
cp manifest/config/config.prod.yaml /tmp/deploy/manifest/config/config.yaml
|
||||||
|
# 静态资源(GoFrame public/template)
|
||||||
|
cp -r resource/public /tmp/deploy/resource/public
|
||||||
|
mkdir -p /tmp/deploy/resource/template
|
||||||
|
# 容器 Dockerfile(仓库内维护 deploy/Dockerfile,避免 workflow 内 heredoc 缩进问题)
|
||||||
|
cp deploy/Dockerfile /tmp/deploy/Dockerfile
|
||||||
|
echo "assemble done"
|
||||||
|
|
||||||
|
- name: Build image & restart container
|
||||||
|
env:
|
||||||
|
DB_DSN: ${{ secrets.DB_DSN }}
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd /tmp/deploy
|
||||||
|
docker build -t service.xpcool.com:latest .
|
||||||
|
docker rm -f service.xpcool.com >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name service.xpcool.com \
|
||||||
--network xpcool-net \
|
--network xpcool-net \
|
||||||
-p 127.0.0.1:8000:8000 \
|
-p 127.0.0.1:10100:10100 \
|
||||||
-e DB_DSN="${{ secrets.DB_DSN }}" \
|
-e GF_GCFG_ENV=prod \
|
||||||
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
|
-e "DB_DSN=$DB_DSN" \
|
||||||
|
-e "JWT_SECRET=$JWT_SECRET" \
|
||||||
|
--restart unless-stopped \
|
||||||
service.xpcool.com:latest
|
service.xpcool.com:latest
|
||||||
|
# 冒烟:等待启动并验证 OpenAPI
|
||||||
- name: Notify success (PushPlus)
|
sleep 5
|
||||||
if: success()
|
curl -fsS -m 10 http://127.0.0.1:10100/api.json | head -c 120 || { echo "SMOKE TEST FAILED"; exit 1; }
|
||||||
run: |
|
echo
|
||||||
curl -s -X POST "https://www.pushplus.plus/send" \
|
echo "Deployed service.xpcool.com (container restarted, 127.0.0.1:10100)"
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"token":"${{ secrets.PUSHPLUS_TOKEN }}","title":"✅ 部署成功 service.xpcool.com","content":"仓库: ${{ github.repository }}<br>分支: ${{ github.ref_name }}<br>提交: ${{ github.sha }}<br>触发人: ${{ github.actor }}<br>状态: 编译打包部署成功<br>访问: https://service.xpcool.com","template":"html"}'
|
|
||||||
|
|
||||||
- name: Notify failure (PushPlus)
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
curl -s -X POST "https://www.pushplus.plus/send" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"token":"${{ secrets.PUSHPLUS_TOKEN }}","title":"❌ 部署失败 service.xpcool.com","content":"仓库: ${{ github.repository }}<br>分支: ${{ github.ref_name }}<br>提交: ${{ github.sha }}<br>触发人: ${{ github.actor }}<br>状态: 构建或部署失败<br>日志: https://git.xpcool.com/${{ github.repository }}/actions","template":"html"}'
|
|
||||||
|
|||||||
26
.gitignore
vendored
26
.gitignore
vendored
@ -17,3 +17,29 @@ temp/
|
|||||||
temp.yaml
|
temp.yaml
|
||||||
bin
|
bin
|
||||||
**/config/config.yaml
|
**/config/config.yaml
|
||||||
|
|
||||||
|
# WorkBuddy 本地记忆:仅放行变更记录(CHANGELOG.md),其余本地数据不入库
|
||||||
|
.workbuddy/*
|
||||||
|
!.workbuddy/memory/
|
||||||
|
.workbuddy/memory/*
|
||||||
|
!.workbuddy/memory/CHANGELOG.md
|
||||||
|
# server runtime logs
|
||||||
|
# 注意必须锚定到仓库根目录:写成 `log/` 会匹配任意层级的 log 目录,
|
||||||
|
# 把 api/admin/**/log、internal/service/admin/**/log 这些**源码包**一并忽略,
|
||||||
|
# 导致它们被静默漏提交(引用方 controller 已入库 → HEAD 编译不过)。
|
||||||
|
/log/
|
||||||
|
|
||||||
|
# 本地环境变量(含数据库口令),禁止提交
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# 运行时数据目录(含自动生成的 RSA 登录加密私钥),禁止提交
|
||||||
|
data/
|
||||||
|
|
||||||
|
# GoLand 项目级运行配置(含数据库口令),禁止提交
|
||||||
|
.run/
|
||||||
|
service_test.exe
|
||||||
|
service_test2*
|
||||||
|
|
||||||
|
# 招聘爬虫本地离线验证产物(rc_*:验证脚本编译出的二进制 + 运行日志),不入库
|
||||||
|
rc_*.log
|
||||||
|
rc_verify*
|
||||||
|
|||||||
75
.workbuddy/memory/CHANGELOG.md
Normal file
75
.workbuddy/memory/CHANGELOG.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# service.xpcool.com 变更记录
|
||||||
|
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||||
|
|
||||||
|
2026-09-14 | CFG | **安装 chrome-devtools MCP(用户级配置)**:全局 `npm i -g chrome-devtools-mcp@1.9.0`(本机 Node v23.0.0 满足其 `engines: ^20.19.0 || ^22.12.0 || >=23`),在 `C:\Users\xxl\.codebuddy\mcp.json` 的 `mcpServers` 下新增 `chrome-devtools` 条目。**刻意沿用现有 `dbx` 条目的绝对路径写法**(`command` 指向 `...\nvm\v23.0.0\node.exe`,`args[0]` 指向 `...\node_modules\chrome-devtools-mcp\build\src\bin\chrome-devtools-mcp.js`),而非 `npx chrome-devtools-mcp@latest`——Windows 上 `npx` 实为 `npx.cmd`,由 IDE 直接 spawn 时存在解析风险,绝对路径最稳。附 `--no-usage-statistics` 关闭 Google 使用统计。Chrome 已就位(`C:\Program Files\Google\Chrome\Application\chrome.exe`,MCP 默认自动发现,未额外传 `--executablePath`)。冒烟测试:`initialize` 请求下进程正常启动、stderr 打印自身横幅、退出码 0。**需重启 IDE(或重载 MCP 面板)方生效**;默认行为是新开一个独立 profile 的 Chrome 实例,若要接管已开的浏览器需改用 `--browser-url http://127.0.0.1:9222`
|
||||||
|
|
||||||
|
2026-09-14 | CHG | **壁纸模块后端上线(12 接口 + 开源平台聚合 + 自建图库)**。为 xpcool.com 提供「开源壁纸平台 + 后台自建图库」双来源,**保留**原有浏览器实时生成能力。设计核心是**带宽经济学**(本机出口仅 5M≈625KB/s 且多站共用):① 开源平台(Bing 每日一图 / Picsum / Unsplash / Pexels / Wallhaven)图片**不经过服务器**,后端只代理元数据并返回官方 CDN 直链,`sources`/`list`/`random`/`download-track` 挂 `open` 分组,平台响应内存缓存(Bing 1h、其余 10min);② 自建图库分三档落盘(480px 缩略图 / 长边 1920 预览 / 原图),**由 nginx 直出 `/wallpaper/`**,后端只管元数据与上传。接口遵守本仓库强制规范:**全 POST、URL 不带参、入参走 body**。`manifest/sql/018_wallpaper.sql` 为幂等种子(菜单 + 5 平台,`ON DUPLICATE KEY UPDATE` 只更新 name/sort/remark)
|
||||||
|
|
||||||
|
2026-09-14 | FIX | **全量加密(fullBody)下 multipart 上传必被拒**(生产才暴露、dev 永不复现):`encrypt.fullBody=true` 时中间件对所有非豁免请求一律 `json.Unmarshal` body,而 multipart 是二进制流必然失败 → 上传被判定为「未加密请求」拒绝(前端对 `FormData` 本就跳过加密,**两端语义不一致**)。修法:`internal/middleware/encrypt.go` 新增 `isMultipartRequest` 豁免,与前端 `request.ts` 的 `config.data instanceof FormData` 分支对齐;**不影响安全** —— 上传接口同样挂在 `/api/service/admin` 分组下,仍受 JWT + RBAC(POST+path 精确匹配)双重保护
|
||||||
|
|
||||||
|
2026-09-14 | FIX | **`.gitignore` 的 `log/` 规则误伤源码包,导致干净检出无法编译**:`log/` 未锚定根目录、匹配任意层级,把 `api/admin/{base,system}/log`、`internal/service/admin/{base,system}/log` 四个**源码包**静默忽略,而 `internal/controller/admin/log.go` 已在库 → 干净 clone 后 `go build .` 直接失败(`no required module provides package service.xpcool.com/api/admin/base/log`)。修法:改为 `/log/` 并补交这 4 个包;此后干净 HEAD 交叉编译通过(31,075,947 字节)
|
||||||
|
|
||||||
|
2026-09-14 | DEP | **壁纸模块部署上线 + 端到端验证**:`GOOS=linux GOARCH=amd64 CGO_ENABLED=0` 交叉编译 → 替换 `/data/deploy/projects/service.xpcool.com/main`(旧版备份 `main.bak.20260914b`)→ 配置换为仓库版 → `docker build` 重建容器。**两个必守点**:① 容器必须显式传 `GF_GCFG_FILE=config.prod.yaml`(gf v2.10.2 起不再自动替换 `${ENV}` 占位符,且新 `main.go` 只认 `GF_GCFG_FILE`/`GF_GCFG_PATH`、**不认 `GF_GCFG_ENV`**,漏传即崩溃循环 `specified config file "config.dev.yaml" not found`);② 双挂载 `/data/www/wallpaper` + `/data/service-crypto`(**防容器重建后 RSA 私钥漂移,否则前端解密全挂**)。**验证全绿**:5 平台 sources、朝向过滤、来源限定 random、停用来源返回明确中文错误、CORS 正确回显、密文登录解密成功、响应体 `{"encryptedData":…}`、明文业务请求被拒 `encrypted request required`
|
||||||
|
|
||||||
|
2026-09-14 | DOC | 壁纸模块上线中修复的**生产数据问题(中文双编码 mojibake)**:`admin_menu`/`wallpaper_source` 中文呈 `Bing æ¯æ—¥ä¸€å›¾`,判据 `HEX(name)` 为 `C3A7C2B3…` 且 `LENGTH/CHAR_LENGTH = 25/12`(正确应为 9)。根因是**导入 SQL 时未指定 `--default-character-set`**,MySQL 客户端按 latin1 解码 UTF-8 字节、再按 utf8mb4 重编码。以 `--default-character-set=utf8mb4` 重放 `018_wallpaper.sql` 后壁纸相关中文恢复。**注意 `admin_menu` 全表历史乱码(id 1~96)本次有意未动**,待用户决定是否批量修复
|
||||||
|
|
||||||
|
2026-09-14 | FIX | **招聘抓取模块健壮性实施与真实站点校准(阶段一完成)**。基于贵阳市人社局真实列表页做离线+在线校准,修正两处**方案级错误**:① **评分判定必须按「URL 结尾形态」而非「路径关键词」**——栏目页 `/zfxxgk/.../rszk/`(以 `/` 结尾)与正文页 `/zfxxgk/.../rszk/202609/t20260908_xxx.html`(以 `.html` 结尾)**共享同一段栏目路径**,按路径段减分会把真实公告一并误杀(首版实测 19 条真实公告全被过滤只剩 1 条);改为「`.html` 等后缀 +3 / 以 `/` 结尾 -5」后,16 个真实锚点用例分类全对。② **图片型锚点会以文件名污染标题**——`<a href=".../ysqgk/index.html"><img src="ysqgk3.png" title="ysqgk3.png"></a>` 经 stripTags 后为空、`title` 属性回退取到图片名 `ysqgk3.png`,**绕过全部中文排除词**且叠加 `.html` 的 +3 分通过筛选,故新增 `isImageFileName`(图片锚点优先取 alt,取不到整体丢弃)+ 排除域名硬拦友链(gyrc.cn 贵阳人才网、cx.guiyang.gov.cn 信用中国)。另完成:发布日期**分段抽取**(meta PubDate/og:published_time → 语义容器 class=time/date → 「发布时间:」前缀 40 字内 → 自定义正则 → **置空**,替代原「取整页首个日期」把页头"今天是…"误当发布日)、失败**自动禁用**(连续 5 次禁用+告警、3 次预警、成功归零,用坏源 http://127.0.0.1:1 实测第 5 次 `enabled` 1→0)、`Sources()` 改读 `last_log_*` 冗余列消除 `crawl_log` 全表扫描、`resolveCfgValue` 把未解析 `${...}` 占位符视为空(避免「invalid character { in host name」误导)、`cleanText` 清洗 `\n\t\t\t` 标题污染、`bark.go` 4 处英文日志中文化。**实测对比(源1)**:增量 8 条含 6 噪声 → 2 条全真实;force 全量 8 条 → **21 条真实公告**;日期识别率 → **100%**(0 条空)。单测 `go test ./internal/service/recruitment/` 全绿。详见 `docs/recruitment-crawler-design.md` 第七节
|
||||||
|
|
||||||
|
2026-09-14 | FEAT | **通知历史记录表格界面(后端能力补齐 + 前端页面 + 联调,跨两个仓库)**。① **后端**:`api/notice/notice.go` 日志契约由 11 字段扩到 27 字段(新增 batchId/ruleName/eventName/noticeType/typeName/group/groupName/channelName/userName/status/statusName/retryCount/durationMs/source/remark),新增 `keyword/noticeType/group/userId/batchId/status/orderBy/orderDir` 筛选与 `stats` 统计概览,新增 4 个端点 `log/detail`、`log/delete`、`log/clear`、`log/options`;`dto/notice_meta.go`(新增)收口「渠道/事件/类型/分组」四级字典与中文名映射(**类型与分组由 event_type 派生,不落库**,避免双写不一致);`entity/do` 的 NoticeLog 加 6 列;service `LogList` 重写为多维筛选 + 列表/统计**共用同一 logQuery 保证口径一致** + 批量补齐规则名与接收人(避免 N+1),新增 LogDetail/LogDelete/LogClear,`Send` 引入 batchId、`deliver` 记录耗时/来源/状态,写库由 `map[string]interface{}` **改回 do 对象**(符合 AGENTS.md 硬规范);controller 新增 4 方法 + toLogItem/toMetaItems 转换。② **SQL**:`manifest/sql/017_notice_log_enhance.sql`(notice_log 加 6 列 + 3 索引 + 按 result 回填 status + 菜单 982 改「通知历史」+ 5 条 type=2 权限 + 超管绑定,全幂等)。⚠️**关键坑**:`ADD COLUMN status ... DEFAULT 1` 会把**全部历史行**置 1(含失败记录)→ 显示"历史失败记录成功",故默认取 0 再按 result 回填。③ **前端**(`E:\Project\admin.xpcool.com`):`api/notice.ts` 扩类型与 4 个新方法;`views/system/notice/log/index.vue` 重写(4 张统计卡 + 9 项筛选 + 多选表格 + 详情抽屉 + 批量删除 + 保留 N 天清空 + 权限码控显)。④ **联调**:迁移已执行(notice_log 17 列、菜单 982/9821-9825、14 条历史 status 正确回填为 2);经前端 Vite 代理(20100→10100)跑通 登录→选项→列表→详情,5 接口全部通过 RBAC 返回 code=0;真实投递一条测试通知验证 batchId/durationMs/source/userName/target/remark 落库回显;vue-tsc/oxfmt/oxlint 全 0 错误。⑤ **联调中修复缺陷**:LogDetail 对不存在 ID 会把 `sql.ErrNoRows` 包装上抛 → gf 归为 code=50 且**回显 SQL 细节**,改为忽略 Scan 错误 + `Id==0` 判定,现返回 code=10001「通知记录不存在」。详见 docs/change-log/2026-09-14.md
|
||||||
|
|
||||||
|
2026-09-14 | CHG | **归档提交 09-13~09-14 压在工作区的两批改动(招聘爬虫健壮性 + 通知记录增强)**,11 改 9 增。① **招聘爬虫**:新增 `source_config.go`(`crawl_source.config` 这个 VARCHAR(1000) JSON 的源级抓取参数解析——增量窗口/单次上限/分页/限速/自定义标准词,**全字段可选、解析失败即回退默认**,保证历史数据零迁移可用、一个脏配置不会拖垮整个调度)、`keywords.go`(链接筛选改「评分制」:URL 形态加分 + 标准词加分 + 排除词大幅减分达阈值入选,取代原先「URL 形态与文本语义必须同时命中」的与逻辑——该逻辑会系统性漏抓「补充工作人员的通知」「公开选调公务员简章」等标题变体且静默无报错,带 `keywords_test.go` 单测与 `testdata/`)、`manifest/sql/recruitment/004_crawl_source_status.sql`(`crawl_source` 增「最近一次运行状态」冗余列,规避 `Sources()` 全表扫只增不减的 `crawl_log`、随运行时间线性劣化);`crawler.go` 在既有两级抓取流程上做锚点抽取/过滤与编码回退的健壮性增强,`bark.go` 推送配合调整。② **通知模块**:`notice.go`/controller/api/dto 扩展通知日志的查询与操作(批次号、状态、重试次数、来源、耗时、详情、删除、清空)、`dto/notice_meta.go`(312 行,字典选项元数据)、`manifest/sql/017_notice_log_enhance.sql`(notice_log 扩列 + 新增接口权限,幂等可重放)。③ `.gitignore` 补 `rc_*` 挡掉本地验证产物(含 30MB 的 `rc_verify.exe`)。设计依据见 `docs/recruitment-crawler-design.md`
|
||||||
|
|
||||||
|
2026-09-13 | DOC | 项目整理与全局中文化:①重写 README.MD(原英文且路由陈旧 /api/v1 → 实际三分组 /api/service/{open,user,admin},补分层约定/RBAC 权限映射/gen dao 流程/本地启动);②重写 PROJECT_STRUCTURE.md(原英文目录树缺 house/recruitment/notice/job/serversecurity,标注手写 entity/do/dao 风险);③hack/hack.mk + hack-cli.mk 注释中文化;④common/doc.go + common/tools/doc.go 工具清单中文化,ip/main.go/cmd.go 英文注释中文化;⑤**校验提示与服务层错误信息中文化**(19 文件逾 90 处):api 层 v:"#提示" 中文(user/auth、tools/md5、tools/random、menu_manage、admin)、service 层 gerror.Wrap/response.Error 全部中文(admin/admin、admin/login、system/{role,menu,menu_manage,login_log}、user/auth、house/{community,listing,dashboard,transaction,presale}、notice、job、serversecurity、recruitment/{recruitment,crawler})、jwt.go、controller/admin/admin.go、各 service 的 panic("xxx implementation not registered")→"xxx 实现未注册";⑥api/user/login/login.go 补中文包文档。生成代码(entity/do/dao)英文注释按约定保持原样(DO NOT EDIT,重生成会覆盖)。仅注释/文档/字符串改动,无业务逻辑变更;read_lints 全绿,go build 因 proxy.golang.org 网络超时未跑通。详见 docs/change-log/2026-09-13.md
|
||||||
|
|
||||||
|
2026-08-27 | CHG | 登录接口从全量加密豁免,永远只加密密码字段:APICrypto plainRoutes 增 /system/auth/login(登录请求不整体加密,handler 直接收 {username, encryptedKey, encryptedData},登录响应因无会话密钥保持明文);ResolveLogin 分支顺序调整——密码字段密文优先解密,其次 fullBody 明文、开发 allowPlain。全量模式验证 5 项(登录明文响应/info 密文/解密成功/未加密拒绝/登出)+ dev 回归 15 项全过
|
||||||
|
|
||||||
|
2026-08-27 | FEAT | 全量请求/响应加密(生产 encrypt.fullBody=true / env ENCRYPT_FULL_BODY):①crypto.Service 增 fullBody 开关 + DecryptRequest(RSA 解 AES 会话密钥 + AES-GCM 解 body,返回会话密钥)+ EncryptResponse(用同一会话密钥 AES-GCM 加密响应回传,请求结束即销毁);②新增 middleware/APICrypto——请求整体解密(io.ReadAll 直接读 r.Request.Body 勿用 GetBody,否则缓存密文致 handler Parse 读到密文)+ 响应加密 buffer(HandlerResponse 外层、Recover 内层,500 也加密),public-key 明文豁免,未加密业务请求一律拒绝;③admin 组中间件链改 CORS→APICrypto→Recover→HandlerResponse;④ResolveLogin/resolvePassword 增 fullBody 分支(传输层已整体解密,直接信任明文 username/password);⑤injectEnv 支持 ENCRYPT_FULL_BODY/ENCRYPT_ALLOW_PLAIN(直接跑二进制恒加载 config.yaml,GF_GCFG_ENV 仅 gf run 认,必须 env 注入)。⚠️GF 坑:r.GetBody() 缓存密文到 bodyContent,handler Parse 走缓存 → 中间件必须 io.ReadAll(r.Request.Body)。dev(仅密码加密)E2E 15 项、prod(全量加密)E2E 9 项全过
|
||||||
|
|
||||||
|
2026-08-27 | FEAT | 登录密码「RSA + AES-GCM」混合加密传输(对称+非对称结合)+ 创建/重置密码加密字段:①新建 internal/library/crypto——密钥优先级 配置 encrypt.privateKey(PEM) > data/crypto/rsa_private.pem > 自动生成落盘(data/ 已 gitignore);公钥输出 SPKI(x509.MarshalPKIXPublicKey,前端 WebCrypto importKey('spki'),⚠️PKCS#1 会 ASN.1 wrong tag);DecryptLogin 解密 {username,password,ts}(ts 5 分钟窗口防重放)、DecryptField 解密 {value,ts}(创建/重置密码复用);进程级 SetDefault/Get 单例。②公开接口 POST /system/auth/public-key 返回 publicKey;LoginReq 改 encryptedKey+encryptedData(明文字段仅 encrypt.allowPlain=true 时可用,config.yaml/prod 默认 false、dev true);controller.ResolveLogin 统一解析凭据。③AdminCreate/AdminResetPwd 同样支持加密 password(resolvePassword 复用 DecryptField)。④密文结构:encryptedData=base64(nonce(12B)||ciphertext||tag)、encryptedKey=base64(RSA-OAEP(SHA-256) 加密 AES-256 密钥)。端到端 Node 模拟 WebCrypto 15 项全过(公钥/加密登录/错密码30002/篡改密文/过期载荷/受保护接口/加密创建/加密重置/旧密码失效/登出撤销/登出后刷新拒绝)。注:数据库密码本就是 bcrypt 哈希保存(bcrypt.GenerateFromPassword),存储安全已达标
|
||||||
|
|
||||||
|
2026-08-27 | FEAT | 认证链路联调(登录/登出/超时,3d24dbd):①未授权响应 HTTP 200+code10002 改 401 语义化(response.JSONWithStatus;三处中间件切换)——vben authenticateResponseInterceptor 只认 HTTP 401,此前 token 过期永不触发静默刷新;②LogoutReq 支持 refreshToken,登出撤销刷新会话(IAdminAuth.Revoke 幂等),Refresh 顺带清理过期会话行;③injectEnv 开发兜底:${DB_DSN}/${RECRUITMENT_DB_DSN}/${JWT_SECRET} 占位符未注入 env 时回填本地默认 DSN,任何启动方式零配置可跑(当日两起登录 500 均为启动漏 env),prod 不兜底。端到端 8 项 curl 全过:错密码 30002/登录/伪造 token HTTP401/轮换/旧令牌重放拒绝/登出撤销/登出后拒绝/有效访问 200。注:并行 notice/job 会话提交的 admin_user entity 为 bool/string 映射(公司 gen 模板),已按旧风格手工恢复 int/gtime 并补两列
|
||||||
|
2026-08-27 | CHG | 通知模块+自动任务+用户渠道字段:016 SQL(admin_user 加 bark_device_id/pushplus_token;建 notice_channel/rule/log + auto_job/auto_job_log 5 表;菜单 98 通知中心(980规则/981渠道/982日志)/99 自动任务(990任务/991日志) + type=2 权限;渠道/规则/任务种子;人员管理菜单改名用户管理);api/notice + api/job(11 接口全 POST 无 URL 参数);service/notice(Send 统一发送:Bark 路径式 POST /{key} + pushplus 官方接口;渠道/规则/日志/测试);service/job(DB 驱动 gcron:List/Save/Trigger/LogList + Register 业务函数注册 + 完成/失败自动通知 job_done/job_fail);recruitment scheduler 改 RegisterTasks 纳管(原 StartScheduler 废弃);用户 admin 接口支持渠道字段;已部署:容器重建 api.json 200、11 路由全注册、调度器无 handler 缺失告警、种子数据验证 OK。
|
||||||
|
2026-08-27 | FIX | 菜单种子主键冲突修复(743683e):recruitment/003_menu.sql 招聘中心一级菜单 id=95 与 014_house_presale_menu.sql 新房预售 id=95(挂看房中心 90)ON DUPLICATE 互相覆盖、后导者赢,本地实际发生(新房预售菜单被顶掉致 /house/presale 404、生产库同样风险);改为 id=96 后发现 015_server_security.sql(并行会话,生产已部署)也用 96=安全日志(挂系统监控5),再改 **97**:95=新房预售(挂90)、96=安全日志(挂5)、97=招聘中心,三模块错开;本地按 014→015→003 重放,routes 接口 22 节点验证全部正确(e18b25f)。⚠️ 生产库需按 014→015→003 顺序重放修复(当前生产 95=招聘中心、新房预售菜单丢失)
|
||||||
|
|
||||||
|
2026-08-27 | CHG | 服务器安全日志模块:015 SQL 建 server_security_log 表 + 菜单(96安全日志/960查询/961统计);api/serversecurity/v1 三接口全 POST——open 上报 /api/service/open/security/log/report(body token+list,校验 internalToken=INTERNAL_TOKEN env)+ admin 查询 /server-security/log/list、统计 /server-security/log/stats(时间/IP/类型/端口筛选+TOP攻击源);entity/do/dao(主库)/dto/service/controller(ReportController+ManageController 双控制器);cmd.go 注入 INTERNAL_TOKEN→internalToken、open 组绑 NewReport、protected 组绑 NewManage;go build 通过,交叉编译上传 + 服务器 docker build + 容器重建(复用 env 追加 INTERNAL_TOKEN),路由/上报验证 OK(错误 token 返回 10002)
|
||||||
|
2026-08-27 | FIX | 本地登录 500 修复:根因=config.dev.yaml 的 ${RECRUITMENT_DB_DSN} 占位符在本地无对应 env(injectEnv 只回填已有变量),gdb 配置解析失败致 g.DB() 全局报 invalid link configuration;本地补建 recruitment 库(导入 001_init+002_seed_sources,6 表 5 源)+.env.dev 与 .run/service-dev.run.xml 补 RECRUITMENT_DB_DSN(两文件 gitignore 不入库);附带修复 gcron 5 段式表达式注册失败(invalid pattern "0 3 * * *")——改 6 段式 '0 0 3 * * *'/'0 0 8 * * *',此前线上每日抓取/推送 cron 实际从未执行(f81ce64);验证:重启后登录返回业务码 30002(非500)、无 cron 报错
|
||||||
|
2026-08-27 | CHG | 看房新增新房预售证模块:api/house/presale + controller/service(POST /api/service/admin/house/presale/list,筛选 region/purpose/keyword,id 倒序);dto 加 HousePresaleVO;house_presale 加 publish_date 字段(013 SQL 同步 + hack/config.yaml tables 补 house_presale + gen dao 更新);权限种子 014(菜单 95 新房预售 + 950 查询权限);go build 通过、接口返回 487 条干净数据(presaleNo/publishDate 正确)
|
||||||
|
|
||||||
|
2026-08-27 | CHG | 看房数据扩充:013_house_presale.sql 建预售证表(presale_no 唯一);house_community 加 avg_price 字段(010 SQL 同步 + gen dao 更新 entity/do 加 AvgPrice);go build 通过
|
||||||
|
2026-08-27 | CHG | 服务器日志(操作审计 admin_operation_log)全面增强为综合分页列表:① 新增字段 admin_username(冗余账号)、ip_location(IP归属地)、error_message(失败原因)、user_agent;② 自建纯 Go 的 xdb v4 离线解析器 internal/library/iploc(go:embed 嵌入 ip2region.xdb,无外部依赖,单次内存二分),审计写入时解析账号+归属地、记录 UA 与错误摘要;③ 查询筛选覆盖时间范围/账号/IP/归属地/HTTP方法/结果(成功2xx·失败非2xx)/关键字(权限码·路径·IP)/耗时上下限/排序(时间·耗时);④ 接口 POST /api/service/admin/system/log(body 入参,遵守全 POST 无参约定),dto.LogQuery/LogItem 同步;⑤ SQL:001_core.sql 建表补列 + 新增 010_server_log_enhance.sql 增量迁移(须手动导库,DB 结构变更不自动 DDL);go build 通过、iploc 单元冒烟通过
|
||||||
|
2026-08-27 | FIX | 看房中心接口异常修复:根因=后端进程是旧代码(recruitment 模块中途编译错误致项目编译不过、后端停在旧进程未加载 house 路由),重启后端加载新代码;附带修复 house listing/transaction 的 List 方法 Fields 在 Count 前设置导致 COUNT(t.*,c.name) SQL 语法错误(Fields 移到 Scan 前);7 个看房接口全部 code 0 验证通过
|
||||||
|
2026-08-27 | CFG | 新增 013_workbench_menu.sql:工作台顶层菜单(id=9, parent_id=0, path=/workbench, component=dashboard/workbench/index, permission=workbench, sort=0 置顶)+ 超管 role_id=1 绑定;前端工作台页面复用既有列表接口聚合,无新增 Go 接口
|
||||||
|
2026-08-27 | CHG | 看房成交模块:api/house/transaction + service/house/transaction + controller/house/transaction 实现成交记录 list 接口(/api/service/admin/house/transaction/list,全 POST 分页,关联小区名,按成交日期倒序),dto 加 HouseTransactionVO,cmd.go 注册;012_house_transaction_menu.sql 权限种子(house:data:transaction id=941 挂数据明细 94);go build 通过
|
||||||
|
2026-08-26 | CFG | 新增 Gitea act_runner 自动部署:.gitea/workflows/deploy.yml(push main/dev → 下载 Go1.23 工具链 npmmirror + GOPROXY goproxy.cn → CGO=0 linux/amd64 交叉编译 → 组装 main+manifest/config(config.yaml+config.prod.yaml)+resource+deploy/Dockerfile → docker build 重建容器 127.0.0.1:10100(xpcool-net,GF_GCFG_ENV=prod,DB_DSN/JWT_SECRET 走 Gitea Actions secrets)→ curl /api.json 冒烟);deploy/Dockerfile 仓库内维护;DB 结构变更不自动 DDL,需手动导 manifest/sql
|
||||||
|
2026-08-26 | FIX | 本地登录 no rows 修复:根因=本地库 service_xpcool_com 的 admin_user 空表(本地/服务器库两套,此前仅改服务器);补 super_admin 角色 + 导入 009_admin_account_v2.sql(须 --default-character-set=utf8mb4 否则中文 Data too long)→ 本地 xxcool/xxCool@2026 登录成功(subject=3);同时服务器废弃 webhook 方案已删除(deploy-webhook.service/webhook.py/secrets.env/deploy.sh),自动部署改走 Gitea act_runner + .gitea/workflows(仓库内尚未配置,待补)
|
||||||
|
2026-08-26 | CHG | 看房 house 模块后端落地:010_house_tables.sql 建 9 表(community/building/listing/price_snapshot/transaction/facility/community_facility/school_district/preference)+ 011_house_menu.sql 菜单/权限种子(超管 role_id=1 绑定);gf gen dao 生成 dao/entity/do;实现 api/house/{community,listing,dashboard} + controller/house + service/house(管理 CRUD + 看板聚合,全 POST 动作式,前缀 /api/service/admin/house,含多平台软关联 match_group_id、笋盘/低可信标记);cmd.go 注册 housectl + 3 个 RegisterService;go build 通过。坑①gf CLI 为 com.lib.gf.v2 分支,gen dao 产物 import 需 sed 回 github.com/gogf/gf/v2(18 文件);②mysql 客户端须 --default-character-set=utf8mb4 否则中文 COMMENT/INSERT 乱码;③TINYINT 字段 comment 含 0/1 被 gf 映射 bool(status 三值改 INT);hack/config.yaml 的 link+tables 已指向本地库
|
||||||
|
2026-08-26 | CHG | 生产超级管理员账号替换:删除 admin/admin123(用户/绑定/refresh 会话全清),新增 xxcool/xxCool@2026(bcrypt $2b$10$,绑定 super_admin);脚本 manifest/sql/009_admin_account_v2.sql(008 编号已被 008_menu_rbac_v4.sql 占用);004_seed.sql 种子账号同步改 xxcool;登录验证:xxcool 成功、admin 报 no rows(预期);⚠️ 线上前端仍是旧版(默认 admin/admin123),需部署新版 dist 后 xxcool 才可正常登录
|
||||||
|
2026-08-26 | CHG | RBAC 菜单调整:移除管理员管理(id=2 及按钮 21-25 软删+解绑),新增日志管理菜单(id=7, system:log, component=system/log/index),原按钮 63(system:log:list)挂到 id=7 下;超管绑定新菜单;幂等脚本 manifest/sql/008_menu_rbac_v4.sql
|
||||||
|
2026-08-26 | FIX | RBAC 安全漏洞修复:禁用角色(status=0)绑定的权限仍生效。根因为 Codes/HasPermission/Routes 三处联表查询未过滤 admin_role 启用状态,仅 roleCodes 过滤。修复:三处统一 LeftJoin admin_role 并加 r.status=1,opuser 联调验证 codes 空/routes 空/接口 403,admin 全链路回归通过
|
||||||
|
|
||||||
|
2026-08-26 | REQ | 看房方案补充「后台管理 + 可视化看板」两层:数据管理用 vben 标准列表 CRUD(查询表单 + vxe-table + 分页 + 批量,页面含小区/房源/快照/成交/配套学区/采集任务),可视化看板用全局筛选器驱动图表联动(筛选器→图表、图表交叉过滤、列表↔地图双向,状态放 Pinia);Go 接口分管理 CRUD 与看板聚合两类,统一 FilterDto 入参供管理与看板共用;echarts/vxe-table/RBAC 均复用现有依赖
|
||||||
|
2026-08-26 | REQ | 看房方案迭代:推送改 Bark(苹果,自建 Server 到腾讯云/或官方免费版,用 level/group/url/badge 参数);可视化加贵阳区域地图(底图腾讯地图 GL JS,区县边界 DataV 审图号 GeoJSON,叠加地铁线/学区划片/楼栋点/价格热力,楼栋坐标三级落地:小区中心自动→楼栋图解析→重点盘人工校准);多平台同房源「不去重、软关联对比」(推翻去重,source+source_house_id 同平台唯一,match_group_id 跨平台软关联);补充:笋盘识别/挂牌天数+调价历史/租金回报率/可负担性硬过滤/面积建面套内标准化/贵阳区域画像(观山湖新中心、地铁1/2/3号线、山地通勤)/快照长期保留1-2年
|
||||||
|
2026-08-26 | REQ | 规划「看房」功能模块(贵阳购房决策辅助,仅规划设计未开发):架构定为 service 新增 house 业务模块(复用 gf gen dao + MySQL,数据模型 9 张表 community/listing/price_snapshot/transaction/facility/community_facility/school_district/preference/score),admin 新增看房页面(echarts@6 已内置),另立独立 Python 采集分析服务(采集/清洗/去重/打分,只写库不对外 API);决策:目标城市贵阳、新房+二手房都做、先本地后上云;核心风险为贵阳网签/成交数据公开程度不如一线
|
||||||
|
2026-08-26 | DEP | 生产部署上线(193.112.118.168):交叉编译 linux/amd64,重建容器 service.xpcool.com:new(127.0.0.1:10100,GF_GCFG_ENV=prod,DB_DSN 补 mysql: 前缀,沿用 JWT_SECRET);nginx 改 HTTPS(443+80→301,证书 /data/nginx/ssl/service.xpcool.com/)反代 10100;DB service 库补种 003→004→004b→007,权限映射对齐 /api/service 路由,admin/admin123 登录验证通过
|
||||||
|
2026-08-26 | CFG | 新增 manifest/sql/007_menu_permissions_v3.sql(type=2 权限路径映射最新 /api/service 全 POST 路由,含 system:log:list id=63)与 004b_fill_seed.sql(004 中断后的幂等补齐脚本);部署要点:容器内需 config.yaml(基础配置)否则 GoFrame 启动报找不到 config
|
||||||
|
|
||||||
|
2026-08-26 | FIX | RBAC 安全漏洞修复:禁用角色(status=0)绑定的权限仍生效。根因为 Codes/HasPermission/Routes 三处联表查询未过滤 admin_role 启用状态,仅 roleCodes 过滤。修复:三处统一 LeftJoin admin_role 并加 r.status=1,opuser 联调验证 codes 空/routes 空/接口 403,admin 全链路回归通过
|
||||||
|
|
||||||
|
2026-08-26 | CFG | 修复 GoLand 直接 Run 报「找不到 config.yaml」:新增项目级运行配置 .run/service-dev.run.xml(内置 GF_GCFG_FILE=config.dev.yaml + DB_DSN + JWT_SECRET 三环境变量);.run/ 加入 .gitignore(含口令);README 补「Local startup」小节(终端 .env.dev / GoLand 运行配置两种方式)
|
||||||
|
|
||||||
|
2026-08-26 | FIX | 前端登录 502 联调:根因为服务未启动(10100 无监听);按 .env.dev 启动后 login/info/codes/menu-routes 全链路 curl 200,dev server(20100/20101/20102/20103)代理全部恢复
|
||||||
|
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md),中央工作空间迁移至 ../workbuddy.xpcool.com(相对路径索引)
|
||||||
|
|
||||||
|
2026-08-26 | CHG | 统一 API 前缀 /api/service/(admin→/api/service/admin、user→/api/service/user、open→/api/service/open);服务端口统一 10100(dev/prod/test);DB admin_menu 权限映射前缀同步;前端 vite 代理与 api 文件同步适配(全 POST + 动作式路径),路由回归 29 条一致
|
||||||
|
2026-08-26 | CHG | 遗留 /v1 全移除(URL 前缀 /admin/v1→/admin、/api/v1→/api、/api/open/v1→/api/open);全部接口改 POST(5 组 CRUD 冲突路径改动作式 /list /create /update/{id} 等);DB admin_menu 权限映射 path 同步;全项目英文注释转中文(含 dao/entity/do/table 生成模板),路由回归 29 条全 POST 一致
|
||||||
|
2026-08-26 | FIX | 修复审计落库失败:admin_operation_log.request_param 为 JSON 列,仅合法 JSON 入库、其余存 NULL(此前一直失败被「尽力而为」吞掉,系统日志查不到数据)
|
||||||
|
2026-08-26 | CHG | 区分系统日志与服务器日志:审计服务由 admin/system/audit 归位为 admin/system/log(package admin_system_log,含 Record+List),新增 GET /admin/v1/system/log 分页查询 + system:log:list 权限映射(菜单 id=63);服务器日志保持 admin/base/log
|
||||||
|
2026-08-26 | CHG | login 资源迁移至 admin/admin/login(api+service),全 api/service 包名统一为「路径下划线拼接」(package admin_admin_login / admin_system_menu / user_auth 式);恢复 api/open 并规范化(去 v1,包名 open_tools_ip 式);controller/cmd 引用同步,路由回归 28 条一致
|
||||||
|
2026-08-26 | REQ | 后台登录接口联调通过:login/info/codes/menu-routes/RBAC/refresh(单次)/logout 全链路 curl 验证 OK(库 service_xpcool_com)
|
||||||
|
2026-08-26 | FIX | 联调发现并修复 admin refresh 单次使用漏洞:Refresh 撤销旧会话后补 RowsAffected 校验,重复使用旧 refreshToken 返回 code 10002
|
||||||
|
2026-08-26 | CFG | 新建 .env.dev 本地配置(DB_DSN=mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com?loc=Local + JWT_SECRET),.gitignore 追加 .env* 防口令入库
|
||||||
|
2026-08-26 | CHG | 清理模板:删除 api/hello、api/open、common/tools 及 controller/hello、controller/open;API 目录去掉 v1 层(api/admin/system/auth/auth.go 式),service 层完全镜像;URL 前缀保持 /admin/v1 兼容前端
|
||||||
|
2026-08-26 | CHG | 目录重构:API 层资源目录化(api/admin/v1/system/auth/auth.go 式);service 层严格镜像 API 多级结构(internal/service/admin/system/auth/auth.go 式),接口随实现归位、admin_rbac 拆为 admin/role/menu_manage 三包,controller/cmd 引用同步,路由回归 29 条一致
|
||||||
|
2026-08-26 | CHG | 补全管理端登录接口:新增 Refresh/Logout 端点(/admin/v1/system/auth/refresh|logout),登录改走 issue 把刷新令牌 JTI 落库,与 user 端刷新撤旧机制一致
|
||||||
|
2026-08-26 | CHG | 同步三铁律(中文注释/做记录/中文优先)至 AGENTS.md「统一工作约定」小节
|
||||||
|
2026-08-26 | CFG | 建立统一变更记录机制(CHANGELOG),接入 xpcool.com 中央索引
|
||||||
126
AGENTS.md
Normal file
126
AGENTS.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# AGENTS.md — 项目智能体说明书
|
||||||
|
|
||||||
|
> 本文件是给所有 AI 编程助手(CodeBuddy / WorkBuddy / Claude Code / Codex / Cursor 等)看的项目级上下文。
|
||||||
|
> 任何账号 clone 本仓库后,助手都应先读本文件与 `docs/change-log/` 下最近的记录,即可无缝衔接。
|
||||||
|
> 请保持本文件**长期稳定**:只写「架构、规范、约定」,不要写一次性事项。
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
`service.xpcool.com`:个人多客户端(mini / h5 / app)后端服务,GoFrame v2 单体应用。
|
||||||
|
核心业务:用户认证(JWT)、内容、收藏、站内消息;后台管理(RBAC + 操作审计)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 语言 | Go 1.23.0 |
|
||||||
|
| 框架 | github.com/gogf/gf/v2 v2.10.2 |
|
||||||
|
| 数据库 | MySQL(ORM 由 gf 生成 dao/do/entity) |
|
||||||
|
| 认证 | JWT(`internal/library/jwt`),admin 另有 `X-Permission` 校验 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
service.xpcool.com/
|
||||||
|
├── api/ # HTTP 契约与 Swagger 元数据
|
||||||
|
│ ├── user/v1/ # /api/v1 用户端 API(登录公开,其余走 UserAuth)
|
||||||
|
│ ├── admin/v1/ # /admin/v1 管理端 API(AdminAuth + X-Permission)
|
||||||
|
│ └── open/v1/ # /api/open/v1 开放接口(给前端调用,公开无鉴权)
|
||||||
|
│ └── tools/ # 工具子功能契约,每子功能一目录:tools/<name>/<name>.go
|
||||||
|
├── common/ # 公共可复用模块(不依赖 internal,可独立抽取成库)
|
||||||
|
│ └── tools/ # 工具模块,按子功能分包(见下文)
|
||||||
|
├── internal/
|
||||||
|
│ ├── cmd/ # 启动引导(路由分组注册在此)
|
||||||
|
│ ├── consts/ # 错误码与常量
|
||||||
|
│ ├── controller/ # API→service 适配层(不写业务;含 open/ 开放接口实现)
|
||||||
|
│ ├── service/ # 领域用例(业务逻辑直接写这里,不用 logic/)
|
||||||
|
│ ├── dao/ # gf gen dao 生成,禁止手改
|
||||||
|
│ ├── model/ # entity/ do/ dto/ vo/(entity、do 生成,禁止手改)
|
||||||
|
│ ├── middleware/ # 路由中间件
|
||||||
|
│ ├── library/ # jwt / page / response 等内部基础件
|
||||||
|
│ └── table/ # 表列名常量
|
||||||
|
├── manifest/ # config.dev/test/prod.yaml、sql 迁移
|
||||||
|
├── docs/change-log/ # 每次请求与变更的记录(重要!见「上下文记忆」)
|
||||||
|
└── utility/ # (预留)跨切面辅助
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后台管理 API(api/admin/v1,按 base/system/admin 分组)
|
||||||
|
|
||||||
|
- **分组规则**:`api/admin/v1/{base,system,admin}/` 三个子包,路由前缀 `/admin/v1/{base,system,admin}`:
|
||||||
|
- **base**(基础常规):`/base/log/*`(服务器日志监控)
|
||||||
|
- **system**(系统管理):`/system/auth/*`(登录/信息/权限码)、`/system/menu/*`(菜单路由+CRUD)、`/system/role/*`(角色 CRUD)
|
||||||
|
- **admin**(后台管理):`/admin`(管理员账号 CRUD)
|
||||||
|
- 三层路由隔离(`internal/cmd/cmd.go`):
|
||||||
|
- **公开**(无鉴权):`POST /system/auth/login`
|
||||||
|
- **仅登录** `AdminAuthOnly`:`GET /system/auth/info`、`GET /system/auth/codes`、`GET /system/menu/routes`
|
||||||
|
- **接口级鉴权** `AdminAuth`:RBAC 管理、日志等
|
||||||
|
- **接口鉴权机制(重要)**:中间件按「请求方法+路径」从 `admin_menu`(type=2 行,path 存 `"METHOD /路径"`,`{id}` 为动态段)反查所需权限码,再校验用户是否拥有。**前端无需传 X-Permission**;未配置映射的接口一律拒绝。
|
||||||
|
- 权限码(permission)与路由分离:权限码保持 `system:admin:list` 等逻辑标识,路由路径按 base/system/admin 分组。
|
||||||
|
- 新增受保护接口三步:① `api/admin/v1/{分组}/<xxx>.go` 写 Req/Res;② `internal/controller/admin/<xxx>.go` 加方法;③ 在 `admin_menu` 加 type=2 行:`permission` 填权限码、`path` 填 `"METHOD /路径"` 映射。
|
||||||
|
- 迁移脚本:`003_schema_ext.sql`(admin_menu 加列)、`004_seed.sql`(初始账号/角色/菜单)、`005_menu_paths.sql`、`006_menu_paths_v2.sql`(按钮-接口路径映射,006 为分组重构后)。
|
||||||
|
|
||||||
|
## 开放接口(api/open/v1,前端调用)与命名规则
|
||||||
|
|
||||||
|
- **命名决策**:公共接口前缀用 **open**(不用 common)。理由:`common` 语义偏"内部公共代码",`open` 是开放接口业界惯例(支付宝 /open/api 等),更能表达"对外暴露、无鉴权"。同属"公开"语义的备选还有 `public`。
|
||||||
|
- 路由前缀 `/api/open/v1`,**公开、无鉴权**,实现于 `internal/controller/open`。
|
||||||
|
- **tools 子功能目录规则**:`api/open/v1/tools/<子功能名>/<子功能名>.go` 定义该子功能的 Req/Res 契约(目录名=包名=文件名三一致);控制器 `internal/controller/open/<子功能名>.go` 放对应方法(controller 统一 `package open`)。子功能变大后按端点/子领域在目录内**加文件**(如 ocr 目录下 `ocr.go` → `ocr.go + idcard.go + invoice.go`),不要堆在一个文件里。
|
||||||
|
- 当前端点:`GET/POST /tools/*`(uuid、md5、random、time、ip),底层复用 `common/tools` Go 包。
|
||||||
|
- **新增子功能三步**:① `api/open/v1/tools/<name>/<name>.go` 写 Req/Res(g.Meta 带 path/method);② `internal/controller/open/<name>.go` 加方法;③ 路由自动绑定,无需改 cmd.go。
|
||||||
|
|
||||||
|
## 公共工具模块 common/tools
|
||||||
|
|
||||||
|
- 规则:**不依赖 internal/**,只薄封装 GoFrame 内置组件;新工具优先复用内置(gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...),避免重复造轮子。
|
||||||
|
- 子功能:`md5`、`cryptox`(AES/DES)、`uuid`、`random`、`timex`、`convertx`、`strx`、`slicex`、`ip`、`filex`。
|
||||||
|
- 新增子功能:在 `common/tools/` 下建子包,更新 `common/tools/doc.go` 的布局清单。
|
||||||
|
|
||||||
|
## 分层与调用规范(必须遵守)
|
||||||
|
|
||||||
|
1. 调用链:`controller → service → dao → model(do)`;controller 不碰 dao。
|
||||||
|
2. DTO/VO 边界:跨层出入参走 `internal/model/dto` 与 `internal/model/vo`,API 类型与 entity 不得越界。
|
||||||
|
3. **数据库操作必须用 DO 对象**(`internal/model/do`),禁止 `g.Map`;未赋值字段保持 nil 自动忽略:
|
||||||
|
```go
|
||||||
|
dao.Users.Ctx(ctx).Where(cols.Id, id).Data(do.User{Uid: uid}).Update()
|
||||||
|
```
|
||||||
|
4. **时间字段自动维护**:`created_at/updated_at/deleted_at` 由 ORM 自动处理,禁止手动赋值;软删除用 `Delete()`,禁止手写 `WhereNull(cols.DeletedAt)`。
|
||||||
|
5. **错误处理一律用 gerror**(保留堆栈);响应统一走 `internal/library/response`。
|
||||||
|
6. 生成代码(dao/do/entity)**禁止手改**,改表后跑 `gf gen dao` 重新生成。
|
||||||
|
7. 声明 ≥3 个相关变量时,用 `var (...)` 块对齐。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行(dev)
|
||||||
|
GF_GCFG_FILE=config.dev.yaml DB_DSN="user:pass@tcp(127.0.0.1:3306)/db?loc=Local" JWT_SECRET=xxx go run main.go
|
||||||
|
# 数据库模型生成(唯一来源)
|
||||||
|
gf gen dao -p internal -g default -gt -c
|
||||||
|
# 构建 / 测试
|
||||||
|
go build ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上下文记忆(重要)
|
||||||
|
|
||||||
|
三层配合,保证「换个账号/换台机器也能无缝衔接」:
|
||||||
|
|
||||||
|
1. **本文件(AGENTS.md)**:长期稳定的架构与规范。
|
||||||
|
2. **`docs/change-log/YYYY-MM-DD.md`**:每次对话的「请求 + 变更 + 决策」记录,随 git 提交。
|
||||||
|
- 每次完成任务后,若 `docs/change-log/` 已有当日文件则**追加**,否则新建;
|
||||||
|
- 格式固定:`## 请求` / `## 变更`(含文件清单)/ `## 决策与理由` / `## 待办与风险`;
|
||||||
|
- 助手开工前先读最近 1-2 篇,快速恢复上下文。
|
||||||
|
3. **`.workbuddy/memory/`**:WorkBuddy 桌面端本机记忆(每日日志 + MEMORY.md),**已加入 .gitignore,不入库**,仅本机增强。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- ⚠️ **gtime v2.10.2 格式化**:`Time.Format("Y-m-d H:i:s")` 是 PHP 风格;传 Go layout(`2006-01-02`)要用 `Time.Layout(...)`。工具包 `common/tools/timex` 已统一封装。
|
||||||
|
- ⚠️ **gf v2.10.2 包名与旧版不同**:AES/DES 在 `crypto/gaes`、`crypto/gdes`(无 gcrypto);UUID 在 `util/guid`(无 guuid);无 gslicer(用标准库 slices)。
|
||||||
|
- 配置文件按 `GF_GCFG_FILE` 切换;`manifest/config/config.yaml` 不入库。
|
||||||
|
- 生产环境强密码:`JWT_SECRET`、数据库口令。
|
||||||
|
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释。
|
||||||
|
|
||||||
|
## 统一工作约定(xpcool.com 中央规则)
|
||||||
|
|
||||||
|
> 与中央 `../workbuddy.xpcool.com/.workbuddy/memory/MEMORY.md` 保持一致;本项目变更流水在 `.workbuddy/memory/CHANGELOG.md`。
|
||||||
|
|
||||||
|
1. **中文注释**:写/改代码时,在应有处(函数、复杂逻辑、配置项、非显然分支)加中文注释。
|
||||||
|
2. **做记录**:每次请求/变更/修复/文档动作,都在本项目 `.workbuddy/memory/CHANGELOG.md` 顶部追加一条,格式 `YYYY-MM-DD | 类型 | 一句话摘要`(类型:REQ/CHG/FIX/DOC/CFG/DEP)。
|
||||||
|
3. **中文优先**:与用户的思考、输出、交流,能中文尽量中文。
|
||||||
@ -1,27 +1,55 @@
|
|||||||
# Production layout
|
# 生产目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
service.xpcool.com/
|
service.xpcool.com/
|
||||||
├── api/ # HTTP contracts and Swagger metadata
|
├── api/ # HTTP 契约与 Swagger 元数据(按业务分组)
|
||||||
│ ├── user/v1/ # /api/v1 - client-facing API
|
│ ├── admin/ # /api/service/admin - 管理端 API(AdminAuth + 权限码)
|
||||||
│ └── admin/v1/ # /admin/v1 - administration API
|
│ │ ├── admin/ # 管理员账号 CRUD、登录/资料/权限码/刷新/登出
|
||||||
|
│ │ └── system/ # 登录日志、菜单路由、菜单管理、角色
|
||||||
|
│ ├── house/ # /api/service/admin - 看房模块(小区/房源/看板/成交/预售证)
|
||||||
|
│ ├── recruitment/ # /api/service/admin - 招聘考试聚合模块
|
||||||
|
│ ├── notice/ # /api/service/admin - 站内通知(渠道/规则/日志)
|
||||||
|
│ ├── job/ # /api/service/admin - 自动任务(任务/日志)
|
||||||
|
│ ├── serversecurity/ # 服务器安全日志(open 上报 + admin 查询/统计)
|
||||||
|
│ ├── user/ # /api/service/user - 用户端认证(登录/刷新)
|
||||||
|
│ └── open/ # /api/service/open - 开放接口(公开、无鉴权)
|
||||||
|
│ └── tools/ # 工具子功能契约,每子功能一目录:tools/<name>/<name>.go
|
||||||
|
├── common/ # 公共可复用模块(不依赖 internal/)
|
||||||
|
│ └── tools/ # 工具集:md5、cryptox、uuid、random、timex、
|
||||||
|
│ # convertx、strx、slicex、ip、filex
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── cmd/ # application bootstrap and route isolation
|
│ ├── cmd/ # 启动引导、环境变量注入、路由分组注册
|
||||||
│ ├── consts/ # application error codes and constants
|
│ ├── consts/ # 业务错误码与常量
|
||||||
│ ├── controller/ # API-to-service adapters only
|
│ ├── controller/ # API→service 适配层(不写业务;含 open/ 开放接口实现)
|
||||||
│ ├── service/ # domain use cases and provider interfaces
|
│ ├── service/ # 领域用例(业务逻辑直接写这里,不用 logic/)
|
||||||
│ ├── dao/ # generated by gf gen dao; never hand edited
|
│ ├── dao/ # 数据访问(gf gen dao 生成 + 部分手写,禁止手改生成物)
|
||||||
|
│ │ └── internal/ # 生成代码内部实现
|
||||||
│ ├── model/
|
│ ├── model/
|
||||||
│ │ ├── entity/ # generated database entities
|
│ │ ├── entity/ # 数据库实体(生成 + 部分手写)
|
||||||
│ │ ├── do/ # generated Data Objects
|
│ │ ├── do/ # Data Object(写库必须用 DO)
|
||||||
│ │ ├── dto/ # service boundary input/output
|
│ │ ├── dto/ # 服务边界入参/出参
|
||||||
│ │ └── vo/ # API view models
|
│ │ └── vo/ # API 视图模型
|
||||||
│ ├── middleware/ # configurable route-group middleware
|
│ ├── middleware/ # 路由中间件:Recover、CORS、认证、全量加密
|
||||||
│ └── library/ # JWT, response, pagination primitives
|
│ ├── library/ # jwt / crypto(RSA+AES) / iploc / page / response 基础件
|
||||||
|
│ └── table/ # 表列名常量(手写维护,供代码引用列名)
|
||||||
|
├── docs/
|
||||||
|
│ ├── house-system-design.md # 看房系统总设计文档
|
||||||
|
│ └── change-log/ # 每次请求与变更的记录(AI 上下文记忆,随 git 提交)
|
||||||
├── manifest/
|
├── manifest/
|
||||||
│ ├── config/ # config.dev/test/prod.yaml
|
│ ├── config/ # config.dev/test/prod.yaml(config.yaml 不入库)
|
||||||
│ └── sql/ # ordered MySQL migrations
|
│ ├── sql/ # 有序 MySQL 迁移脚本(含 recruitment/ 子模块)
|
||||||
└── utility/ # optional cross-cutting helpers
|
│ ├── deploy/ # kustomize 部署清单
|
||||||
|
│ ├── docker/ # Docker 构建上下文
|
||||||
|
│ ├── i18n/ # 国际化资源
|
||||||
|
│ └── protobuf/ # protobuf 定义
|
||||||
|
├── deploy/
|
||||||
|
│ └── Dockerfile # 生产容器镜像(alpine 最小运行时)
|
||||||
|
├── hack/ # gf CLI 配置与 Makefile 片段
|
||||||
|
└── utility/ # (预留)跨切面辅助
|
||||||
```
|
```
|
||||||
|
|
||||||
`dao`, `model/do` and `model/entity` are generated after migration, so their schema never drifts from MySQL. Controllers do not access DAO; only services do.
|
`dao`、`model/do`、`model/entity` 由迁移脚本生成,保证与 MySQL 结构不漂移;
|
||||||
|
但招聘 / 通知 / 自动任务 / 服务器安全日志等模块的部分结构为手写,改动时勿被生成命令覆盖。
|
||||||
|
控制器不访问 DAO,只有 service 可以。
|
||||||
|
|
||||||
|
面向 AI 助手的约定与上下文记忆体系见 [`AGENTS.md`](./AGENTS.md)。
|
||||||
|
|||||||
62
README.MD
62
README.MD
@ -1,22 +1,64 @@
|
|||||||
# Personal multi-client service
|
# service.xpcool.com — 个人多客户端后端服务
|
||||||
|
|
||||||
## Layout
|
GoFrame v2 单体应用,为 mini / h5 / app 多端提供后端服务,并附带完整的后台管理体系。
|
||||||
|
|
||||||
`api → internal/controller → internal/service → internal/dao → internal/model(entity/do)` is enforced. DTO and VO live in `internal/model/dto` and `internal/model/vo`; neither API types nor entities cross that boundary.
|
核心业务:用户认证(JWT)、看房数据(小区/房源/成交/预售证)、招聘考试聚合、
|
||||||
|
站内通知、服务器安全日志;后台管理(RBAC 菜单权限 + 操作审计)。
|
||||||
|
|
||||||
## Database model generation
|
> 面向 AI 助手的项目上下文见 [`AGENTS.md`](./AGENTS.md);每次变更的结构化记录见
|
||||||
|
> [`docs/change-log/`](./docs/change-log/);目录树见 [`PROJECT_STRUCTURE.md`](./PROJECT_STRUCTURE.md)。
|
||||||
|
|
||||||
Run `manifest/sql/001_core.sql` on MySQL, set `DB_DSN`, then run:
|
## 分层约定
|
||||||
|
|
||||||
|
调用链强制为 `api → internal/controller → internal/service → internal/dao → internal/model(entity/do)`。
|
||||||
|
|
||||||
|
- `controller` 只做 API 到 service 的适配,**不碰 dao**;
|
||||||
|
- 跨层出入参走 `internal/model/dto` 与 `internal/model/vo`,API 类型与 entity 不得越界;
|
||||||
|
- 数据库操作必须用 DO 对象(`internal/model/do`),禁止 `g.Map`;
|
||||||
|
- 错误一律用 `gerror`(保留堆栈),响应统一走 `internal/library/response`。
|
||||||
|
|
||||||
|
## 路由分组
|
||||||
|
|
||||||
|
三组路由在 `internal/cmd/cmd.go` 中注册:
|
||||||
|
|
||||||
|
| 前缀 | 用途 | 鉴权 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/service/open` | 开放接口(前端直接调用) | 公开,无鉴权 |
|
||||||
|
| `/api/service/user` | 用户端接口 | 登录公开,其余走 `UserAuth` |
|
||||||
|
| `/api/service/admin` | 管理端接口 | `AdminAuth` + 按「方法+路径」自动匹配权限码 |
|
||||||
|
|
||||||
|
管理端权限由后端从 `admin_menu`(type=2 行,`path` 存 `"METHOD /路径"`)反查,
|
||||||
|
**前端无需传 `X-Permission`**;未配置映射的接口一律拒绝。
|
||||||
|
|
||||||
|
## 数据库模型生成
|
||||||
|
|
||||||
|
先在 MySQL 上执行 `manifest/sql/` 下的迁移脚本,再设置 `DB_DSN`,然后运行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gf gen dao -p internal -g default -gt -c
|
gf gen dao -p internal -g default -gt -c
|
||||||
```
|
```
|
||||||
|
|
||||||
The command is intentionally the only source of `internal/dao`, `internal/model/do`, and `internal/model/entity`.
|
该命令是 `internal/dao`、`internal/model/do`、`internal/model/entity` 的**唯一来源**,生成物禁止手改。
|
||||||
|
|
||||||
## API isolation
|
注意:部分模块(招聘、通知、自动任务、服务器安全日志)的表结构为**手写** entity/do/dao,
|
||||||
|
未走 `gf gen`,改动时请勿用生成命令覆盖。
|
||||||
|
|
||||||
- `/api/v1/*`: user API, terminal header/body supports `mini`, `h5`, and `app`.
|
## 本地启动
|
||||||
- `/admin/v1/*`: admin API. Protected endpoints require both an admin access token and an `X-Permission` identifier.
|
|
||||||
|
|
||||||
Use `GF_GCFG_FILE=config.dev.yaml` (or `config.test.yaml` / `config.prod.yaml`) and set `DB_DSN` plus a strong `JWT_SECRET` before startup.
|
环境变量按 `GF_GCFG_FILE` 切换配置文件(`config.dev.yaml` / `config.test.yaml` / `config.prod.yaml`)。
|
||||||
|
|
||||||
|
- **终端**:`source .env.dev && go run main.go`
|
||||||
|
(`.env.dev` 不入库,保存本地 DSN 与密钥)。
|
||||||
|
- **GoLand**:运行配置 `.run/service-dev.run.xml`(项目级,重载项目后自动识别)
|
||||||
|
已注入 `GF_GCFG_FILE` / `DB_DSN` / `JWT_SECRET`,在运行下拉框中选择即可。
|
||||||
|
该文件含数据库口令,已加入 `.gitignore`。
|
||||||
|
|
||||||
|
开发环境下,若 `${DB_DSN}` / `${RECRUITMENT_DB_DSN}` / `${JWT_SECRET}` 占位符未被环境变量注入,
|
||||||
|
`injectEnv` 会回填本地默认值,保证「零配置可启动」;生产(`GF_GCFG_ENV=prod`)不兜底,配置缺失显性报错。
|
||||||
|
|
||||||
|
## 构建与测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go build ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|||||||
88
api/admin/admin/admin/admin.go
Normal file
88
api/admin/admin/admin/admin.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// Package admin_admin_admin 定义后台管理接口(管理员账号管理),路由前缀 /admin。
|
||||||
|
package admin_admin_admin
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// AdminItem 管理员列表中的一行。
|
||||||
|
type AdminItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
BarkDeviceId string `json:"barkDeviceId"`
|
||||||
|
PushplusToken string `json:"pushplusToken"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
RoleNames []string `json:"roleNames"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListReq 分页查询管理员。
|
||||||
|
type AdminListReq struct {
|
||||||
|
g.Meta `path:"/admin/list" method:"post" tags:"Admin/Admin" summary:"管理员列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // 匹配用户名或昵称
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListRes 是 AdminListReq 的响应。
|
||||||
|
type AdminListRes struct {
|
||||||
|
List []*AdminItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateReq 创建管理员。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制),
|
||||||
|
// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。
|
||||||
|
type AdminCreateReq struct {
|
||||||
|
g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"`
|
||||||
|
Username string `json:"username" v:"required"`
|
||||||
|
Password string `json:"password" v:"min-length:6#密码长度不能少于 6 位"`
|
||||||
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
|
EncryptedData string `json:"encryptedData"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
BarkDeviceId string `json:"barkDeviceId"`
|
||||||
|
PushplusToken string `json:"pushplusToken"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateRes 是 AdminCreateReq 的响应。
|
||||||
|
type AdminCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateReq 更新管理员的资料、状态与角色。
|
||||||
|
type AdminUpdateReq struct {
|
||||||
|
g.Meta `path:"/admin/update/{id}" method:"post" tags:"Admin/Admin" summary:"更新管理员"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
BarkDeviceId string `json:"barkDeviceId"`
|
||||||
|
PushplusToken string `json:"pushplusToken"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateRes 是 AdminUpdateReq 的响应。
|
||||||
|
type AdminUpdateRes struct{}
|
||||||
|
|
||||||
|
// AdminResetPwdReq 重置管理员密码。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制),
|
||||||
|
// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。
|
||||||
|
type AdminResetPwdReq struct {
|
||||||
|
g.Meta `path:"/admin/resetPwd/{id}" method:"post" tags:"Admin/Admin" summary:"重置管理员密码"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Password string `json:"password" v:"min-length:6#密码长度不能少于 6 位"`
|
||||||
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
|
EncryptedData string `json:"encryptedData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPwdRes 是 AdminResetPwdReq 的响应。
|
||||||
|
type AdminResetPwdRes struct{}
|
||||||
|
|
||||||
|
// AdminDeleteReq 删除管理员(软删除)。
|
||||||
|
type AdminDeleteReq struct {
|
||||||
|
g.Meta `path:"/admin/delete/{id}" method:"post" tags:"Admin/Admin" summary:"删除管理员"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteRes 是 AdminDeleteReq 的响应。
|
||||||
|
type AdminDeleteRes struct{}
|
||||||
79
api/admin/admin/login/login.go
Normal file
79
api/admin/admin/login/login.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Package admin_admin_login 定义管理端认证接口(登录/资料/权限码/刷新/登出),路由前缀 /admin。
|
||||||
|
package admin_admin_login
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LoginReq 管理员登录请求。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输(encryptedKey/encryptedData 必填,生产强制):
|
||||||
|
// - encryptedKey:RSA-OAEP 加密的 AES-256 会话密钥(base64)
|
||||||
|
// - encryptedData:AES-GCM 加密的明文载荷 {username,password,ts}(base64,nonce 前缀)
|
||||||
|
//
|
||||||
|
// username/password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。
|
||||||
|
type LoginReq struct {
|
||||||
|
g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"管理员登录"`
|
||||||
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
|
EncryptedData string `json:"encryptedData"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRes 管理员登录响应(令牌对 + 管理员 ID)。
|
||||||
|
type LoginRes struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
AdminID uint64 `json:"adminId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyReq 获取登录加密公钥(RSA 公钥 PEM,前端用于混合加密密码)。公开接口,免鉴权。
|
||||||
|
type PublicKeyReq struct {
|
||||||
|
g.Meta `path:"/system/auth/public-key" method:"post" tags:"Admin/System/Auth" summary:"获取登录加密公钥"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyRes 是 PublicKeyReq 的响应。
|
||||||
|
type PublicKeyRes struct {
|
||||||
|
// PublicKey PEM 格式 RSA 公钥(-----BEGIN PUBLIC KEY-----)。
|
||||||
|
PublicKey string `json:"publicKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoReq 获取当前管理员资料(供 vben getUserInfo 使用)。
|
||||||
|
type InfoReq struct {
|
||||||
|
g.Meta `path:"/system/auth/info" method:"post" tags:"Admin/System/Auth" summary:"当前管理员资料"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoRes 是 InfoReq 的响应,Roles 携带角色码供 vben 权限使用。
|
||||||
|
type InfoRes struct {
|
||||||
|
AdminID uint64 `json:"adminId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodesReq 获取当前管理员的按钮级权限码(vben getAccessCodes,后端鉴权模式)。
|
||||||
|
type CodesReq struct {
|
||||||
|
g.Meta `path:"/system/auth/codes" method:"post" tags:"Admin/System/Auth" summary:"当前管理员权限码"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodesRes 是 CodesReq 的响应。
|
||||||
|
type CodesRes struct {
|
||||||
|
Codes []string `json:"codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshReq 使用刷新令牌轮换管理员令牌对。
|
||||||
|
type RefreshReq struct {
|
||||||
|
g.Meta `path:"/system/auth/refresh" method:"post" tags:"Admin/System/Auth" summary:"轮换管理员令牌"`
|
||||||
|
RefreshToken string `json:"refreshToken" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshRes 是 RefreshReq 的响应。
|
||||||
|
type RefreshRes LoginRes
|
||||||
|
|
||||||
|
// LogoutReq 结束管理员会话。refreshToken 可选携带:
|
||||||
|
// 传入时撤销对应刷新会话,阻止该设备继续续期(幂等)。
|
||||||
|
type LogoutReq struct {
|
||||||
|
g.Meta `path:"/system/auth/logout" method:"post" tags:"Admin/System/Auth" summary:"管理员登出"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutRes 是 LogoutReq 的响应。
|
||||||
|
type LogoutRes struct{}
|
||||||
40
api/admin/base/log/log.go
Normal file
40
api/admin/base/log/log.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Package admin_base_log 定义服务器日志监控接口契约。
|
||||||
|
//
|
||||||
|
// 路由前缀 /api/service/admin/base/log,对应 admin_menu 中的:
|
||||||
|
// - POST /api/service/admin/base/log/files —— monitor:log:view(查看日志文件列表)
|
||||||
|
// - POST /api/service/admin/base/log/tail —— monitor:log:tail(实时读取日志尾部)
|
||||||
|
package admin_base_log
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LogFile 描述一个服务器日志文件。
|
||||||
|
type LogFile struct {
|
||||||
|
Name string `json:"name"` // 文件名
|
||||||
|
Path string `json:"path"` // 文件绝对路径
|
||||||
|
Size int64 `json:"size"` // 文件大小(字节)
|
||||||
|
ModTime string `json:"modTime"` // 最后修改时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesReq 列出服务器日志文件。
|
||||||
|
type LogFilesReq struct {
|
||||||
|
g.Meta `path:"/base/log/files" method:"post" tags:"Admin/Base/Log" summary:"日志文件列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesRes 是 LogFilesReq 的响应。
|
||||||
|
type LogFilesRes struct {
|
||||||
|
Dir string `json:"dir"` // 日志目录
|
||||||
|
Files []*LogFile `json:"files"` // 文件列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailReq 读取日志文件尾部,支持关键字过滤。
|
||||||
|
type LogTailReq struct {
|
||||||
|
g.Meta `path:"/base/log/tail" method:"post" tags:"Admin/Base/Log" summary:"读取日志尾部"`
|
||||||
|
File string `json:"file" v:"required#日志文件名不能为空"` // 日志文件名(不含目录)
|
||||||
|
Lines int `json:"lines" d:"200" v:"min:1|max:5000#行数必须在 1-5000 之间"`
|
||||||
|
Keyword string `json:"keyword"` // 关键字过滤,为空则不过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailRes 是 LogTailReq 的响应。
|
||||||
|
type LogTailRes struct {
|
||||||
|
Lines []string `json:"lines"` // 日志行
|
||||||
|
}
|
||||||
51
api/admin/system/log/log.go
Normal file
51
api/admin/system/log/log.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Package admin_system_log 定义管理员操作日志(操作审计)查询接口契约。
|
||||||
|
//
|
||||||
|
// 路由前缀 /api/service/admin/system/log,对应 admin_menu:
|
||||||
|
// - POST /api/service/admin/system/log —— system:log:list(操作日志分页查询)
|
||||||
|
package admin_system_log
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LogItem 一条管理员操作日志记录。
|
||||||
|
type LogItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
AdminID uint64 `json:"adminId"` // 操作人ID
|
||||||
|
AdminUsername string `json:"adminUsername"` // 操作人账号(冗余)
|
||||||
|
Permission string `json:"permission"` // 命中的权限码
|
||||||
|
Method string `json:"method"` // HTTP 方法
|
||||||
|
Path string `json:"path"` // 请求路径
|
||||||
|
IP string `json:"ip"` // 来源 IP
|
||||||
|
IpLocation string `json:"ipLocation"` // IP 归属地
|
||||||
|
Param string `json:"param"` // 请求参数
|
||||||
|
DurationMS uint `json:"durationMs"` // 耗时(毫秒)
|
||||||
|
StatusCode int `json:"statusCode"` // HTTP 状态码
|
||||||
|
ErrorMessage string `json:"errorMessage"` // 失败原因
|
||||||
|
UserAgent string `json:"userAgent"` // 浏览器 UA
|
||||||
|
CreatedAt string `json:"createdAt"` // 操作时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogListReq 分页查询管理员操作日志。
|
||||||
|
type LogListReq struct {
|
||||||
|
g.Meta `path:"/system/log" method:"post" tags:"Admin/System/Log" summary:"操作日志列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
AdminID uint64 `json:"adminId"` // 按管理员ID精确过滤
|
||||||
|
Username string `json:"username"` // 按管理员账号模糊过滤
|
||||||
|
IP string `json:"ip"` // 按来源 IP 模糊过滤
|
||||||
|
IpLocation string `json:"ipLocation"` // 按 IP 归属地模糊过滤
|
||||||
|
Method string `json:"method"` // 按 HTTP 方法精确过滤
|
||||||
|
Status int `json:"status"` // 结果筛选:0 全部 / 1 成功 / 2 失败
|
||||||
|
Keyword string `json:"keyword"` // 关键字:匹配 permission / path / ip
|
||||||
|
StartTime string `json:"startTime"` // 起始时间,格式 2006-01-02 15:04:05
|
||||||
|
EndTime string `json:"endTime"` // 结束时间,格式 2006-01-02 15:04:05
|
||||||
|
MinDuration int `json:"minDuration"` // 耗时下限(ms),0 不限
|
||||||
|
MaxDuration int `json:"maxDuration"` // 耗时上限(ms),0 不限
|
||||||
|
OrderBy string `json:"orderBy"` // 排序字段:createdAt(默认) | durationMs
|
||||||
|
OrderDir string `json:"orderDir"` // 排序方向:desc(默认) | asc
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogListRes 是 LogListReq 的响应。
|
||||||
|
type LogListRes struct {
|
||||||
|
List []*LogItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
30
api/admin/system/login_log/login_log.go
Normal file
30
api/admin/system/login_log/login_log.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Package admin_system_login_log 定义管理员登录日志查询接口,路由前缀 /admin。
|
||||||
|
package admin_system_login_log
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LoginLogItem 一条管理员登录日志记录。
|
||||||
|
type LoginLogItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
UserAgent string `json:"userAgent"`
|
||||||
|
Status int `json:"status"` // 1 成功, 0 失败
|
||||||
|
FailReason string `json:"failReason"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginLogListReq 分页查询管理员登录日志。
|
||||||
|
type LoginLogListReq struct {
|
||||||
|
g.Meta `path:"/system/login-log" method:"post" tags:"Admin/System/LoginLog" summary:"登录日志列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Username string `json:"username"` // 按账号过滤
|
||||||
|
Status *int `json:"status"` // 按结果过滤:1 成功 0 失败,不传为全部
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginLogListRes 是 LoginLogListReq 的响应。
|
||||||
|
type LoginLogListRes struct {
|
||||||
|
List []*LoginLogItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
33
api/admin/system/menu/menu.go
Normal file
33
api/admin/system/menu/menu.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Package admin_system_menu 定义菜单路由接口(当前管理员可见菜单树),路由前缀 /admin。
|
||||||
|
package admin_system_menu
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RouteMeta 与 vben 后台动态路由元数据对应。
|
||||||
|
type RouteMeta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Authority []string `json:"authority,omitempty"` // 角色码
|
||||||
|
HideInMenu bool `json:"hideInMenu,omitempty"`
|
||||||
|
KeepAlive bool `json:"keepAlive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteItem vben 路由树中的一个节点。
|
||||||
|
type RouteItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component,omitempty"`
|
||||||
|
Meta RouteMeta `json:"meta"`
|
||||||
|
Children []*RouteItem `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuRoutesReq 获取当前管理员的菜单树(后端鉴权模式,登录后即可访问)。
|
||||||
|
type MenuRoutesReq struct {
|
||||||
|
g.Meta `path:"/system/menu/routes" method:"post" tags:"Admin/System/Menu" summary:"当前管理员菜单路由"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuRoutesRes 是 MenuRoutesReq 的响应。
|
||||||
|
type MenuRoutesRes struct {
|
||||||
|
Routes []*RouteItem `json:"routes"`
|
||||||
|
}
|
||||||
78
api/admin/system/menu_manage/menu_manage.go
Normal file
78
api/admin/system/menu_manage/menu_manage.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// Package admin_system_menu_manage 定义菜单管理接口(菜单 + 按钮权限树),路由前缀 /admin。
|
||||||
|
package admin_system_menu_manage
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// MenuItem 完整菜单树中的一个节点(type 1 菜单 / 2 按钮)。
|
||||||
|
type MenuItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type"` // 1 菜单, 2 按钮/接口
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
Children []*MenuItem `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuTreeReq 获取完整菜单树(供管理)。
|
||||||
|
type MenuTreeReq struct {
|
||||||
|
g.Meta `path:"/system/menu/tree" method:"post" tags:"Admin/System/Menu" summary:"完整菜单树"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuTreeRes 是 MenuTreeReq 的响应。
|
||||||
|
type MenuTreeRes struct {
|
||||||
|
Tree []*MenuItem `json:"tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreateReq 创建菜单或按钮节点。
|
||||||
|
type MenuCreateReq struct {
|
||||||
|
g.Meta `path:"/system/menu/create" method:"post" tags:"Admin/System/Menu" summary:"创建菜单"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type" v:"in:1,2#类型必须为 1 或 2"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission" v:"required"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreateRes 是 MenuCreateReq 的响应。
|
||||||
|
type MenuCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdateReq 更新菜单或按钮节点。
|
||||||
|
type MenuUpdateReq struct {
|
||||||
|
g.Meta `path:"/system/menu/update/{id}" method:"post" tags:"Admin/System/Menu" summary:"更新菜单"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type" v:"in:1,2#类型必须为 1 或 2"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission" v:"required"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdateRes 是 MenuUpdateReq 的响应。
|
||||||
|
type MenuUpdateRes struct{}
|
||||||
|
|
||||||
|
// MenuDeleteReq 删除菜单节点(软删除)。
|
||||||
|
type MenuDeleteReq struct {
|
||||||
|
g.Meta `path:"/system/menu/delete/{id}" method:"post" tags:"Admin/System/Menu" summary:"删除菜单"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuDeleteRes 是 MenuDeleteReq 的响应。
|
||||||
|
type MenuDeleteRes struct{}
|
||||||
63
api/admin/system/role/role.go
Normal file
63
api/admin/system/role/role.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Package admin_system_role 定义角色管理接口(角色及其菜单绑定),路由前缀 /admin。
|
||||||
|
package admin_system_role
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RoleItem 角色列表中的一行。
|
||||||
|
type RoleItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"` // 已绑定的菜单 id(编辑用)
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleListReq 分页查询角色。
|
||||||
|
type RoleListReq struct {
|
||||||
|
g.Meta `path:"/system/role/list" method:"post" tags:"Admin/System/Role" summary:"角色列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // 匹配角色码或名称
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleListRes 是 RoleListReq 的响应。
|
||||||
|
type RoleListRes struct {
|
||||||
|
List []*RoleItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreateReq 创建角色并绑定菜单。
|
||||||
|
type RoleCreateReq struct {
|
||||||
|
g.Meta `path:"/system/role/create" method:"post" tags:"Admin/System/Role" summary:"创建角色"`
|
||||||
|
Code string `json:"code" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreateRes 是 RoleCreateReq 的响应。
|
||||||
|
type RoleCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdateReq 更新角色并重新绑定菜单。
|
||||||
|
type RoleUpdateReq struct {
|
||||||
|
g.Meta `path:"/system/role/update/{id}" method:"post" tags:"Admin/System/Role" summary:"更新角色"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdateRes 是 RoleUpdateReq 的响应。
|
||||||
|
type RoleUpdateRes struct{}
|
||||||
|
|
||||||
|
// RoleDeleteReq 删除角色(软删除)。
|
||||||
|
type RoleDeleteReq struct {
|
||||||
|
g.Meta `path:"/system/role/delete/{id}" method:"post" tags:"Admin/System/Role" summary:"删除角色"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleDeleteRes 是 RoleDeleteReq 的响应。
|
||||||
|
type RoleDeleteRes struct{}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import "github.com/gogf/gf/v2/frame/g"
|
|
||||||
|
|
||||||
type LoginReq struct {
|
|
||||||
g.Meta `path:"/auth/login" method:"post" tags:"Admin/Auth" summary:"Administrator login"`
|
|
||||||
Username string `json:"username" v:"required"`
|
|
||||||
Password string `json:"password" v:"required"`
|
|
||||||
}
|
|
||||||
type LoginRes struct {
|
|
||||||
AccessToken string `json:"accessToken"`
|
|
||||||
RefreshToken string `json:"refreshToken"`
|
|
||||||
ExpiresIn int64 `json:"expiresIn"`
|
|
||||||
AdminID uint64 `json:"adminId"`
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
// =================================================================================
|
|
||||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
package hello
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"service.xpcool.com/api/hello/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
type IHelloV1 interface {
|
|
||||||
Hello(ctx context.Context, req *v1.HelloReq) (res *v1.HelloRes, err error)
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HelloReq struct {
|
|
||||||
g.Meta `path:"/hello" tags:"Hello" method:"get" summary:"You first hello api"`
|
|
||||||
}
|
|
||||||
type HelloRes struct {
|
|
||||||
g.Meta `mime:"text/html" example:"string"`
|
|
||||||
}
|
|
||||||
93
api/house/community/community.go
Normal file
93
api/house/community/community.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// Package house_community 定义楼盘/小区管理接口,路由前缀 /house。
|
||||||
|
package house_community
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// CommunityItem 小区列表中的一行。
|
||||||
|
type CommunityItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
BusinessDistrict string `json:"businessDistrict"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
BuildYear int `json:"buildYear"`
|
||||||
|
Households int `json:"households"`
|
||||||
|
PlotRatio float64 `json:"plotRatio"`
|
||||||
|
GreenRate float64 `json:"greenRate"`
|
||||||
|
PropertyCompany string `json:"propertyCompany"`
|
||||||
|
PropertyFee float64 `json:"propertyFee"`
|
||||||
|
Developer string `json:"developer"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityListReq 分页查询小区。
|
||||||
|
type CommunityListReq struct {
|
||||||
|
g.Meta `path:"/house/community/list" method:"post" tags:"Admin/House/Community" summary:"小区列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // 匹配名称/地址
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityListRes 是 CommunityListReq 的响应。
|
||||||
|
type CommunityListRes struct {
|
||||||
|
List []*CommunityItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityCreateReq 新增小区。
|
||||||
|
type CommunityCreateReq struct {
|
||||||
|
g.Meta `path:"/house/community/create" method:"post" tags:"Admin/House/Community" summary:"新增小区"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
BusinessDistrict string `json:"businessDistrict"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
BuildYear int `json:"buildYear"`
|
||||||
|
Households int `json:"households"`
|
||||||
|
PlotRatio float64 `json:"plotRatio"`
|
||||||
|
GreenRate float64 `json:"greenRate"`
|
||||||
|
PropertyCompany string `json:"propertyCompany"`
|
||||||
|
PropertyFee float64 `json:"propertyFee"`
|
||||||
|
Developer string `json:"developer"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityCreateRes 是 CommunityCreateReq 的响应。
|
||||||
|
type CommunityCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityUpdateReq 更新小区。
|
||||||
|
type CommunityUpdateReq struct {
|
||||||
|
g.Meta `path:"/house/community/update/{id}" method:"post" tags:"Admin/House/Community" summary:"更新小区"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
BusinessDistrict string `json:"businessDistrict"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
BuildYear int `json:"buildYear"`
|
||||||
|
Households int `json:"households"`
|
||||||
|
PlotRatio float64 `json:"plotRatio"`
|
||||||
|
GreenRate float64 `json:"greenRate"`
|
||||||
|
PropertyCompany string `json:"propertyCompany"`
|
||||||
|
PropertyFee float64 `json:"propertyFee"`
|
||||||
|
Developer string `json:"developer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityUpdateRes 是 CommunityUpdateReq 的响应。
|
||||||
|
type CommunityUpdateRes struct{}
|
||||||
|
|
||||||
|
// CommunityDeleteReq 删除小区(软删除)。
|
||||||
|
type CommunityDeleteReq struct {
|
||||||
|
g.Meta `path:"/house/community/delete/{id}" method:"post" tags:"Admin/House/Community" summary:"删除小区"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityDeleteRes 是 CommunityDeleteReq 的响应。
|
||||||
|
type CommunityDeleteRes struct{}
|
||||||
85
api/house/dashboard/dashboard.go
Normal file
85
api/house/dashboard/dashboard.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// Package house_dashboard 定义看房数据看板聚合接口,路由前缀 /house。
|
||||||
|
package house_dashboard
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// OverviewRes 看板统计概览。
|
||||||
|
type OverviewRes struct {
|
||||||
|
CommunityCount int `json:"communityCount"` // 小区数
|
||||||
|
ListingCount int `json:"listingCount"` // 在售房源数
|
||||||
|
BargainCount int `json:"bargainCount"` // 笋盘数
|
||||||
|
LowConfidence int `json:"lowConfidence"` // 低可信房源数
|
||||||
|
AvgUnitPrice float64 `json:"avgUnitPrice"` // 在售均价(元/平米)
|
||||||
|
AvgTotalPrice float64 `json:"avgTotalPrice"` // 在售平均总价(万元)
|
||||||
|
AvgListDays float64 `json:"avgListDays"` // 平均挂牌天数
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverviewReq 看板统计概览请求。
|
||||||
|
type OverviewReq struct {
|
||||||
|
g.Meta `path:"/house/dashboard/overview" method:"post" tags:"Admin/House/Dashboard" summary:"看板概览"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapPoint 地图上的一个小区点。
|
||||||
|
type MapPoint struct {
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
AvgUnitPrice float64 `json:"avgUnitPrice"`
|
||||||
|
ListingCount int `json:"listingCount"`
|
||||||
|
BargainCount int `json:"bargainCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapPointsReq 地图点位查询(带筛选)。
|
||||||
|
type MapPointsReq struct {
|
||||||
|
g.Meta `path:"/house/dashboard/map-points" method:"post" tags:"Admin/House/Dashboard" summary:"地图点位"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
PriceMin float64 `json:"priceMin"`
|
||||||
|
PriceMax float64 `json:"priceMax"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapPointsRes 是 MapPointsReq 的响应。
|
||||||
|
type MapPointsRes struct {
|
||||||
|
Points []*MapPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendPoint 一个日期的均价点。
|
||||||
|
type TrendPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
AvgListPrice float64 `json:"avgListPrice"` // 挂牌均价(万元)
|
||||||
|
AvgDealPrice float64 `json:"avgDealPrice"` // 成交均价(万元)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceTrendReq 价格趋势查询。
|
||||||
|
type PriceTrendReq struct {
|
||||||
|
g.Meta `path:"/house/dashboard/price-trend" method:"post" tags:"Admin/House/Dashboard" summary:"价格趋势"`
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Limit int `json:"limit" d:"30"` // 最近 N 天
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceTrendRes 是 PriceTrendReq 的响应。
|
||||||
|
type PriceTrendRes struct {
|
||||||
|
Trend []*TrendPoint `json:"trend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionAgg 一个区域的聚合指标。
|
||||||
|
type RegionAgg struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
AvgUnitPrice float64 `json:"avgUnitPrice"`
|
||||||
|
ListingCount int `json:"listingCount"`
|
||||||
|
BargainCount int `json:"bargainCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateRegionReq 区域聚合查询。
|
||||||
|
type AggregateRegionReq struct {
|
||||||
|
g.Meta `path:"/house/dashboard/aggregate-region" method:"post" tags:"Admin/House/Dashboard" summary:"区域聚合"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateRegionRes 是 AggregateRegionReq 的响应。
|
||||||
|
type AggregateRegionRes struct {
|
||||||
|
List []*RegionAgg `json:"list"`
|
||||||
|
}
|
||||||
139
api/house/listing/listing.go
Normal file
139
api/house/listing/listing.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
// Package house_listing 定义房源/挂牌管理接口,路由前缀 /house。
|
||||||
|
package house_listing
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// ListingItem 房源列表中的一行。
|
||||||
|
type ListingItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
CommunityName string `json:"communityName"`
|
||||||
|
BuildingId uint64 `json:"buildingId"`
|
||||||
|
HouseNo string `json:"houseNo"`
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Area float64 `json:"area"`
|
||||||
|
UsableArea float64 `json:"usableArea"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
Floor int `json:"floor"`
|
||||||
|
TotalFloors int `json:"totalFloors"`
|
||||||
|
Decoration string `json:"decoration"`
|
||||||
|
TotalPrice float64 `json:"totalPrice"`
|
||||||
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
|
ListPrice float64 `json:"listPrice"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceHouseId string `json:"sourceHouseId"`
|
||||||
|
SourceUrl string `json:"sourceUrl"`
|
||||||
|
MatchGroupId uint64 `json:"matchGroupId"`
|
||||||
|
OnMarketDays int `json:"onMarketDays"`
|
||||||
|
PriceChangeCount int `json:"priceChangeCount"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Confidence int `json:"confidence"`
|
||||||
|
IsBargain int `json:"isBargain"`
|
||||||
|
ListingTime string `json:"listingTime"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingListReq 分页查询房源(统一筛选入参,供管理列表与看板共用)。
|
||||||
|
type ListingListReq struct {
|
||||||
|
g.Meta `path:"/house/listing/list" method:"post" tags:"Admin/House/Listing" summary:"房源列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
Keyword string `json:"keyword"` // 匹配房号/户型
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
PriceMin float64 `json:"priceMin"`
|
||||||
|
PriceMax float64 `json:"priceMax"`
|
||||||
|
AreaMin float64 `json:"areaMin"`
|
||||||
|
AreaMax float64 `json:"areaMax"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
IsBargain int `json:"isBargain"` // 0 全部,1 仅笋盘
|
||||||
|
Confidence int `json:"confidence"` // 0 全部,1 仅低可信
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingListRes 是 ListingListReq 的响应。
|
||||||
|
type ListingListRes struct {
|
||||||
|
List []*ListingItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingCreateReq 新增房源(采集器/手动录入)。
|
||||||
|
type ListingCreateReq struct {
|
||||||
|
g.Meta `path:"/house/listing/create" method:"post" tags:"Admin/House/Listing" summary:"新增房源"`
|
||||||
|
CommunityId uint64 `json:"communityId" v:"required"`
|
||||||
|
BuildingId uint64 `json:"buildingId"`
|
||||||
|
HouseNo string `json:"houseNo"`
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Area float64 `json:"area"`
|
||||||
|
UsableArea float64 `json:"usableArea"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
Floor int `json:"floor"`
|
||||||
|
TotalFloors int `json:"totalFloors"`
|
||||||
|
Decoration string `json:"decoration"`
|
||||||
|
TotalPrice float64 `json:"totalPrice"`
|
||||||
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
|
ListPrice float64 `json:"listPrice"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceHouseId string `json:"sourceHouseId"`
|
||||||
|
SourceUrl string `json:"sourceUrl"`
|
||||||
|
MatchGroupId uint64 `json:"matchGroupId"`
|
||||||
|
OnMarketDays int `json:"onMarketDays"`
|
||||||
|
PriceChangeCount int `json:"priceChangeCount"`
|
||||||
|
Status int `json:"status" d:"1"`
|
||||||
|
Confidence int `json:"confidence"`
|
||||||
|
IsBargain int `json:"isBargain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingCreateRes 是 ListingCreateReq 的响应。
|
||||||
|
type ListingCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingUpdateReq 更新房源。
|
||||||
|
type ListingUpdateReq struct {
|
||||||
|
g.Meta `path:"/house/listing/update/{id}" method:"post" tags:"Admin/House/Listing" summary:"更新房源"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
BuildingId uint64 `json:"buildingId"`
|
||||||
|
HouseNo string `json:"houseNo"`
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Area float64 `json:"area"`
|
||||||
|
UsableArea float64 `json:"usableArea"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
Floor int `json:"floor"`
|
||||||
|
TotalFloors int `json:"totalFloors"`
|
||||||
|
Decoration string `json:"decoration"`
|
||||||
|
TotalPrice float64 `json:"totalPrice"`
|
||||||
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
|
ListPrice float64 `json:"listPrice"`
|
||||||
|
MatchGroupId uint64 `json:"matchGroupId"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Confidence int `json:"confidence"`
|
||||||
|
IsBargain int `json:"isBargain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingUpdateRes 是 ListingUpdateReq 的响应。
|
||||||
|
type ListingUpdateRes struct{}
|
||||||
|
|
||||||
|
// ListingDeleteReq 删除房源(软删除)。
|
||||||
|
type ListingDeleteReq struct {
|
||||||
|
g.Meta `path:"/house/listing/delete/{id}" method:"post" tags:"Admin/House/Listing" summary:"删除房源"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingDeleteRes 是 ListingDeleteReq 的响应。
|
||||||
|
type ListingDeleteRes struct{}
|
||||||
|
|
||||||
|
// ListingBatchMarkReq 批量标记房源(置信度/笋盘/状态)。
|
||||||
|
type ListingBatchMarkReq struct {
|
||||||
|
g.Meta `path:"/house/listing/batch-mark" method:"post" tags:"Admin/House/Listing" summary:"批量标记房源"`
|
||||||
|
Ids []uint64 `json:"ids" v:"required"`
|
||||||
|
Field string `json:"field" v:"required"` // confidence/isBargain/status
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingBatchMarkRes 是 ListingBatchMarkReq 的响应。
|
||||||
|
type ListingBatchMarkRes struct {
|
||||||
|
Affected int64 `json:"affected"`
|
||||||
|
}
|
||||||
37
api/house/presale/presale.go
Normal file
37
api/house/presale/presale.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Package house_presale 定义新房预售证查询接口,路由前缀 /house。
|
||||||
|
package house_presale
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// PresaleItem 一条新房预售许可证。
|
||||||
|
type PresaleItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
PresaleNo string `json:"presaleNo"`
|
||||||
|
CommunityName string `json:"communityName"`
|
||||||
|
Developer string `json:"developer"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
BuildingNo string `json:"buildingNo"`
|
||||||
|
HouseCount int `json:"houseCount"`
|
||||||
|
Area float64 `json:"area"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
IssueDate string `json:"issueDate"`
|
||||||
|
PublishDate string `json:"publishDate"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresaleListReq 分页查询新房预售证。
|
||||||
|
type PresaleListReq struct {
|
||||||
|
g.Meta `path:"/house/presale/list" method:"post" tags:"Admin/House/Presale" summary:"新房预售证列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Purpose string `json:"purpose"` // 规划用途(住宅/商业)
|
||||||
|
Keyword string `json:"keyword"` // 匹配楼盘名/开发商/预售证号
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresaleListRes 是 PresaleListReq 的响应。
|
||||||
|
type PresaleListRes struct {
|
||||||
|
List []*PresaleItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
33
api/house/transaction/transaction.go
Normal file
33
api/house/transaction/transaction.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Package house_transaction 定义成交记录查询接口,路由前缀 /house。
|
||||||
|
package house_transaction
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// TransactionItem 一条成交记录。
|
||||||
|
type TransactionItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
CommunityId uint64 `json:"communityId"`
|
||||||
|
CommunityName string `json:"communityName"`
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Area float64 `json:"area"`
|
||||||
|
DealPrice float64 `json:"dealPrice"`
|
||||||
|
DealUnitPrice float64 `json:"dealUnitPrice"`
|
||||||
|
ListDays int `json:"listDays"`
|
||||||
|
DealDate string `json:"dealDate"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionListReq 分页查询成交记录。
|
||||||
|
type TransactionListReq struct {
|
||||||
|
g.Meta `path:"/house/transaction/list" method:"post" tags:"Admin/House/Transaction" summary:"成交记录列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Keyword string `json:"keyword"` // 匹配小区名/户型
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionListRes 是 TransactionListReq 的响应。
|
||||||
|
type TransactionListRes struct {
|
||||||
|
List []*TransactionItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
73
api/job/job.go
Normal file
73
api/job/job.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// Package job_v1 自动任务管理模块接口契约(DB 驱动的 gcron 调度 + 运行记录)。
|
||||||
|
// 规范(2026-08-27):全部 POST;URL 不含参数;入参一律 body。
|
||||||
|
package job
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// ---------- 任务列表 ----------
|
||||||
|
type JobItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
JobType string `json:"jobType"`
|
||||||
|
CronExpr string `json:"cronExpr"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
LastRunAt string `json:"lastRunAt"`
|
||||||
|
LastResult int `json:"lastResult"` // 0未运行 1成功 2失败
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobListReq struct {
|
||||||
|
g.Meta `path:"/auto-job/list" method:"post" tags:"Admin/AutoJob" summary:"自动任务列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobListRes struct {
|
||||||
|
List []*JobItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 保存任务(改 cron/启用/备注) ----------
|
||||||
|
type AutoJobSaveReq struct {
|
||||||
|
g.Meta `path:"/auto-job/save" method:"post" tags:"Admin/AutoJob" summary:"保存自动任务(改cron/启用)"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
CronExpr string `json:"cronExpr"`
|
||||||
|
Enabled int `json:"enabled" d:"1"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobSaveRes struct{}
|
||||||
|
|
||||||
|
// ---------- 手动触发 ----------
|
||||||
|
type AutoJobTriggerReq struct {
|
||||||
|
g.Meta `path:"/auto-job/trigger" method:"post" tags:"Admin/AutoJob" summary:"手动触发任务"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobTriggerRes struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 任务运行日志 ----------
|
||||||
|
type JobLogItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
JobId uint64 `json:"jobId"`
|
||||||
|
JobName string `json:"jobName"`
|
||||||
|
RunAt string `json:"runAt"`
|
||||||
|
Result int `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
DurationMs int `json:"durationMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobLogListReq struct {
|
||||||
|
g.Meta `path:"/auto-job/log/list" method:"post" tags:"Admin/AutoJob" summary:"任务运行日志分页"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
JobId uint64 `json:"jobId"` // 0=全部
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoJobLogListRes struct {
|
||||||
|
List []*JobLogItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
210
api/notice/notice.go
Normal file
210
api/notice/notice.go
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// Package notice_v1 通知模块接口契约(统一推送管理:渠道/规则/日志/测试)。
|
||||||
|
// 规范(2026-08-27):全部 POST;URL 不含参数;入参一律 body。
|
||||||
|
package notice
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// ---------- 通知渠道 ----------
|
||||||
|
type NoticeChannelItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Config string `json:"config"` // JSON 字符串
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeChannelListReq struct {
|
||||||
|
g.Meta `path:"/notice/channel/list" method:"post" tags:"Admin/Notice" summary:"通知渠道列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeChannelListRes struct {
|
||||||
|
List []*NoticeChannelItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeChannelSaveReq struct {
|
||||||
|
g.Meta `path:"/notice/channel/save" method:"post" tags:"Admin/Notice" summary:"保存通知渠道(新增/更新)"`
|
||||||
|
Id uint64 `json:"id"` // 0=新增
|
||||||
|
Code string `json:"code" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Enabled int `json:"enabled" d:"1"`
|
||||||
|
Config string `json:"config"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeChannelSaveRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 通知规则 ----------
|
||||||
|
type NoticeRuleItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
ChannelCodes []string `json:"channelCodes"`
|
||||||
|
UserIds []uint64 `json:"userIds"`
|
||||||
|
TitleTemplate string `json:"titleTemplate"`
|
||||||
|
BodyTemplate string `json:"bodyTemplate"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleListReq struct {
|
||||||
|
g.Meta `path:"/notice/rule/list" method:"post" tags:"Admin/Notice" summary:"通知规则列表"`
|
||||||
|
EventType string `json:"eventType"` // 可选过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleListRes struct {
|
||||||
|
List []*NoticeRuleItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleSaveReq struct {
|
||||||
|
g.Meta `path:"/notice/rule/save" method:"post" tags:"Admin/Notice" summary:"保存通知规则(新增/更新)"`
|
||||||
|
Id uint64 `json:"id"` // 0=新增
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
EventType string `json:"eventType" v:"required"`
|
||||||
|
ChannelCodes []string `json:"channelCodes" v:"required|min-length:1"`
|
||||||
|
UserIds []uint64 `json:"userIds" v:"required|min-length:1"`
|
||||||
|
TitleTemplate string `json:"titleTemplate"`
|
||||||
|
BodyTemplate string `json:"bodyTemplate"`
|
||||||
|
Enabled int `json:"enabled" d:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleSaveRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleDeleteReq struct {
|
||||||
|
g.Meta `path:"/notice/rule/delete" method:"post" tags:"Admin/Notice" summary:"删除通知规则"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeRuleDeleteRes struct{}
|
||||||
|
|
||||||
|
// ---------- 通知历史记录 ----------
|
||||||
|
// NoticeLogItem 通知历史记录行:落库字段 + 渠道/事件/类型/分组/接收人等派生展示字段。
|
||||||
|
type NoticeLogItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
BatchId string `json:"batchId"` // 批次号(同一次业务触发)
|
||||||
|
RuleId uint64 `json:"ruleId"` // 规则ID
|
||||||
|
RuleName string `json:"ruleName"` // 规则名称
|
||||||
|
EventType string `json:"eventType"` // 事件编码
|
||||||
|
EventName string `json:"eventName"` // 事件名称
|
||||||
|
NoticeType string `json:"noticeType"` // 类型编码
|
||||||
|
TypeName string `json:"typeName"` // 类型名称
|
||||||
|
Group string `json:"group"` // 分组编码
|
||||||
|
GroupName string `json:"groupName"` // 分组名称
|
||||||
|
ChannelCode string `json:"channelCode"` // 渠道编码
|
||||||
|
ChannelName string `json:"channelName"` // 渠道名称
|
||||||
|
UserId uint64 `json:"userId"` // 接收用户ID
|
||||||
|
UserName string `json:"userName"` // 接收人
|
||||||
|
Target string `json:"target"` // 发送目标
|
||||||
|
Title string `json:"title"` // 标题
|
||||||
|
Body string `json:"body"` // 内容
|
||||||
|
Status int `json:"status"` // 0待发送 1成功 2失败
|
||||||
|
StatusName string `json:"statusName"` // 状态名称
|
||||||
|
Result int `json:"result"` // 兼容旧字段 1成功 0失败
|
||||||
|
Error string `json:"error"` // 错误信息
|
||||||
|
RetryCount int `json:"retryCount"` // 已重试次数
|
||||||
|
DurationMs int `json:"durationMs"` // 耗时(毫秒)
|
||||||
|
Source string `json:"source"` // 触发来源 auto/manual/test
|
||||||
|
Remark string `json:"remark"` // 备注
|
||||||
|
CreatedAt string `json:"createdAt"` // 通知时间
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeLogListReq struct {
|
||||||
|
g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知历史记录分页"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // 标题/内容/目标/错误 模糊
|
||||||
|
NoticeType string `json:"noticeType"` // 类型编码
|
||||||
|
Group string `json:"group"` // 分组编码
|
||||||
|
EventType string `json:"eventType"` // 事件编码
|
||||||
|
ChannelCode string `json:"channelCode"` // 渠道编码
|
||||||
|
UserId uint64 `json:"userId"` // 接收用户ID
|
||||||
|
BatchId string `json:"batchId"` // 批次号
|
||||||
|
Status int `json:"status"` // 0全部 1成功 2失败
|
||||||
|
Result int `json:"result"` // 兼容旧参数:1成功 2失败
|
||||||
|
DateFrom string `json:"dateFrom"` // 起始时间
|
||||||
|
DateTo string `json:"dateTo"` // 结束时间
|
||||||
|
OrderBy string `json:"orderBy"` // createdAt(默认) | durationMs
|
||||||
|
OrderDir string `json:"orderDir"` // desc(默认) | asc
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeLogListRes struct {
|
||||||
|
List []*NoticeLogItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Stats *NoticeLogStatsItem `json:"stats"` // 当前筛选条件下的统计概览
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeLogStatsItem 统计概览。
|
||||||
|
type NoticeLogStatsItem struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
SuccessRate int `json:"successRate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeLogDetailReq 通知历史记录详情。
|
||||||
|
type NoticeLogDetailReq struct {
|
||||||
|
g.Meta `path:"/notice/log/detail" method:"post" tags:"Admin/Notice" summary:"通知历史记录详情"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeLogDetailRes struct {
|
||||||
|
Item *NoticeLogItem `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeLogDeleteReq 删除通知历史记录(支持批量)。
|
||||||
|
type NoticeLogDeleteReq struct {
|
||||||
|
g.Meta `path:"/notice/log/delete" method:"post" tags:"Admin/Notice" summary:"删除通知历史记录"`
|
||||||
|
Ids []uint64 `json:"ids" v:"required|min-length:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeLogDeleteRes struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeLogClearReq 清空通知历史记录(按时间范围,可选保留最近 N 天)。
|
||||||
|
type NoticeLogClearReq struct {
|
||||||
|
g.Meta `path:"/notice/log/clear" method:"post" tags:"Admin/Notice" summary:"清空通知历史记录"`
|
||||||
|
KeepDays int `json:"keepDays"` // 保留最近 N 天(>0 时忽略 DateFrom/DateTo)
|
||||||
|
DateFrom string `json:"dateFrom"` // 清空起始时间
|
||||||
|
DateTo string `json:"dateTo"` // 清空结束时间
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeLogClearRes struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 字典选项 ----------
|
||||||
|
type NoticeMetaItem struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeMetaOptionsReq struct {
|
||||||
|
g.Meta `path:"/notice/log/options" method:"post" tags:"Admin/Notice" summary:"通知字典选项(渠道/事件/类型/分组)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeMetaOptionsRes struct {
|
||||||
|
Channels []*NoticeMetaItem `json:"channels"`
|
||||||
|
Events []*NoticeMetaItem `json:"events"`
|
||||||
|
Types []*NoticeMetaItem `json:"types"`
|
||||||
|
Groups []*NoticeMetaItem `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 测试发送 ----------
|
||||||
|
type NoticeTestReq struct {
|
||||||
|
g.Meta `path:"/notice/test" method:"post" tags:"Admin/Notice" summary:"测试通知(按规则或直接指定)"`
|
||||||
|
RuleId uint64 `json:"ruleId"` // 按规则测试(0=手动指定)
|
||||||
|
ChannelCode string `json:"channelCode"` // 手动:渠道编码
|
||||||
|
Target string `json:"target"` // 手动:设备key/token
|
||||||
|
UserIds []uint64 `json:"userIds"` // 手动:或按用户
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoticeTestRes struct {
|
||||||
|
Result bool `json:"result"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
15
api/open/tools/doc.go
Normal file
15
api/open/tools/doc.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package open_tools 承载开放工具接口(GET/POST /tools/*)。
|
||||||
|
//
|
||||||
|
// 命名规则:tools/ 下每个子功能一个独立目录,目录名即包名;Req/Res 契约放在
|
||||||
|
// 目录内以功能命名的文件中,例如:
|
||||||
|
//
|
||||||
|
// api/open/tools/uuid/uuid.go - POST /tools/uuid
|
||||||
|
// api/open/tools/md5/md5.go - POST /tools/md5
|
||||||
|
// api/open/tools/random/random.go - POST /tools/random
|
||||||
|
// api/open/tools/time/time.go - POST /tools/time
|
||||||
|
// api/open/tools/ip/ip.go - POST /tools/ip
|
||||||
|
//
|
||||||
|
// 功能增长时,在其目录内新增文件(如 ocr/ 下加 idcard.go、invoice.go),
|
||||||
|
// 而不是把起始文件写大。新增功能:创建 tools/<name>/<name>.go 并在
|
||||||
|
// internal/controller/open/<name>.go 中增加对应方法,路由自动绑定。
|
||||||
|
package open_tools
|
||||||
15
api/open/tools/ip/ip.go
Normal file
15
api/open/tools/ip/ip.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package open_tools_ip 定义 POST /tools/ip 接口契约。
|
||||||
|
package open_tools_ip
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// IPReq 获取调用方 IP 信息。
|
||||||
|
type IPReq struct {
|
||||||
|
g.Meta `path:"/tools/ip" method:"post" tags:"Open/Tools" summary:"客户端 IP 信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPRes 是 IPReq 的响应。
|
||||||
|
type IPRes struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Internal bool `json:"internal"` // 是否为内网/私网地址
|
||||||
|
}
|
||||||
15
api/open/tools/md5/md5.go
Normal file
15
api/open/tools/md5/md5.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package open_tools_md5 定义 POST /tools/md5 接口契约。
|
||||||
|
package open_tools_md5
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// MD5Req 计算文本的 MD5 摘要。
|
||||||
|
type MD5Req struct {
|
||||||
|
g.Meta `path:"/tools/md5" method:"post" tags:"Open/Tools" summary:"计算 MD5 摘要"`
|
||||||
|
Text string `json:"text" v:"required#待摘要文本不能为空"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5Res 是 MD5Req 的响应。
|
||||||
|
type MD5Res struct {
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
}
|
||||||
16
api/open/tools/random/random.go
Normal file
16
api/open/tools/random/random.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Package open_tools_random 定义 POST /tools/random 接口契约。
|
||||||
|
package open_tools_random
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RandomReq 生成随机字符串。
|
||||||
|
type RandomReq struct {
|
||||||
|
g.Meta `path:"/tools/random" method:"post" tags:"Open/Tools" summary:"生成随机字符串"`
|
||||||
|
Length int `json:"length" d:"16" v:"min:1|max:128#长度必须在 1-128 之间"`
|
||||||
|
Type string `json:"type" d:"alnum" v:"in:alnum,digits,letters#不支持的随机类型"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomRes 是 RandomReq 的响应。
|
||||||
|
type RandomRes struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
16
api/open/tools/time/time.go
Normal file
16
api/open/tools/time/time.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Package open_tools_time 定义 POST /tools/time 接口契约。
|
||||||
|
package open_tools_time
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// TimeReq 获取当前服务器时间。
|
||||||
|
type TimeReq struct {
|
||||||
|
g.Meta `path:"/tools/time" method:"post" tags:"Open/Tools" summary:"当前服务器时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeRes 是 TimeReq 的响应。
|
||||||
|
type TimeRes struct {
|
||||||
|
Timestamp int64 `json:"timestamp"` // Unix 秒
|
||||||
|
DateTime string `json:"dateTime"` // 2006-01-02 15:04:05
|
||||||
|
Date string `json:"date"` // 2006-01-02
|
||||||
|
}
|
||||||
15
api/open/tools/uuid/uuid.go
Normal file
15
api/open/tools/uuid/uuid.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package open_tools_uuid 定义 POST /tools/uuid 接口契约。
|
||||||
|
package open_tools_uuid
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// UUIDReq 生成唯一 ID。
|
||||||
|
type UUIDReq struct {
|
||||||
|
g.Meta `path:"/tools/uuid" method:"post" tags:"Open/Tools" summary:"生成唯一 ID"`
|
||||||
|
Short bool `json:"short"` // true: 8 位短码;false: 32 位 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUIDRes 是 UUIDReq 的响应。
|
||||||
|
type UUIDRes struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
}
|
||||||
191
api/recruitment/recruitment.go
Normal file
191
api/recruitment/recruitment.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
// Package recruitment_v1 招聘考试聚合模块接口契约。
|
||||||
|
// 规范(2026-08-27):全部 POST;URL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
|
||||||
|
package recruitment
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// ---------- 公告列表 ----------
|
||||||
|
type RecruitmentListReq struct {
|
||||||
|
g.Meta `path:"/recruitment/info/list" method:"post" tags:"Admin/Recruitment/Info" summary:"招聘公告列表(多维筛选)"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Region string `json:"region"` // 地区过滤(空=全部)
|
||||||
|
Category int `json:"category"` // 分类(0=全部)
|
||||||
|
Keyword string `json:"keyword"` // 标题/正文/单位关键字
|
||||||
|
Status int `json:"status"` // 状态(0=有效+已更正;传 2 只看已失效等)
|
||||||
|
DateFrom string `json:"dateFrom"` // 发布日期起点 YYYY-MM-DD
|
||||||
|
DateTo string `json:"dateTo"` // 发布日期终点 YYYY-MM-DD
|
||||||
|
OrgName string `json:"orgName"` // 发布主体名称模糊匹配
|
||||||
|
SourceId uint64 `json:"sourceId"` // 指定数据源
|
||||||
|
OnlyNewToday bool `json:"onlyNewToday"` // 仅当日新增
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SourceId uint64 `json:"sourceId"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
OrgId uint64 `json:"orgId"`
|
||||||
|
OrgName string `json:"orgName"`
|
||||||
|
Category int `json:"category"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
PublishDate string `json:"publishDate"`
|
||||||
|
Deadline string `json:"deadline"`
|
||||||
|
ExamDate string `json:"examDate"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Attachments string `json:"attachments"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
StatusName string `json:"statusName"`
|
||||||
|
GroupKey string `json:"groupKey"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentListRes struct {
|
||||||
|
List []*RecruitmentItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公告详情 ----------
|
||||||
|
type RecruitmentDetailReq struct {
|
||||||
|
g.Meta `path:"/recruitment/info/detail" method:"post" tags:"Admin/Recruitment/Info" summary:"招聘公告详情"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentDetailRes struct {
|
||||||
|
Info *RecruitmentItem `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 看板统计 ----------
|
||||||
|
type RecruitmentStatsReq struct {
|
||||||
|
g.Meta `path:"/recruitment/stats" method:"post" tags:"Admin/Recruitment/Dashboard" summary:"招聘数据看板统计"`
|
||||||
|
Region string `json:"region"` // 可选:按地区过滤统计
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentStatsRes struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
TodayNew int `json:"todayNew"`
|
||||||
|
WeekNew int `json:"weekNew"`
|
||||||
|
ByCategory []CategoryAggItem `json:"byCategory"`
|
||||||
|
ByRegion []RegionAggItem `json:"byRegion"`
|
||||||
|
RecentTrend []TrendPointItem `json:"recentTrend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryAggItem struct {
|
||||||
|
Category int `json:"category"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegionAggItem struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendPointItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 趋势分析 ----------
|
||||||
|
type RecruitmentTrendReq struct {
|
||||||
|
g.Meta `path:"/recruitment/trend" method:"post" tags:"Admin/Recruitment/Dashboard" summary:"招聘公告趋势(按日)"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Category int `json:"category"`
|
||||||
|
Days int `json:"days" d:"30" v:"min:1|max:365"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentTrendRes struct {
|
||||||
|
Trend []TrendPointItem `json:"trend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据源状态 ----------
|
||||||
|
type RecruitmentSourcesReq struct {
|
||||||
|
g.Meta `path:"/recruitment/source/list" method:"post" tags:"Admin/Recruitment/Crawler" summary:"数据源列表与运行状态"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseUrl string `json:"baseUrl"`
|
||||||
|
SourceType int `json:"sourceType"`
|
||||||
|
Category int `json:"category"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
LastSuccessAt string `json:"lastSuccessAt"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
LastSummary string `json:"lastSummary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSourcesRes struct {
|
||||||
|
List []*SourceItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 手动触发抓取 ----------
|
||||||
|
type RecruitmentTriggerReq struct {
|
||||||
|
g.Meta `path:"/recruitment/crawl/trigger" method:"post" tags:"Admin/Recruitment/Crawler" summary:"手动触发抓取(单源或全量)"`
|
||||||
|
SourceId uint64 `json:"sourceId"` // 0=全部启用源
|
||||||
|
Force bool `json:"force"` // 是否强制全量回溯
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentTriggerRes struct {
|
||||||
|
Triggered int `json:"triggered"` // 触发的源数量
|
||||||
|
Summary string `json:"summary"` // 运行摘要
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bark 推送测试 ----------
|
||||||
|
type RecruitmentPushTestReq struct {
|
||||||
|
g.Meta `path:"/recruitment/push/test" method:"post" tags:"Admin/Recruitment/Push" summary:"Bark 推送测试"`
|
||||||
|
SubscriptionId uint64 `json:"subscriptionId"` // 0=使用首个启用订阅
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentPushTestRes struct {
|
||||||
|
Result bool `json:"result"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 推送订阅管理 ----------
|
||||||
|
type RecruitmentSubscriptionListReq struct {
|
||||||
|
g.Meta `path:"/recruitment/subscription/list" method:"post" tags:"Admin/Recruitment/Push" summary:"推送订阅列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DeviceKey string `json:"deviceKey"`
|
||||||
|
Regions []string `json:"regions"`
|
||||||
|
Categories []int `json:"categories"`
|
||||||
|
OnlyNew int `json:"onlyNew"`
|
||||||
|
PushTime string `json:"pushTime"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSubscriptionListRes struct {
|
||||||
|
List []*SubscriptionItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSubscriptionSaveReq struct {
|
||||||
|
g.Meta `path:"/recruitment/subscription/save" method:"post" tags:"Admin/Recruitment/Push" summary:"保存推送订阅(新增/更新)"`
|
||||||
|
Id uint64 `json:"id"` // 0=新增
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
DeviceKey string `json:"deviceKey" v:"required"`
|
||||||
|
Regions []string `json:"regions"`
|
||||||
|
Categories []int `json:"categories"`
|
||||||
|
OnlyNew int `json:"onlyNew" d:"1"`
|
||||||
|
PushTime string `json:"pushTime" d:"08:00"`
|
||||||
|
Enabled int `json:"enabled" d:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSubscriptionSaveRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSubscriptionDeleteReq struct {
|
||||||
|
g.Meta `path:"/recruitment/subscription/delete" method:"post" tags:"Admin/Recruitment/Push" summary:"删除推送订阅"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecruitmentSubscriptionDeleteRes struct{}
|
||||||
91
api/serversecurity/security.go
Normal file
91
api/serversecurity/security.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Package serversecurity_v1 服务器安全监控日志模块接口契约。
|
||||||
|
// 规范(2026-08-27):全部 POST;URL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
|
||||||
|
package serversecurity
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// ---------- 上报(宿主机采集脚本调用,open 组 + 内部 token 校验) ----------
|
||||||
|
// SecurityLogItem 单条安全日志。
|
||||||
|
type SecurityLogItem struct {
|
||||||
|
LogTime string `json:"logTime"` // 事件发生时间 YYYY-MM-DD HH:MM:SS
|
||||||
|
SrcIp string `json:"srcIp"` // 来源 IP
|
||||||
|
SrcPort int `json:"srcPort"` // 来源端口
|
||||||
|
DestPort int `json:"destPort"` // 目标端口(如 22025)
|
||||||
|
EventType string `json:"eventType"` // failed_ssh / accepted_ssh / banned / unbanned
|
||||||
|
Detail string `json:"detail"` // 日志详情/原文
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityLogReportReq 上报请求。
|
||||||
|
type SecurityLogReportReq struct {
|
||||||
|
g.Meta `path:"/security/log/report" method:"post" tags:"Open/Security" summary:"上报服务器安全日志(内部脚本调用)"`
|
||||||
|
Token string `json:"token" v:"required"` // 内部上报令牌
|
||||||
|
List []SecurityLogItem `json:"list" v:"required|min-length:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityLogReportRes 上报结果。
|
||||||
|
type SecurityLogReportRes struct {
|
||||||
|
Accepted int `json:"accepted"` // 成功入库条数
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询(admin 组,受权限保护) ----------
|
||||||
|
// SecurityLogListReq 分页查询请求。
|
||||||
|
type SecurityLogListReq struct {
|
||||||
|
g.Meta `path:"/server-security/log/list" method:"post" tags:"Admin/Security/Log" summary:"安全日志分页查询"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
SrcIp string `json:"srcIp"` // 来源 IP 精确/模糊
|
||||||
|
EventType string `json:"eventType"` // 事件类型过滤(空=全部)
|
||||||
|
DestPort int `json:"destPort"` // 目标端口(0=全部)
|
||||||
|
DateFrom string `json:"dateFrom"` // 起始时间 YYYY-MM-DD HH:MM:SS
|
||||||
|
DateTo string `json:"dateTo"` // 结束时间 YYYY-MM-DD HH:MM:SS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityLogListItem 日志列表项。
|
||||||
|
type SecurityLogListItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
LogTime string `json:"logTime"`
|
||||||
|
SrcIp string `json:"srcIp"`
|
||||||
|
SrcPort int `json:"srcPort"`
|
||||||
|
DestPort int `json:"destPort"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
EventName string `json:"eventName"` // 派生:事件中文名
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityLogListRes 分页查询结果。
|
||||||
|
type SecurityLogListRes struct {
|
||||||
|
List []*SecurityLogListItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 统计(admin 组,受权限保护) ----------
|
||||||
|
// SecurityLogStatsReq 统计请求。
|
||||||
|
type SecurityLogStatsReq struct {
|
||||||
|
g.Meta `path:"/server-security/log/stats" method:"post" tags:"Admin/Security/Log" summary:"安全日志统计(默认最近24h)"`
|
||||||
|
DateFrom string `json:"dateFrom"` // 起始时间(空=24小时前)
|
||||||
|
DateTo string `json:"dateTo"` // 结束时间(空=当前)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityTopIp TOP 攻击来源 IP。
|
||||||
|
type SecurityTopIp struct {
|
||||||
|
SrcIp string `json:"srcIp"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityByType 按事件类型分布。
|
||||||
|
type SecurityByType struct {
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityLogStatsRes 统计结果。
|
||||||
|
type SecurityLogStatsRes struct {
|
||||||
|
Total int `json:"total"` // 范围内总记录
|
||||||
|
Failed int `json:"failed"` // SSH 爆破尝试
|
||||||
|
Banned int `json:"banned"` // fail2ban 封禁
|
||||||
|
Accepted int `json:"accepted"` // 成功登录
|
||||||
|
TopIps []SecurityTopIp `json:"topIps"` // TOP 攻击源(按条数)
|
||||||
|
ByType []SecurityByType `json:"byType"` // 按类型分布
|
||||||
|
}
|
||||||
33
api/user/auth/auth.go
Normal file
33
api/user/auth/auth.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Package user_auth 定义用户端认证接口(登录/刷新),路由前缀 /api。
|
||||||
|
package user_auth
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LoginReq 用户登录请求(支持微信/手机验证码/账号密码三种方式)。
|
||||||
|
type LoginReq struct {
|
||||||
|
g.Meta `path:"/auth/login" method:"post" tags:"User/Auth" summary:"用户登录"`
|
||||||
|
LoginType string `json:"loginType" v:"required|in:wechat,mobile,password#登录方式不能为空|不支持的登录方式"`
|
||||||
|
Code string `json:"code"` // 微信授权码
|
||||||
|
Mobile string `json:"mobile"` // 手机号(mobile 登录方式)
|
||||||
|
VerifyCode string `json:"verifyCode"` // 短信验证码
|
||||||
|
Account string `json:"account"` // 账号(password 登录方式)
|
||||||
|
Password string `json:"password"` // 密码
|
||||||
|
Terminal string `json:"terminal" v:"required|in:mini,h5,app#终端类型不能为空|无效的终端类型"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRes 用户登录响应。
|
||||||
|
type LoginRes struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
UserID uint64 `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshReq 使用刷新令牌轮换用户令牌对。
|
||||||
|
type RefreshReq struct {
|
||||||
|
g.Meta `path:"/auth/refresh" method:"post" tags:"User/Auth" summary:"轮换用户令牌"`
|
||||||
|
RefreshToken string `json:"refreshToken" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshRes 是 RefreshReq 的响应。
|
||||||
|
type RefreshRes LoginRes
|
||||||
6
api/user/login/login.go
Normal file
6
api/user/login/login.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Package user_login 用户端登录契约。
|
||||||
|
//
|
||||||
|
// 说明:当前用户端登录已统一收敛到 `api/user/auth`(支持微信 / 手机号 / 密码三种方式),
|
||||||
|
// user 路由组直接绑定 Auth 控制器即可,因此本包暂无端点定义。
|
||||||
|
// 保留该包是为了维持目录结构一致性,后续如需拆分「扫码登录」「短信登录」等独立契约,可在此扩展。
|
||||||
|
package user_login
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import "github.com/gogf/gf/v2/frame/g"
|
|
||||||
|
|
||||||
type LoginReq struct {
|
|
||||||
g.Meta `path:"/auth/login" method:"post" tags:"User/Auth" summary:"User login"`
|
|
||||||
LoginType string `json:"loginType" v:"required|in:wechat,mobile,password#login type required|unsupported login type"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Mobile string `json:"mobile"`
|
|
||||||
VerifyCode string `json:"verifyCode"`
|
|
||||||
Account string `json:"account"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Terminal string `json:"terminal" v:"required|in:mini,h5,app#terminal required|invalid terminal"`
|
|
||||||
}
|
|
||||||
type LoginRes struct {
|
|
||||||
AccessToken string `json:"accessToken"`
|
|
||||||
RefreshToken string `json:"refreshToken"`
|
|
||||||
ExpiresIn int64 `json:"expiresIn"`
|
|
||||||
UserID uint64 `json:"userId"`
|
|
||||||
}
|
|
||||||
type RefreshReq struct {
|
|
||||||
g.Meta `path:"/auth/refresh" method:"post" tags:"User/Auth" summary:"Rotate user token"`
|
|
||||||
RefreshToken string `json:"refreshToken" v:"required"`
|
|
||||||
}
|
|
||||||
type RefreshRes LoginRes
|
|
||||||
257
api/wallpaper/wallpaper.go
Normal file
257
api/wallpaper/wallpaper.go
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
// Package wallpaper_v1 壁纸模块接口契约。
|
||||||
|
//
|
||||||
|
// 分成两组:
|
||||||
|
// - open 组(/api/service/open/wallpaper/*):给 xpcool.com 前台调用,免鉴权;
|
||||||
|
// - admin 组(/api/service/admin/wallpaper/*):后台管理,走 RBAC 权限。
|
||||||
|
//
|
||||||
|
// 规范(2026-08-27 起):全部 POST;URL 不含参数;入参一律 body。
|
||||||
|
// 唯一例外是「上传」——文件必须走 multipart,其余字段随之走表单而不是 JSON。
|
||||||
|
package wallpaper
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
import "github.com/gogf/gf/v2/net/ghttp"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 公共数据结构
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Item 是壁纸的统一输出条目。开源平台与自建图库填的是同一个结构,
|
||||||
|
// 前端因此只需要一套渲染逻辑。
|
||||||
|
type Item struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Orientation int `json:"orientation"` // 0未知 1横版 2竖版 3方形
|
||||||
|
ThumbUrl string `json:"thumbUrl"` // 列表网格用(480px)
|
||||||
|
PreviewUrl string `json:"previewUrl"` // 全屏展示用(长边 1920)
|
||||||
|
FullUrl string `json:"fullUrl"` // 下载用(原图)
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
FromOpen bool `json:"fromOpen"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
AuthorUrl string `json:"authorUrl"`
|
||||||
|
PageUrl string `json:"pageUrl"`
|
||||||
|
License string `json:"license"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Filesize int64 `json:"filesize"`
|
||||||
|
// Enabled / Sort / Remark 仅自建图库有值,供后台列表展示与就地编辑;
|
||||||
|
// 开源平台条目恒为默认值,前台不关心。
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfo 开源平台的可用性描述。
|
||||||
|
type SourceInfo struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Configured bool `json:"configured"` // 凭据是否齐备
|
||||||
|
Available bool `json:"available"` // 启用 且 凭据齐备
|
||||||
|
Hint string `json:"hint"` // 不可用原因
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
HasApiKey bool `json:"hasApiKey"` // 只回传「是否已设置」,不回传明文
|
||||||
|
// Config 是脱敏后的平台配置,供后台编辑界面回显(前台接口不下发)。
|
||||||
|
// apiKey / apiSecret 恒为空串 —— 提交时留空即表示「不修改已保存的 Key」。
|
||||||
|
Config *SourceConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceConfig 是平台可配置项,与 dto.WallpaperSourceConfig 一一对应。
|
||||||
|
type SourceConfig struct {
|
||||||
|
ApiKey string `json:"apiKey"`
|
||||||
|
ApiSecret string `json:"apiSecret"`
|
||||||
|
DefaultQuery string `json:"defaultQuery"`
|
||||||
|
// Purity 分级过滤(Wallhaven):sfw / sketchy / nsfw,默认只放 sfw。
|
||||||
|
Purity string `json:"purity"`
|
||||||
|
// Categories 分类(Wallhaven):general/anime/people 组合。
|
||||||
|
Categories string `json:"categories"`
|
||||||
|
// BaseUrl 覆盖平台接口地址(自建反代或镜像)。
|
||||||
|
BaseUrl string `json:"baseUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// open 组:前台
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// OpenSourceListReq 取全部来源清单(含开源平台 + 自建图库计数)。
|
||||||
|
type OpenSourceListReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/sources" method:"post" tags:"Open/Wallpaper" summary:"壁纸来源清单"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenSourceListRes struct {
|
||||||
|
List []*SourceInfo `json:"list"`
|
||||||
|
// MineCount 只统计「启用」的自建图片数量,为 0 时前台隐藏「我的图库」入口
|
||||||
|
MineCount int `json:"mineCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenListReq 按来源分页取图。
|
||||||
|
type OpenListReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/list" method:"post" tags:"Open/Wallpaper" summary:"壁纸列表(按来源)"`
|
||||||
|
Source string `json:"source" d:"mine" dc:"来源编码:mine=自建图库,其余为平台编码"`
|
||||||
|
Query string `json:"query" dc:"搜索词(仅部分平台与自建图库支持)"`
|
||||||
|
Orientation int `json:"orientation" dc:"0不限 1横版 2竖版 3方形"`
|
||||||
|
Tag string `json:"tag" dc:"标签(仅自建图库)"`
|
||||||
|
Page int `json:"page" d:"1"`
|
||||||
|
Size int `json:"size" d:"24"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenListRes struct {
|
||||||
|
List []*Item `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRandomReq 跨来源随机取一张。
|
||||||
|
type OpenRandomReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/random" method:"post" tags:"Open/Wallpaper" summary:"随机取一张壁纸"`
|
||||||
|
Sources []string `json:"sources" dc:"限定来源;为空则在所有可用来源中随机"`
|
||||||
|
Orientation int `json:"orientation" dc:"0不限 1横版 2竖版 3方形"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenRandomRes struct {
|
||||||
|
Item *Item `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTrackDownloadReq 通知平台「这张图被下载了」。
|
||||||
|
// 目前只有 Unsplash 需要(其 API 许可的硬性要求),其它平台为空操作。
|
||||||
|
type OpenTrackDownloadReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/download-track" method:"post" tags:"Open/Wallpaper" summary:"下载回调(平台统计)"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenTrackDownloadRes struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// admin 组:自建图库
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AdminListReq 后台图库列表(含已停用项)。
|
||||||
|
type AdminListReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/list" method:"post" tags:"Admin/Wallpaper" summary:"图库列表(含停用)"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Orientation int `json:"orientation"`
|
||||||
|
Enabled int `json:"enabled" d:"-1" dc:"-1全部 0停用 1启用"`
|
||||||
|
Page int `json:"page" d:"1"`
|
||||||
|
Size int `json:"size" d:"24"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminListRes struct {
|
||||||
|
List []*Item `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUploadReq 上传壁纸(文件走 multipart,其余字段为表单字段)。
|
||||||
|
type AdminUploadReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/upload" method:"post" mime:"multipart/form-data" tags:"Admin/Wallpaper" summary:"上传壁纸(可多选)"`
|
||||||
|
Files []*ghttp.UploadFile `json:"files" type:"file" dc:"图片文件,可多选"`
|
||||||
|
Title string `json:"title" dc:"标题,留空则用文件名"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadItem 单个文件的处理结果。
|
||||||
|
// 批量上传时允许部分失败,故逐条返回,而不是整体成功/失败。
|
||||||
|
type UploadItem struct {
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Duplicated bool `json:"duplicated"` // 内容重复,命中了已有图片(秒传)
|
||||||
|
Message string `json:"message"`
|
||||||
|
Item *Item `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUploadRes struct {
|
||||||
|
List []*UploadItem `json:"list"`
|
||||||
|
OkCount int `json:"okCount"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSaveReq 编辑壁纸元数据(不涉及文件)。
|
||||||
|
type AdminSaveReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/save" method:"post" tags:"Admin/Wallpaper" summary:"保存壁纸信息"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSaveRes struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteReq 删除壁纸(同时清理原图与派生图)。
|
||||||
|
type AdminDeleteReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/delete" method:"post" tags:"Admin/Wallpaper" summary:"删除壁纸"`
|
||||||
|
Id uint64 `json:"id" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminDeleteRes struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminStatsReq 图库概览。
|
||||||
|
type AdminStatsReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/stats" method:"post" tags:"Admin/Wallpaper" summary:"图库统计"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminStatsRes struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Disabled int `json:"disabled"`
|
||||||
|
Portrait int `json:"portrait"`
|
||||||
|
Landscape int `json:"landscape"`
|
||||||
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// admin 组:开源平台配置
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AdminSourceListReq 平台配置列表。
|
||||||
|
type AdminSourceListReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/source/list" method:"post" tags:"Admin/Wallpaper" summary:"平台配置列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSourceListRes struct {
|
||||||
|
List []*SourceInfo `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSourceSaveReq 保存平台配置。
|
||||||
|
type AdminSourceSaveReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/source/save" method:"post" tags:"Admin/Wallpaper" summary:"保存平台配置"`
|
||||||
|
Code string `json:"code" v:"required"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
// Config 是平台配置的 JSON 字符串。
|
||||||
|
// 其中 apiKey 留空表示「不修改已保存的 Key」—— 界面不回传密钥明文,
|
||||||
|
// 用户只改开关时不必重新粘贴。
|
||||||
|
Config string `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSourceSaveRes struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSourceTestReq 测试平台连通性。
|
||||||
|
type AdminSourceTestReq struct {
|
||||||
|
g.Meta `path:"/wallpaper/source/test" method:"post" tags:"Admin/Wallpaper" summary:"测试平台连通性"`
|
||||||
|
Code string `json:"code" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSourceTestRes struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
10
common/doc.go
Normal file
10
common/doc.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Package common 是 service.xpcool.com 的公共可复用模块。
|
||||||
|
//
|
||||||
|
// 仅包含与 internal/ 无关的代码——此处的内容可以
|
||||||
|
// 在仓库内跨服务共享,或日后抽取为独立库,
|
||||||
|
// 均无需改动业务代码。
|
||||||
|
//
|
||||||
|
// 当前结构:
|
||||||
|
//
|
||||||
|
// common/tools 公共工具集(md5、cryptox、uuid 等)
|
||||||
|
package common
|
||||||
64
common/tools/convertx/convertx.go
Normal file
64
common/tools/convertx/convertx.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Package convertx 提供带默认值兜底的类型转换工具,
|
||||||
|
// 基于 gconv 构建。
|
||||||
|
//
|
||||||
|
// 注意:gconv 转换失败时静默返回零值,
|
||||||
|
// 因此本工具仅对 nil / 空字符串输入回退到默认值。
|
||||||
|
// 需要严格转换时请传入已校验的数据。
|
||||||
|
package convertx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/util/gconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToInt 将 v 转为 int,v 为 nil 或空字符串时返回 def。
|
||||||
|
func ToInt(v any, def int) int {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToInt64 将 v 转为 int64,v 为 nil 或空字符串时返回 def。
|
||||||
|
func ToInt64(v any, def int64) int64 {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Int64(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToFloat64 将 v 转为 float64,v 为 nil 或空字符串时返回 def。
|
||||||
|
func ToFloat64(v any, def float64) float64 {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Float64(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToString 将 v 转为 string,v 为 nil 时返回 def。
|
||||||
|
func ToString(v any, def string) string {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToBool 将 v 转为 bool,v 为 nil 或空字符串时返回 def。
|
||||||
|
func ToBool(v any, def bool) bool {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Bool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEmpty 判断 v 是否为 nil 或空字符串。
|
||||||
|
func isEmpty(v any) bool {
|
||||||
|
if v == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
80
common/tools/cryptox/cryptox.go
Normal file
80
common/tools/cryptox/cryptox.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Package cryptox 提供 AES/DES 加解密工具(base64 输出),
|
||||||
|
// 基于 gaes 与 gdes 构建。
|
||||||
|
//
|
||||||
|
// 密钥:接受任意长度密钥,内部会归一化为精确密钥尺寸,
|
||||||
|
// 调用方无需关心密钥长度。
|
||||||
|
package cryptox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/crypto/gaes"
|
||||||
|
"github.com/gogf/gf/v2/crypto/gdes"
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
aesKeySize = 16 // AES-128 key size in bytes
|
||||||
|
desKeySize = 8 // DES key size in bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeKey 从任意长度密钥派生出固定尺寸密钥。
|
||||||
|
func normalizeKey(secret string, size int) []byte {
|
||||||
|
key := []byte(secret)
|
||||||
|
if len(key) == size {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(secret))
|
||||||
|
out := make([]byte, size)
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
out[i] = sum[i%len(sum)]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesEncrypt 使用由 secret 派生的密钥对 plainText 做 AES-128-CBC 加密,
|
||||||
|
// 返回 base64 编码的密文。
|
||||||
|
func AesEncrypt(plainText, secret string) (string, error) {
|
||||||
|
out, err := gaes.Encrypt([]byte(plainText), normalizeKey(secret, aesKeySize))
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `AesEncrypt failed`)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesDecrypt 解密 AesEncrypt 产生的 base64 密文。
|
||||||
|
func AesDecrypt(cipherText, secret string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `base64 decode failed`)
|
||||||
|
}
|
||||||
|
out, err := gaes.Decrypt(data, normalizeKey(secret, aesKeySize))
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `AesDecrypt failed`)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesEncrypt 使用由 secret 派生的密钥对 plainText 做 DES-ECB(PKCS5 填充)加密,
|
||||||
|
// 返回 base64 编码的密文。
|
||||||
|
func DesEncrypt(plainText, secret string) (string, error) {
|
||||||
|
out, err := gdes.EncryptECB([]byte(plainText), normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `DesEncrypt failed`)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesDecrypt 解密 DesEncrypt 产生的 base64 密文。
|
||||||
|
func DesDecrypt(cipherText, secret string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `base64 decode failed`)
|
||||||
|
}
|
||||||
|
out, err := gdes.DecryptECB(data, normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `DesDecrypt failed`)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
24
common/tools/doc.go
Normal file
24
common/tools/doc.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Package tools 是项目的公共工具集。
|
||||||
|
//
|
||||||
|
// 每个子包都是对 GoFrame 内置组件的薄封装,
|
||||||
|
// (gmd5、gaes、gdes、guid、grand、gtime、gconv、gstr、gfile 等),
|
||||||
|
// 保持小巧且与框架风格一致。
|
||||||
|
//
|
||||||
|
// 结构:
|
||||||
|
//
|
||||||
|
// common/tools/md5 MD5 摘要工具
|
||||||
|
// common/tools/cryptox AES/DES 加解密(base64 输出)
|
||||||
|
// common/tools/uuid 唯一 ID 生成
|
||||||
|
// common/tools/random 随机数与随机字符串
|
||||||
|
// common/tools/timex 时间格式化与计算
|
||||||
|
// common/tools/convertx 类型转换(带默认值兜底)
|
||||||
|
// common/tools/strx 字符串 / 命名 / 脱敏工具
|
||||||
|
// common/tools/slicex 泛型切片工具
|
||||||
|
// common/tools/ip IP 地址工具
|
||||||
|
// common/tools/filex 文件系统工具
|
||||||
|
//
|
||||||
|
// 规则:
|
||||||
|
// - 禁止依赖 internal/ —— 本模块必须保持自包含。
|
||||||
|
// - 优先复用 GoFrame 内置组件,避免重复造轮子。
|
||||||
|
// - 每个工具保持小巧,并补充中文文档注释。
|
||||||
|
package tools
|
||||||
28
common/tools/filex/filex.go
Normal file
28
common/tools/filex/filex.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// Package filex 基于 gfile 提供常用文件系统工具。
|
||||||
|
package filex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/os/gfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exists 判断 path 对应的文件或目录是否存在。
|
||||||
|
func Exists(path string) bool {
|
||||||
|
return gfile.Exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir 判断 path 是否为目录。
|
||||||
|
func IsDir(path string) bool {
|
||||||
|
return gfile.IsDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadString 以字符串形式返回 path 文件的完整内容,
|
||||||
|
// 文件不存在时返回空字符串。
|
||||||
|
func ReadString(path string) string {
|
||||||
|
return gfile.GetContents(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteString 将 content 写入 path 文件,必要时自动创建中间目录。
|
||||||
|
// (续上一行)
|
||||||
|
func WriteString(path, content string) error {
|
||||||
|
return gfile.PutContents(path, content)
|
||||||
|
}
|
||||||
59
common/tools/ip/ip.go
Normal file
59
common/tools/ip/ip.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Package ip 提供 IP 地址工具。
|
||||||
|
package ip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/net/gipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValid 判断 s 是否为合法的 IPv4 地址。
|
||||||
|
func IsValid(s string) bool {
|
||||||
|
return gipv4.Validate(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalIP 返回本机第一个非回环 IPv4 地址。
|
||||||
|
func LocalIP() (string, error) {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `net.InterfaceAddrs failed`)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if ipNet, ok := addr.(*net.IPNet); ok {
|
||||||
|
if ipv4 := ipNet.IP.To4(); ipv4 != nil && !ipv4.IsLoopback() {
|
||||||
|
return ipv4.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInternal 判断 s 是否为私有/内网 IPv4 地址
|
||||||
|
// (含私有网段、回环地址、链路本地地址)。
|
||||||
|
func IsInternal(s string) bool {
|
||||||
|
parsed := net.ParseIP(s)
|
||||||
|
if parsed == nil || parsed.To4() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToLong 将 IPv4 字符串转为 uint32 数值表示
|
||||||
|
// (大端序,与 inet_aton 一致)。
|
||||||
|
func ToLong(s string) (uint32, error) {
|
||||||
|
ipv4 := net.ParseIP(s).To4()
|
||||||
|
if ipv4 == nil {
|
||||||
|
return 0, gerror.Newf(`invalid IPv4 address: %s`, s)
|
||||||
|
}
|
||||||
|
return uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToString 将 uint32 的 IPv4 值转为点分十进制字符串。
|
||||||
|
func ToString(v uint32) string {
|
||||||
|
return strconv.Itoa(int(v>>24)) + "." +
|
||||||
|
strconv.Itoa(int(v>>16&0xFF)) + "." +
|
||||||
|
strconv.Itoa(int(v>>8&0xFF)) + "." +
|
||||||
|
strconv.Itoa(int(v&0xFF))
|
||||||
|
}
|
||||||
23
common/tools/md5/md5.go
Normal file
23
common/tools/md5/md5.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// Package md5 提供 MD5 摘要工具。
|
||||||
|
package md5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/crypto/gmd5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Md5Hex 返回 s 的 MD5 摘要(小写十六进制字符串)。
|
||||||
|
// 底层错误被忽略,因为对内存输入永远不会失败。
|
||||||
|
func Md5Hex(s string) string {
|
||||||
|
h, _ := gmd5.EncryptString(s)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Md5Bytes 返回 data 的 MD5 摘要(小写十六进制字符串)。
|
||||||
|
func Md5Bytes(data []byte) (string, error) {
|
||||||
|
return gmd5.Encrypt(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Md5File 返回 path 本地文件的 MD5 摘要(十六进制字符串)。
|
||||||
|
func Md5File(path string) (string, error) {
|
||||||
|
return gmd5.EncryptFile(path)
|
||||||
|
}
|
||||||
26
common/tools/random/random.go
Normal file
26
common/tools/random/random.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Package random 提供随机数与随机字符串生成工具。
|
||||||
|
package random
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/util/grand"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Int 返回 [min, max] 区间内的随机整数。
|
||||||
|
func Int(min, max int) int {
|
||||||
|
return grand.N(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 返回长度为 n 的随机字母数字字符串。
|
||||||
|
func String(n int) string {
|
||||||
|
return grand.S(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits 返回长度为 n 的纯数字随机字符串,如短信验证码。
|
||||||
|
func Digits(n int) string {
|
||||||
|
return grand.Digits(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters 返回长度为 n 的纯字母随机字符串。
|
||||||
|
func Letters(n int) string {
|
||||||
|
return grand.Letters(n)
|
||||||
|
}
|
||||||
64
common/tools/slicex/slicex.go
Normal file
64
common/tools/slicex/slicex.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Package slicex 基于标准库提供通用切片工具(Go 1.23+)。
|
||||||
|
// (续上一行)
|
||||||
|
package slicex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contains 判断 items 中是否包含 v。
|
||||||
|
func Contains[T comparable](items []T, v T) bool {
|
||||||
|
return slices.Contains(items, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique 去除 items 中重复元素,保持首次出现顺序。
|
||||||
|
func Unique[T comparable](items []T) []T {
|
||||||
|
seen := make(map[T]struct{}, len(items))
|
||||||
|
out := make([]T, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
if _, ok := seen[v]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = struct{}{}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk 将 items 切分为最多 size 个元素的子切片,
|
||||||
|
// size <= 0 或 items 为空时返回 nil。
|
||||||
|
func Chunk[T any](items []T, size int) [][]T {
|
||||||
|
if size <= 0 || len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([][]T, 0, (len(items)+size-1)/size)
|
||||||
|
for len(items) > 0 {
|
||||||
|
n := size
|
||||||
|
if len(items) < n {
|
||||||
|
n = len(items)
|
||||||
|
}
|
||||||
|
out = append(out, items[:n])
|
||||||
|
items = items[n:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map 对每个元素应用 fn 并返回结果。
|
||||||
|
func Map[T, R any](items []T, fn func(T) R) []R {
|
||||||
|
out := make([]R, len(items))
|
||||||
|
for i, v := range items {
|
||||||
|
out[i] = fn(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter 返回 fn 为 true 的元素,保持原顺序。
|
||||||
|
func Filter[T any](items []T, fn func(T) bool) []T {
|
||||||
|
out := make([]T, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
if fn(v) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
57
common/tools/strx/strx.go
Normal file
57
common/tools/strx/strx.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Package strx 基于 gstr 提供字符串工具,含命名
|
||||||
|
// 转换与敏感数据脱敏。
|
||||||
|
package strx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/text/gstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnakeCase 将 s 转为 snake_case,如 "UserName" -> "user_name"。
|
||||||
|
func SnakeCase(s string) string {
|
||||||
|
return gstr.CaseSnake(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CamelCase 将 s 转为 CamelCase,如 "user_name" -> "UserName"。
|
||||||
|
func CamelCase(s string) string {
|
||||||
|
return gstr.CaseCamel(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LowerCamelCase 将 s 转为 lowerCamelCase,如 "user_name" -> "userName"。
|
||||||
|
func LowerCamelCase(s string) string {
|
||||||
|
return gstr.CaseCamelLower(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty 判断 s 是否为空或仅含空白字符。
|
||||||
|
func IsEmpty(s string) bool {
|
||||||
|
return strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskPhone 对手机号脱敏,保留前 3 位与后 4 位。
|
||||||
|
// 例如 "13812345678" -> "138****5678"。
|
||||||
|
func MaskPhone(s string) string {
|
||||||
|
if len(s) < 7 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:3] + "****" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskIDCard 对身份证号脱敏,保留前 6 位与后 4 位,
|
||||||
|
// 例如 "110101199003074512" -> "110101********4512"。
|
||||||
|
func MaskIDCard(s string) string {
|
||||||
|
if len(s) < 10 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:6] + "********" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskName 对中文姓名脱敏,仅保留首字符。
|
||||||
|
// e.g. "张三丰" -> "张**".
|
||||||
|
func MaskName(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= 1 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[0]) + strings.Repeat("*", len(r)-1)
|
||||||
|
}
|
||||||
46
common/tools/timex/timex.go
Normal file
46
common/tools/timex/timex.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Package timex 基于 gtime 提供时间格式化与计算工具。
|
||||||
|
package timex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LayoutDateTime 是常用日期时间格式:2006-01-02 15:04:05。
|
||||||
|
LayoutDateTime = "2006-01-02 15:04:05"
|
||||||
|
// LayoutDate 是常用日期格式:2006-01-02。
|
||||||
|
LayoutDate = "2006-01-02"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now 返回当前时间。
|
||||||
|
func Now() *gtime.Time {
|
||||||
|
return gtime.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 按给定 Go 布局格式化 t,
|
||||||
|
// layout 为空时使用 LayoutDateTime。
|
||||||
|
//
|
||||||
|
// 注意:gtime v2.10.2 的 Format() 接收 PHP 风格格式("Y-m-d H:i:s"),
|
||||||
|
// 因此本工具改用接受 Go 布局的 Layout() 方法。
|
||||||
|
func Format(t *gtime.Time, layout ...string) string {
|
||||||
|
ly := LayoutDateTime
|
||||||
|
if len(layout) > 0 && layout[0] != "" {
|
||||||
|
ly = layout[0]
|
||||||
|
}
|
||||||
|
return t.Layout(ly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp 返回当前 Unix 秒级时间戳。
|
||||||
|
func Timestamp() int64 {
|
||||||
|
return gtime.Now().Timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartOfDay 返回 t 所在日期的零点(00:00:00)。
|
||||||
|
func StartOfDay(t *gtime.Time) *gtime.Time {
|
||||||
|
return gtime.NewFromStr(t.Layout(LayoutDate) + " 00:00:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndOfDay 返回 t 所在日期的末尾(23:59:59)。
|
||||||
|
func EndOfDay(t *gtime.Time) *gtime.Time {
|
||||||
|
return gtime.NewFromStr(t.Layout(LayoutDate) + " 23:59:59")
|
||||||
|
}
|
||||||
21
common/tools/uuid/uuid.go
Normal file
21
common/tools/uuid/uuid.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Package uuid 提供唯一 ID 生成工具。
|
||||||
|
package uuid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/util/grand"
|
||||||
|
"github.com/gogf/gf/v2/util/guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New 返回不带连字符的 32 位唯一 ID。
|
||||||
|
func New() string {
|
||||||
|
return guid.S()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short 返回长度为 n 的随机字母数字 ID(n <= 0 时默认为 8 位),
|
||||||
|
// 适合短邀请码 / 追踪 ID 场景。
|
||||||
|
func Short(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 8
|
||||||
|
}
|
||||||
|
return grand.S(n)
|
||||||
|
}
|
||||||
23
deploy/Dockerfile
Normal file
23
deploy/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# service.xpcool.com 生产容器镜像(alpine 最小运行时)
|
||||||
|
# 由 .gitea/workflows/deploy.yml 构建;本地手动构建亦可:
|
||||||
|
# cd <部署目录> && docker build -f deploy/Dockerfile -t service.xpcool.com:latest .
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# 时区与健康检查工具
|
||||||
|
RUN apk add --no-cache tzdata curl \
|
||||||
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
|
ENV WORKDIR /app
|
||||||
|
WORKDIR $WORKDIR
|
||||||
|
|
||||||
|
# GoFrame 运行所需:二进制 + 配置(基础 config.yaml + 环境化 config.prod.yaml)+ 静态资源
|
||||||
|
COPY main $WORKDIR/main
|
||||||
|
COPY manifest/config $WORKDIR/manifest/config
|
||||||
|
COPY resource/public $WORKDIR/public
|
||||||
|
COPY resource/template $WORKDIR/template
|
||||||
|
|
||||||
|
RUN chmod +x $WORKDIR/main
|
||||||
|
|
||||||
|
EXPOSE 10100
|
||||||
|
CMD ["./main"]
|
||||||
273
docs/change-log/2026-08-24.md
Normal file
273
docs/change-log/2026-08-24.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# Change Log — 2026-08-24
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
1. 新增一个「公共模块」,模块下存放 `tools` 功能模块,`tools` 下再分各种子功能。
|
||||||
|
2. 建立项目上下文记忆体系:记录每次请求与更改,方案对比后落地;要求后续使用其他 CodeBuddy 账号也能无缝衔接。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
新增文件:
|
||||||
|
|
||||||
|
- `common/doc.go` — 公共模块说明(不依赖 internal、可独立抽取)
|
||||||
|
- `common/tools/doc.go` — tools 模块布局清单与封装规则
|
||||||
|
- `common/tools/md5/md5.go` — MD5 摘要(Md5Hex / Md5Bytes / Md5File)
|
||||||
|
- `common/tools/cryptox/cryptox.go` — AES-128-CBC / DES-ECB 加解密(base64 输出,密钥任意长度自动规范化)
|
||||||
|
- `common/tools/uuid/uuid.go` — 唯一 ID(New 32 位 / Short 短随机码)
|
||||||
|
- `common/tools/random/random.go` — 随机数/随机串(Int / String / Digits / Letters)
|
||||||
|
- `common/tools/timex/timex.go` — 时间工具(Format / Timestamp / StartOfDay / EndOfDay)
|
||||||
|
- `common/tools/convertx/convertx.go` — 类型转换带默认值(ToInt / ToInt64 / ToFloat64 / ToString / ToBool)
|
||||||
|
- `common/tools/strx/strx.go` — 字符串工具(命名转换 SnakeCase/CamelCase + 脱敏 MaskPhone/MaskIDCard/MaskName)
|
||||||
|
- `common/tools/slicex/slicex.go` — 泛型切片工具(Contains / Unique / Chunk / Map / Filter)
|
||||||
|
- `common/tools/ip/ip.go` — IP 工具(IsValid / LocalIP / IsInternal / ToLong / ToString)
|
||||||
|
- `common/tools/filex/filex.go` — 文件工具(Exists / IsDir / ReadString / WriteString)
|
||||||
|
- `AGENTS.md` — 项目智能体说明书(架构、规范、命令、记忆体系索引)
|
||||||
|
- `docs/change-log/2026-08-24.md` — 本文档
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `.gitignore` — 追加 `.workbuddy/`(本机记忆不入库)
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树补充 `common/` 与 `docs/change-log/`(见后续提交)
|
||||||
|
|
||||||
|
验证:`go build ./...` 与 `go vet ./common/...` 全部通过。
|
||||||
|
|
||||||
|
## 决策与理由
|
||||||
|
|
||||||
|
- **公共模块放顶层 `common/` 而非 `internal/common/`**:Go 的 internal 包无法被外部模块引用,放顶层便于未来抽取为独立库/被同仓库其他服务复用。
|
||||||
|
- **tools 一律薄封装 GoFrame 内置组件**:v2.10.2 中 AES/DES 已拆为 `crypto/gaes`、`crypto/gdes`,UUID 为 `util/guid`,无 `gslicer`(用标准库 `slices` 替代);避免重复造轮子,保持与框架一致。
|
||||||
|
- **记忆体系三层方案**(对比见下):
|
||||||
|
1. `AGENTS.md`(根目录)— 长期稳定规范,跨工具标准(Claude Code/CodeBuddy/Codex 等均识别),随 git 走;
|
||||||
|
2. `docs/change-log/YYYY-MM-DD.md` — 每次请求变更的结构化记录,随 git 走,**这是跨账号衔接的关键**;
|
||||||
|
3. `.workbuddy/memory/` — WorkBuddy 本机增强,不入库。
|
||||||
|
- 对比过 `CLAUDE.md`(Claude Code 专属、已建议统一为 AGENTS.md)、`.cursor/rules`(Cursor 专属)、`.codebuddy/`(仅 CodeBuddy 读取)——它们都不是最大公约数,故不采用。
|
||||||
|
|
||||||
|
## 待办与风险
|
||||||
|
|
||||||
|
- 后续每次任务完成后:更新 `docs/change-log/`(当日文件追加)+ 提交 git,确保其他账号 clone 即恢复上下文。
|
||||||
|
- `convertx` 依赖 gconv 的"转换失败返回零值"行为(无法区分"0"与非法输入),需要严格转换的场景应在 service 层先校验。
|
||||||
|
- 本次变更尚未 git 提交,建议尽快 commit。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(17:30):方向纠正 — 「公共模块」实为公共接口
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
用户澄清:要新增的是**给前端调用的公共 HTTP 接口**(此前误建成 Go 工具包),确认应规划到 `api/` 契约层。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
新增文件:
|
||||||
|
|
||||||
|
- `api/common/v1/tools.go` — 公共接口契约:`UUIDReq/Res`、`MD5Req/Res`、`RandomReq/Res`、`TimeReq/Res`、`IPReq/Res`(g.Meta 路由元数据)
|
||||||
|
- `internal/controller/common/tools.go` — 控制器实现,薄适配层,复用 `common/tools/*` Go 包
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `internal/cmd/cmd.go` — 新增公开分组 `s.Group("/api/common/v1", ...)`(Recover+CORS+HandlerResponse,**无鉴权**)
|
||||||
|
- `common/tools/timex/timex.go` — 修复:`Format` 内部改用 `Layout()` 方法(见决策)
|
||||||
|
- `internal/controller/common/tools.go` — `Time` 用 `now.Layout(...)`
|
||||||
|
- `AGENTS.md` — 目录树与新增「公共接口」小节、注意事项补 gtime 与包名差异
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树补充 `api/common/v1`
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;**实际启动服务冒烟测试** 5 个端点全部返回正确(含修复后 time 格式化)。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **路由前缀 `/api/common/v1`**:与 `/api/v1`(user)、`/admin/v1`(admin) 平级的独立公开前缀,天然不套登录鉴权;前端三个端(mini/h5/app)通用。
|
||||||
|
- **契约层放 `api/common/v1`,实现放 `internal/controller/common`**:与 user/admin 完全同构;公共接口不需要 service 层(纯工具计算),controller 直接复用 `common/tools` 包,避免过度分层。
|
||||||
|
- **🐛 gtime v2.10.2 大坑(已修复)**:`Time.Format()` 参数是 **PHP 风格**(`"Y-m-d H:i:s"`),传 Go layout(`"2006-01-02 15:04:05"`)会原样输出!Go layout 必须用 `Time.Layout()`。`common/tools/timex` 已统一封装为 `Layout` 语义,调用方直接 `timex.Format(t, layout...)` 即可。
|
||||||
|
- **冒烟测试教训**:`go run` 会 spawn 子进程,`kill %1` 只杀包装进程,残留的 `main.exe` 会继续占用 8000 端口导致后续测试打到旧代码——杀进程需 `netstat -ano | grep :8000` 找 PID 后 `Stop-Process`。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 本次追加变更同样未提交 git,与上文合并为一次 commit。
|
||||||
|
- 公共接口已开放无鉴权能力(md5/random/uuid 等),后续新增接口时需评审是否应限流/加签名,避免被滥用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(18:05):开放接口命名决策 + API 目录重构
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
1. 变更 API 目录设计:契约层改为 `tools/<子功能>/index.go` 结构(示例 `/api/common/v1/tools/ocr/index.go`)。
|
||||||
|
2. 同步修改所有涉及处(控制器、路由)。
|
||||||
|
3. 把命名规则记入项目记忆。
|
||||||
|
4. 咨询:公共接口一般用 common 命名吗?有没有更好的?
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
新增文件(契约层按子功能拆目录):
|
||||||
|
|
||||||
|
- `api/open/v1/tools/doc.go` — tools 目录结构规则说明
|
||||||
|
- `api/open/v1/tools/uuid/index.go`、`md5/index.go`、`random/index.go`、`time/index.go`、`ip/index.go` — 每子功能一个目录,index.go 内 `package <子功能名>`,g.Meta tags 改 `Open/Tools`
|
||||||
|
- `internal/controller/open/controller.go` — Controller 结构 + New()
|
||||||
|
- `internal/controller/open/uuid.go`、`md5.go`、`random.go`、`time.go`、`ip.go` — 按子功能拆文件,import 对应契约子包
|
||||||
|
|
||||||
|
删除文件:
|
||||||
|
|
||||||
|
- `api/common/v1/tools.go`、`api/common/` 目录
|
||||||
|
- `internal/controller/common/tools.go`、`internal/controller/common/` 目录
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `internal/cmd/cmd.go` — import `commonctl`→`openctl`;路由 `/api/common/v1`→`/api/open/v1`,注释改 Open tools API
|
||||||
|
- `AGENTS.md` — 目录树更新;「公共接口」小节改为「开放接口(api/open/v1)与命名规则」,记录 open 命名决策与 tools 子功能目录规则
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树同步
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;冒烟测试:旧路由 `/api/common/v1/tools/time` 返回 404,新路由 `/api/open/v1/tools/*` 5 端点全部正确。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **命名 common → open**:用户询问"公共接口一般用 common 吗",对比后选 **open**(业界开放接口惯例,如支付宝 /open/api;语义强调对外暴露、无鉴权)。`public` 为并列备选(更强调"公开"),`common` 偏内部通用语义、弃用。命名规则已记入 AGENTS.md「开放接口」小节。
|
||||||
|
- **契约层目录规则**:`api/open/v1/tools/<子功能名>/index.go` 每子功能一目录(与 GoFrame 惯例「api 下按功能分包」一致,用户示例 ocr 即为后续子功能,如未来加 OCR 识别接口即建 `tools/ocr/index.go`);控制器 `internal/controller/open/<子功能名>.go` 对应拆文件(同 package open)。
|
||||||
|
- **路由绑定不受目录重构影响**:GoFrame 通过 controller 方法参数反射定位 g.Meta,契约包拆成多个子包(uuid/md5/random/time/ip)不影响 `group.Bind(openctl.New())` 自动注册,cmd.go 只需改前缀。
|
||||||
|
- 包名 `time`(api/open/v1/tools/time)与标准库 time 潜在同名,controller 中统一用 import 别名 `timeapi` 规避。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 前端若已联调旧 `/api/common/v1` 路径需同步改 `/api/open/v1`(当前无线上前端,风险低)。
|
||||||
|
- 新增子功能记得更新 `api/open/v1/tools/doc.go` 的示例清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(18:08):契约文件 index.go → <功能名>.go
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
用户咨询"子功能目录内用 index.go 还是 <功能名>.go 更易扩展维护",确认推荐后执行改动。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `git mv` 重命名 5 个契约文件:`tools/{uuid,md5,random,time,ip}/index.go` → `tools/{uuid,md5,random,time,ip}/{uuid,md5,random,time,ip}.go`(包内容不变)
|
||||||
|
- `api/open/v1/tools/doc.go` — 规则说明改为「目录名=包名=文件名三一致」,补充"功能变大后目录内加文件"的扩展指引
|
||||||
|
- `AGENTS.md` / `PROJECT_STRUCTURE.md` — 同步 index.go 引用为 <name>.go
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;冒烟测试 5 端点全部正常。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **选 <功能名>.go 而非 index.go**:① 目录名=包名=文件名三一致,导航直观;② index.go 是"入口"语义,功能膨胀后出现 `index.go + idcard.go` 混排会失去入口意义,而 `<name>.go + idcard.go` 自然;③ 符合 Go 生态主流(strings/strings.go)与 GoFrame 官方模板(api/user/v1/user.go)。
|
||||||
|
- **扩展路径已定型**:子功能从 1 个端点到多个端点,只需在目录内加文件(如 ocr 目录 `ocr.go → + idcard.go + invoice.go`),无需重构文件名。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 无新增风险;后续新增子功能统一按 `tools/<name>/<name>.go` 建文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(22:40):后台管理功能开发(后端完成)
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
使用 goframe-v2 开发登录、菜单、按钮级权限等常规后台管理功能(前端 vben5 对接,backend 动态路由模式);登录后台后开发服务器日志管理功能。
|
||||||
|
|
||||||
|
### 变更(commit 8fdb25e,35 文件)
|
||||||
|
|
||||||
|
- `manifest/sql/003_schema_ext.sql`:admin_menu 加 `icon/component/hidden` 列
|
||||||
|
- `manifest/sql/004_seed.sql`:初始 admin/admin123、super_admin 角色、22 条菜单(含按钮权限码)、角色/用户绑定
|
||||||
|
- `manifest/sql/005_menu_paths.sql`:按钮行 path 填 `"METHOD /路径"` 接口映射({id} 动态段)
|
||||||
|
- auth:`/auth/info`、`/auth/codes`;路由拆分公开(NewAuth)/仅登录(NewProfile)/受保护(New)三组;新增 `AdminAuthOnly` 中间件
|
||||||
|
- menu:`/menu/routes` 返回 vben backend 动态路由树(按角色过滤+排序)
|
||||||
|
- RBAC:`/admins`、`/roles`、`/menus/tree` 及 CRUD(含角色绑定、重置密码、菜单授权、删除校验子节点)
|
||||||
|
- log:`/log/files`、`/log/tail`(反向块扫描读尾部+关键词过滤+路径穿越防护)
|
||||||
|
- 安全:接口鉴权改为「方法+路径→权限码」自动映射(`PermissionForPath`+`matchRoute`),**不再信任前端 X-Permission**
|
||||||
|
- 修复:gf v2.10.2 `${ENV}` 不自动替换 → cmd.injectEnv 注入;MySQL driver 需 blank import `contrib/drivers/mysql/v2`;gtime Format(PHP) 与 Layout(Go) 区分再次踩坑(CreatedAt 输出 layout 原样)
|
||||||
|
- `log/` 加入 .gitignore;config.dev.yaml logger.path=log(日志落盘)
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **接口鉴权用路径映射而非 X-Permission**:原实现用户可用自己拥有的任意权限码访问任意受保护接口(越权漏洞);改为后端按 method+path 查 admin_menu 映射,未配置即拒绝。
|
||||||
|
- **受保护接口分三层**:公开 login;AdminAuthOnly(info/codes/routes,登录即可取,vben 登录后立即调用);AdminAuth(RBAC/日志)。
|
||||||
|
- **admin_menu 按钮行 path 存接口映射**:与 type=1 菜单行的路由 path 语义区分开,避免冲突。
|
||||||
|
- **gf gen dao 不可用**:本机 gf CLI 为公司定制版(生成 com.lib.gf.v2 import),与项目官方 gf 不兼容;本次手动补齐 admin_menu 三字段(entity/do/table),后续换官方 CLI 或脚本化处理。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- **前端对接(vben5)**:admin.xpcool.com 需配置 accessMode=backend、登录/信息/权限码/动态路由对接、系统管理三页面+日志监控页面、按钮级 v-access:code。
|
||||||
|
- 冒烟测试已建 opuser/op 测试角色,可清理。
|
||||||
|
- 菜单管理接口的 assignMenu 权限码暂无独立接口(角色授权在 role update 中完成),保留扩展位。
|
||||||
|
- 生产环境 JWT_SECRET/DB_DSN 必须通过环境变量提供(${ENV} 不会自动替换)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:05):前端 vben 对接(源码完成,本地启动验证受阻)
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
继续做 vben 前端(admin.xpcool.com,vben 5.7.0 monorepo),对接后端登录/菜单/按钮级权限/RBAC/日志监控。
|
||||||
|
|
||||||
|
### 变更(前端独立仓库 commit 4615e7c)
|
||||||
|
|
||||||
|
- `.env.development`:`VITE_GLOB_API_URL` 置空、关闭 mock
|
||||||
|
- `vite.config.ts`:代理 `/admin`、`/api` → `http://localhost:8000`
|
||||||
|
- `preferences.ts`:`accessMode: 'backend'`(动态路由)、关闭 token 自动刷新
|
||||||
|
- `api/core/auth.ts`:登录/权限码路径改 `/admin/v1/...`,codes 解包 `data.codes`
|
||||||
|
- `api/core/user.ts`:`/admin/v1/auth/info` 字段映射(adminId→userId、nickname→realName、homePath)
|
||||||
|
- `api/core/menu.ts`:`/admin/v1/menu/routes` 解包 `data.routes`
|
||||||
|
- 新增 `api/system.ts`、`api/log.ts`
|
||||||
|
- 新增页面:`views/system/admin|role|menu/index.vue`(CRUD+按钮级权限)、`views/monitor/log/index.vue`(文件列表/tail/关键词过滤/自动刷新)
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **后端 component 值 `system/admin/index` 与 vben 映射**:vben `normalizeViewPath` 会去前缀、补前导 `/`、去 `/views`,最终匹配 `views/**/*.vue`,无需 `.vue` 后缀。
|
||||||
|
- **前端请求路径写完整 `/admin/v1/...` + apiURL 置空**:因 user(`/api/v1`)、open(`/api/open/v1`)、admin(`/admin/v1`) 前缀不同,写全路径最清晰,避免 proxy rewrite 混乱。
|
||||||
|
- **字段映射在后端/前端约定**:后端返回 `adminId/nickname`,前端映射为 vben `UserInfo(userId/realName)`。
|
||||||
|
|
||||||
|
### 待办与风险(本地启动验证受阻)
|
||||||
|
|
||||||
|
- **node 环境**:system node 23.0.0 未编译 `node:sqlite`(`ERR_UNKNOWN_BUILTIN_MODULE`),pnpm 11.16.0 依赖它;已用 managed node 22.22.2 + corepack wrapper 绕过。
|
||||||
|
- **`pnpm install` 卡住**:已配 `.npmrc`(npmmirror 镜像 + `node-linker=hoisted` + `store-dir=E:/.pnpm-store`),但 install 在 "added 27→98" 反复循环,疑似某 native 依赖 postinstall 失败重试。**dev server 未能启动验证**。
|
||||||
|
- 后续步骤:① 定位卡住的依赖(`pnpm install --reporter=append-only` 看具体包);② 或跳过 postinstall(`pnpm install --ignore-scripts` 后手动补 esbuild 等二进制);③ 完成 install 后 `pnpm dev:antd` 启动,浏览器验证登录/菜单/权限/日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:40):API 层 base/system/admin 分组重构
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
优化 API 层设计:`/api/admin/v1/base`(基础常规)、`/api/admin/v1/system`(menu/role/auth 系统管理)、`/api/admin/v1/admin`(后台管理),并同步 service/controller 层。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `api/admin/v1/` 重组为三子包:`base/log.go`(日志)、`system/{auth,menu,menu_manage,role}.go`(认证+菜单+角色)、`admin/admin.go`(管理员)
|
||||||
|
- 路由前缀变更:`/auth/*`→`/system/auth/*`;`/menu/routes`→`/system/menu/routes`;`/menus`→`/system/menu`;`/roles`→`/system/role`;`/admins`→`/admin`;`/log/*`→`/base/log/*`
|
||||||
|
- `internal/controller/admin/*.go` 改 import 对应子包(basev1/systemv1/adminv1)
|
||||||
|
- `manifest/sql/006_menu_paths_v2.sql`:按钮-接口 path 映射更新;`system:role:assignMenu` 无独立接口,path 清空
|
||||||
|
- 冒烟测试全通过:新路径 8 接口正常,旧路径 `/admins` 返回 Not Found
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **权限码与路由分离**:permission 保持 `system:admin:list` 等逻辑标识不变,只改物理路由 path。管理员管理路由在 `/admin/v1/admin` 但权限码仍 `system:admin:*`(逻辑归属系统权限体系)。
|
||||||
|
- **service/dao 层不硬拆子包**:service 保持 `internal/service` 单包按领域接口组织(GoFrame 惯例),dao 为生成代码按表组织;api/controller 体现 base/system/admin 分组即可。
|
||||||
|
- `assignMenu` 权限码保留(前端按钮),但无独立后端接口(授权合并进 role update),path 置空不映射。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 前端 API 路径需同步为 `/admin/v1/{base,system,admin}` 前缀(当前前端代码仍是旧路径)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:55):前端统一 tdesign UI + 清理无用 app
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
1. 前端 vben5 采用 tdesign UI 库;检查整个前端项目,清理用不到的目录。
|
||||||
|
2. (关联)后端 API 层 base/system/admin 分组重构(见上一条 43fa796)。
|
||||||
|
|
||||||
|
### 变更(前端仓库 commit 6725151)
|
||||||
|
|
||||||
|
- 移除 `apps/web-antd|web-ele|web-naive|web-antdv-next` 和 `backend-mock`,仅保留 `web-tdesign`(此前用户已在工作区删除,本次一并提交)
|
||||||
|
- `web-tdesign` 重新对接后端:`.env.development`(apiURL 置空/关 mock)、`vite.config.ts`(代理 /admin,/api→8000)、`preferences.ts`(accessMode=backend/关 token 刷新)
|
||||||
|
- `api/core/{auth,user,menu}.ts` + `api/{system,log}.ts`:路径对齐 base/system/admin 分组
|
||||||
|
- 页面用 tdesign-vue-next 重写:`views/system/{admin,role,menu}/index.vue`、`views/monitor/log/index.vue`
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **UI 统一 tdesign**:web-tdesign 是 vben 官方 tdesign 应用;页面组件从 ant-design-vue 换成 tdesign-vue-next(t-table/t-dialog/t-tree/MessagePlugin/DialogPlugin)。
|
||||||
|
- **权限码与路由分离(后端)**:permission 保持 `system:admin:list` 逻辑标识,路由改 `/admin/v1/admin`;管理员管理在 admin 分组但权限码仍 system:*(逻辑归属系统权限体系)。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- `pnpm install` 卡住问题仍未解决(node:sqlite 已绕过,但 install 在 native 依赖 postinstall 反复循环),dev server 未启动验证。
|
||||||
|
- 后端已删测试角色 opuser/op 可清理;前端路径已同步 base/system/admin,待 dev 启动后联调验证。
|
||||||
69
docs/change-log/2026-09-13.md
Normal file
69
docs/change-log/2026-09-13.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# 变更日志 — 2026-09-13
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
对整个项目做一次整理:更新陈旧的文档,凡能用中文的地方(注释、文档、提示语、错误信息)一律中文化。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- `README.MD` — **重写**。原文为英文且路由信息陈旧(写的是 `/api/v1`)。现改为中文,并修正为实际的三组路由
|
||||||
|
(`/api/service/open`、`/api/service/user`、`/api/service/admin`),补充分层约定、RBAC 权限映射机制、
|
||||||
|
`gf gen dao` 生成流程、本地启动方式(`.env.dev` / GoLand 运行配置)、构建测试命令。
|
||||||
|
- `PROJECT_STRUCTURE.md` — **重写**。原文为英文且目录树过时(缺 house / recruitment / notice / job / serversecurity
|
||||||
|
等模块)。现改为中文,并按实际结构补全 `api/`、`internal/`、`docs/`、`manifest/` 各层说明,
|
||||||
|
标注「部分模块 entity/do/dao 为手写,勿被生成命令覆盖」这一关键事实。
|
||||||
|
- `hack/hack.mk`、`hack/hack-cli.mk` — 构建脚本注释全部中文化。
|
||||||
|
|
||||||
|
### 源码注释与提示
|
||||||
|
|
||||||
|
- `common/doc.go`、`common/tools/doc.go` — 工具清单由英文改为中文,规则说明中文化。
|
||||||
|
- `common/tools/ip/ip.go` — `IsInternal` / `ToLong` 注释中文化。
|
||||||
|
- `main.go` — MySQL 驱动引入注释中文化。
|
||||||
|
- `internal/cmd/cmd.go` — 路由分组注册处的英文注释中文化(open / user / admin 三处)。
|
||||||
|
- `api/user/login/login.go` — 补充中文包文档,说明该包当前无端点、登录已收敛至 `api/user/auth`。
|
||||||
|
|
||||||
|
### 校验与错误提示中文化
|
||||||
|
|
||||||
|
- **API 层校验消息**(`v:"...#提示"` 中文提示):
|
||||||
|
`api/user/auth/auth.go`(登录方式、终端类型)、`api/open/tools/md5`、`api/open/tools/random`、
|
||||||
|
`api/admin/system/menu_manage`(type)、`api/admin/admin/admin`(密码长度)。
|
||||||
|
- **service 层错误上下文与用户提示**(`gerror.Wrap` / `response.Error`),共涉及 19 个文件、逾 90 处:
|
||||||
|
- `service/admin/admin/admin`、`service/admin/admin/login`、`service/admin/system/{role,menu,menu_manage,login_log}`
|
||||||
|
- `service/user/auth`
|
||||||
|
- `service/house/{community,listing,dashboard,transaction,presale}`
|
||||||
|
- `service/notice`、`service/job`、`service/serversecurity`
|
||||||
|
- `service/recruitment/{recruitment,crawler}`
|
||||||
|
- `internal/library/jwt/jwt.go`、`internal/controller/admin/admin.go`
|
||||||
|
- 模式统一:`gerror.Wrap(err, "query list")` → `gerror.Wrap(err, "查询列表失败")`;
|
||||||
|
用户可见提示如 `"username or password incorrect"` → `"用户名或密码错误"`。
|
||||||
|
- 各 service 的 `panic("Xxx implementation not registered")` → `panic("Xxx 实现未注册")`。
|
||||||
|
|
||||||
|
### 变更记录
|
||||||
|
|
||||||
|
- 新增 `docs/change-log/2026-09-13.md`(本文)。
|
||||||
|
- `.workbuddy/memory/CHANGELOG.md` 顶部追加当日条目。
|
||||||
|
|
||||||
|
## 决策与理由
|
||||||
|
|
||||||
|
- **`README.MD` / `PROJECT_STRUCTURE.md` 采用重写而非增量修补**:两份文档原文均为英文,
|
||||||
|
且路由前缀、目录树与当前代码差异过大(路由从 `/api/v1` 演进为 `/api/service/*` 三分组;
|
||||||
|
新增 house/recruitment/notice/job/serversecurity 五大模块),增量改会造成前后矛盾,故整体重写。
|
||||||
|
- **生成代码(`internal/model/entity`、`internal/model/do`、`internal/dao`)的英文注释保持原样**:
|
||||||
|
这些文件由 `gf gen dao` 生成,头部含 `DO NOT EDIT` 标记,手工中文化会在下次生成时被覆盖,
|
||||||
|
反而制造噪音。符合 AGENTS.md「生成代码禁止手改」的约定。
|
||||||
|
- **错误信息中文化的范围界定**:
|
||||||
|
- 用户可见提示(`response.Error`)必须中文——直接展示给前端/用户;
|
||||||
|
- 内部错误上下文(`gerror.Wrap` 的 message)也一并中文——便于运维/日志排查时统一语义;
|
||||||
|
- 日志文案(`g.Log().Errorf` 等)同步中文,保持日志可读性一致。
|
||||||
|
- **保留英文的技术标识不动**:HTTP 方法名、SQL 关键字、表名/列名、字段名、`cron` 表达式、
|
||||||
|
第三方接口名(Bark / pushplus)、`panic` 中的类型名等属于技术符号,翻译反而降低可检索性。
|
||||||
|
|
||||||
|
## 待办与风险
|
||||||
|
|
||||||
|
- 本次仅做「文档整理 + 中文化」,**未改变任何业务逻辑**;所有改动均为注释、文档与字符串字面量。
|
||||||
|
- `go build ./...` 因当前环境无法访问 `proxy.golang.org`(模块下载超时)未能完整跑通;
|
||||||
|
已用 `read_lints` 对改动目录做静态检查,未发现语法/类型错误。
|
||||||
|
建议在有网络的环境执行一次 `go build ./...` 做最终确认。
|
||||||
|
- 后续新增代码请遵循「中文注释」约定;`gf gen dao` 重新生成后,entity/do/dao 的英文注释属预期现象,无需处理。
|
||||||
95
docs/change-log/2026-09-14.md
Normal file
95
docs/change-log/2026-09-14.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# 变更日志 — 2026-09-14
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
通知模块新增「通知历史记录」表格界面:有分页、有查询,记录每次通知的渠道、类型、分组、详细内容等。
|
||||||
|
前端项目 `E:\Project\admin.xpcool.com` 一起实现并联调。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
### 后端 —— 通知历史记录能力扩展
|
||||||
|
|
||||||
|
- `api/notice/notice.go` — 通知历史记录契约扩展:
|
||||||
|
- `NoticeLogItem` 由 11 字段扩到 27 字段(新增 `batchId`/`ruleName`/`eventName`/`noticeType`/`typeName`/
|
||||||
|
`group`/`groupName`/`channelName`/`userName`/`status`/`statusName`/`retryCount`/`durationMs`/`source`/`remark`)。
|
||||||
|
- `NoticeLogListReq` 新增 `keyword`/`noticeType`/`group`/`userId`/`batchId`/`status`/`orderBy`/`orderDir`;
|
||||||
|
`NoticeLogListRes` 新增 `stats` 统计概览。
|
||||||
|
- 新增端点:`POST /notice/log/detail`(详情)、`/notice/log/delete`(批量删除)、
|
||||||
|
`/notice/log/clear`(按保留天数/时间范围清空)、`/notice/log/options`(字典选项)。
|
||||||
|
- `internal/model/dto/notice_meta.go`(**新增**)— 通知字典:渠道(bark/pushplus/webhook/email/internal)、
|
||||||
|
事件(7 种)、类型(job/security/recruit/test/manual/system)、分组(auto_job/server/recruitment/system)
|
||||||
|
的编码常量 + 中文名映射 + 选项下发 + 事件/类型/分组三级推导函数。
|
||||||
|
- `internal/model/dto/notice.go` — `NoticeLogVO`/`NoticeLogFilter` 同步扩展,并新增 `Normalize()`
|
||||||
|
归一化分页/排序(`Status` 与兼容字段 `Result` 的优先级合并)。
|
||||||
|
- `internal/model/entity/notice.go`、`internal/model/do/notice.go` — `NoticeLog` 新增
|
||||||
|
`batch_id`/`status`/`retry_count`/`duration_ms`/`source`/`remark` 六个字段。
|
||||||
|
- `internal/service/notice/notice.go` —
|
||||||
|
- `LogList`:重写为多维筛选(关键字/类型/分组/事件/渠道/接收人/批次/状态/时间范围/排序)+ 统计概览,
|
||||||
|
列表与统计共用同一套 `logQuery` 条件保证口径一致;批量补齐规则名与接收人(避免 N+1)。
|
||||||
|
- 新增 `LogDetail`/`LogDelete`/`LogClear`。
|
||||||
|
- `Send` 引入 `batchId`(同一次业务触发的多条投递共用);`deliver`/`Test` 记录耗时、来源、状态、备注。
|
||||||
|
- 写库统一改用 `do.NoticeLog` / `do.NoticeRule` / `do.NoticeChannel`(原先用 `map[string]interface{}`,
|
||||||
|
不符合 AGENTS.md「数据库操作必须用 DO 对象」)。
|
||||||
|
- `internal/controller/notice/notice.go` — 新增 `LogDetail`/`LogDelete`/`LogClear`/`MetaOptions`,
|
||||||
|
并抽出 `toLogItem`/`toMetaItems` 做 dto → 契约转换。
|
||||||
|
- `manifest/sql/017_notice_log_enhance.sql`(**新增**)— `notice_log` 加 6 列 + 3 个索引;
|
||||||
|
按旧字段 `result` 回填 `status`;菜单 982 改名「通知历史」;新增 5 条 type=2 接口权限
|
||||||
|
(`notice:log:list/detail/delete/clear/options`);超管角色绑定。
|
||||||
|
|
||||||
|
### 前端 —— `E:\Project\admin.xpcool.com`
|
||||||
|
|
||||||
|
- `apps/web-tdesign/src/api/notice.ts` — 通知历史记录 API 客户端:类型与查询参数扩展,
|
||||||
|
新增 `getNoticeLogDetail` / `deleteNoticeLog` / `clearNoticeLog` / `getNoticeMetaOptions`,
|
||||||
|
以及 `NOTICE_STATUS_OPTIONS` / `noticeStatusTheme` 等展示辅助。
|
||||||
|
- `apps/web-tdesign/src/views/system/notice/log/index.vue` — **重写**为完整「通知历史」页面:
|
||||||
|
- 顶部 4 张统计卡(总数/成功/失败/成功率),随筛选条件同步刷新;
|
||||||
|
- 筛选区 9 个条件(时间范围、关键字、分组、类型、事件、渠道、接收人、状态、排序);
|
||||||
|
- vxe 表格:多选列 + 通知时间/分组/类型/事件/渠道/接收人/标题/详细内容/结果/耗时/错误 + 行操作;
|
||||||
|
- 详情抽屉(基本信息 + 标题/内容/目标/错误分区展示,均支持一键复制);
|
||||||
|
- 批量删除、清空历史(可选「保留最近 N 天」);按权限码控制按钮显隐。
|
||||||
|
|
||||||
|
### 联调
|
||||||
|
|
||||||
|
- 本地库执行 `017_notice_log_enhance.sql`:`notice_log` 现 17 列、3 个新索引;
|
||||||
|
菜单 `982 / 9821-9825` 就位;14 条历史失败记录 `status` 正确回填为 2。
|
||||||
|
- 后端 5 个新接口 + 原有 list 全部通过 RBAC 权限映射,返回 `code=0`。
|
||||||
|
- 真实投递一条测试通知,验证 `batchId` / `durationMs` / `source` / `userName` / `target` / `remark`
|
||||||
|
六项新字段正确落库与回显;统计概览 `1/0/1` 与筛选条件联动正确。
|
||||||
|
- 经前端 Vite 代理(20100 → 10100)完整跑通 登录 → 字典选项 → 分页列表 → 详情。
|
||||||
|
- 前端 `vue-tsc` 类型检查、`oxfmt` 格式检查、`oxlint` 全部 0 错误。
|
||||||
|
- **联调中修复 1 个缺陷**:`LogDetail` 对不存在的 ID 会把 `sql.ErrNoRows` 包装上抛,
|
||||||
|
被 gf 归为内部错误 `code=50` 并回显 SQL 细节;改为与项目既有写法一致
|
||||||
|
(忽略 Scan 错误、以 `Id==0` 判定),现返回 `code=10001`「通知记录不存在」。
|
||||||
|
|
||||||
|
## 决策与理由
|
||||||
|
|
||||||
|
- **类型/分组不落库,由 `event_type` 派生**:二者与事件是一对多的确定映射,落库会引入双写不一致;
|
||||||
|
改为只存事件编码,展示与筛选在服务层推导(`NoticeEventToType` / `NoticeTypeToGroup`)。
|
||||||
|
- **`status` 与旧的 `result` 并存**:`result` 是已有数据在用的字段(1成功/0失败),直接改语义会破坏历史数据。
|
||||||
|
新增 `status`(0待发送/1成功/2失败)表达三态,查询时用 `(status=1 OR (status=0 AND result=1))` 兼容历史行。
|
||||||
|
- **字典选项由后端 `/notice/log/options` 统一下发**:前端原先把事件类型硬编码在页面里
|
||||||
|
(`NOTICE_EVENT_OPTIONS` 只有 4 项,且与 `notice_rule.event_type` 种子语义有偏差),
|
||||||
|
新增事件就要改两处;改为后端单一事实源,前端只做渲染。
|
||||||
|
- **清空提供「保留最近 N 天」而非纯全清**:历史记录是排查问题的依据,全清不可逆;
|
||||||
|
默认保留 30 天,`keepDays=0` 才真正全清,属于对误操作的兜底。
|
||||||
|
- **列表与统计共用 `logQuery`**:避免"列表按分组筛选、统计按全量"这类口径不一致,
|
||||||
|
前端统计卡与表格永远对同一批数据。
|
||||||
|
- **迁移脚本中 `status` 默认值取 0 而非 1**:`ALTER TABLE ADD COLUMN ... DEFAULT 1` 会把**所有历史行**
|
||||||
|
一并置为 1(含失败记录),造成"历史失败记录显示成功"。改为默认 0 后按 `result` 回填,语义正确且可重跑。
|
||||||
|
- **提示文案与枚举中文名收口到 `dto` 层**:中文名映射只在 `notice_meta.go` 定义一次,
|
||||||
|
API、服务、前端展示三处复用,避免同义词漂移。
|
||||||
|
|
||||||
|
## 待办与风险
|
||||||
|
|
||||||
|
- ⚠️ **`internal/service/recruitment/noise_test.go` 是 0 字节空文件**(未跟踪),会让
|
||||||
|
`go build ./...` 与 `go test ./...` 直接报 `expected 'package', found 'EOF'`。
|
||||||
|
本次联调为编译通过曾临时移走,**已原样还原**;需删除该文件或补上包声明。
|
||||||
|
- ⚠️ 工作区另有他人未提交改动:`internal/service/recruitment/bark.go` 修改、
|
||||||
|
`live_diag_test.go` 与 `testdata/rszk.html` 删除;前端 `api/wallpaper.ts`、
|
||||||
|
`views/wallpaper/*` 处于暂存状态。均非本次范围。
|
||||||
|
- 手动「测试发送」在目标凭据为空时会直接返回参数错误、**不写历史记录**(沿用原有行为)。
|
||||||
|
若希望失败尝试也可追溯,需在 `Test` 的提前返回分支补记日志。
|
||||||
|
- 历史记录只增不减,长期需要定期清理策略(目前靠页面「清空」手动处理),
|
||||||
|
后续可考虑挂到 `auto_job` 定时任务上。
|
||||||
|
- 本地联调环境:Node 22.22.2 + corepack `pnpm@11.16.0`;后端 10100、前端 dev 20100,
|
||||||
|
前端 `vite.config.ts` 已把 `/api/service` 代理到 `http://localhost:10100`。
|
||||||
223
docs/house-system-design.md
Normal file
223
docs/house-system-design.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# 看房系统(House)总设计文档
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-08-26 | 状态:开发中(阶段 0/1)
|
||||||
|
> 定位:贵阳购房决策辅助系统,从公开房源数据中筛选「最适合自己的房子」。
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
买房的核心矛盾是「信息高度不对称」。本系统通过 **采集 → 存储 → 管理 → 可视化 → 推送** 五段闭环,把贵阳公开房源数据沉淀成一份可管理、可分析、可对比的个人决策资产。
|
||||||
|
|
||||||
|
- **目标城市**:贵阳(二线/省会,数据源以贝壳/安居客/房天下 + 住建局网签为主)
|
||||||
|
- **房源类型**:新房 + 二手房都做(两套数据模型并存)
|
||||||
|
- **核心诉求**:房价波动分析、楼盘楼栋级地图、多平台报价对比、个性化打分推荐、Bark 推送
|
||||||
|
|
||||||
|
## 2. 总体架构
|
||||||
|
|
||||||
|
### 2.1 五层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
应用展示层 房价地图热力 · 楼盘对比看板 · 个性化榜单
|
||||||
|
分析计算层 价格趋势分析 · 匹配打分排序 · 通勤配套评估
|
||||||
|
数据治理层 清洗与标准化 · 房源去重对齐(改:软关联) · 小区实体映射
|
||||||
|
数据存储层 MySQL(house_* 表) · 价格时序快照
|
||||||
|
数据采集层 多平台采集器 · 调度与限频 · 代理与反反爬
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 落地拓扑(贴合现有项目)
|
||||||
|
|
||||||
|
```
|
||||||
|
贵阳公开数据源(贝壳/安居客/房天下/住建局网签)
|
||||||
|
│ 抓取
|
||||||
|
Python 采集分析服务(独立进程: 采集/清洗/软关联/打分/调度)
|
||||||
|
│ 写库
|
||||||
|
MySQL 共享库(house_* 表) ←── 与 service 同库
|
||||||
|
│ 读写
|
||||||
|
service.xpcool.com(GoFrame v2 · house 模块 REST API)
|
||||||
|
├── REST ──→ admin.xpcool.com(Vue3+Vben: 管理列表 + 可视化看板)
|
||||||
|
└── 推送 ──→ Bark(苹果)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键决策**:Python 只负责「把数据搞干净写进库 + 算分」,不对外提供业务 API;所有查询/推送由 Go 的 house 模块统一暴露,保持单一出口。
|
||||||
|
|
||||||
|
## 3. 技术选型
|
||||||
|
|
||||||
|
| 层 | 选型 | 理由 |
|
||||||
|
|----|------|------|
|
||||||
|
| 后端 | GoFrame v2.10(现有 service 栈) | 复用分层 + RBAC + gf gen dao |
|
||||||
|
| 数据库 | MySQL 8(现有同库) | 贵阳数据量级(房源几十万/快照百万)单机够用,不引 PostGIS/TimescaleDB |
|
||||||
|
| 前端 | Vben Admin 5(web-tdesign)+ echarts@6 + vxe-table@4 | 全部现成依赖,零新增 |
|
||||||
|
| 地图 | 腾讯地图 GL JS(合规)+ DataV GeoJSON | 底图合规、支持 MultiMarker/热力/多边形 |
|
||||||
|
| 采集分析 | Python(Scrapy/httpx + pandas) | 爬虫与数据分析生态最强 |
|
||||||
|
| 推送 | Bark(自建 Server 到腾讯云 / 官方免费版) | 苹果原生推送,一条 HTTP 即可 |
|
||||||
|
|
||||||
|
## 4. 数据源规划(贵阳)
|
||||||
|
|
||||||
|
| 类别 | 数据源 | 备注 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 二手房挂牌 | 贝壳 gy.ke.com、安居客、房天下、58 | 贝壳最规整,MVP 首选 |
|
||||||
|
| 成交/网签 | 贵阳市住建局网签备案、贝壳成交频道 | **成交价是真实价,最大风险点** |
|
||||||
|
| 新房备案价 | 住建局预售许可 + 一房一价备案 | 新房「真价格」来源 |
|
||||||
|
| 配套/通勤 | 高德/百度地图 API | 地铁、学校、商圈 POI + 真实通勤时间 |
|
||||||
|
| 学区划片 | 贵阳市/各区教育局划片文件 | 年度版本,半自动采集 |
|
||||||
|
|
||||||
|
## 5. 数据模型(9 张表)
|
||||||
|
|
||||||
|
### 5.1 house_community 小区/楼盘
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT UNSIGNED PK | |
|
||||||
|
| name | VARCHAR | 小区名 |
|
||||||
|
| region | VARCHAR | 区县(云岩/南明/观山湖/花溪…) |
|
||||||
|
| business_district | VARCHAR | 板块 |
|
||||||
|
| address / lng / lat | VARCHAR / DECIMAL(10,6) | 定位(GCJ-02) |
|
||||||
|
| build_year / households | INT | 建成年份/户数 |
|
||||||
|
| plot_ratio / green_rate | DECIMAL | 容积率/绿化率 |
|
||||||
|
| property_company / property_fee | VARCHAR / DECIMAL | 物业/物业费 |
|
||||||
|
| developer | VARCHAR | 开发商 |
|
||||||
|
|
||||||
|
### 5.2 house_building 楼栋
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| community_id | 所属小区 |
|
||||||
|
| building_no | 栋号 |
|
||||||
|
| units / total_floors / elevator_count / ladder_ratio | 单元/总层/电梯/梯户比 |
|
||||||
|
| building_type | 板楼/塔楼 |
|
||||||
|
| lng / lat | 楼栋级坐标(三级落地:小区中心→楼栋图解析→重点盘人工校准) |
|
||||||
|
|
||||||
|
### 5.3 house_listing 房源/挂牌(核心)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| community_id / building_id / house_no | 归属 |
|
||||||
|
| layout / area / usable_area | 户型 / 建面 / 套内(**建面套内要标准化**) |
|
||||||
|
| orientation / floor / total_floors / decoration | 朝向/楼层/总层/装修 |
|
||||||
|
| total_price / unit_price / list_price | 总价/单价/挂牌价 |
|
||||||
|
| **source / source_house_id / source_url** | 来源平台(**跨平台不去重**) |
|
||||||
|
| **match_group_id** | 疑似同房源软关联(对比用) |
|
||||||
|
| on_market_days / price_change_count | 挂牌天数/调价次数 |
|
||||||
|
| status | 在售/下架/成交 |
|
||||||
|
| confidence / is_bargain | 可信度标记 / 笋盘标记 |
|
||||||
|
|
||||||
|
> 多平台对比策略:同平台 `source+source_house_id` 唯一(防重复抓);跨平台各存一条,用「小区+楼栋+户型+面积±3%+楼层」算相似度打 `match_group_id`,用于「疑似同房源」对比视图(不合并)。
|
||||||
|
|
||||||
|
### 5.4 house_price_snapshot 价格快照(时序)
|
||||||
|
|
||||||
|
`listing_id + snap_date` 唯一;`list_price` / `deal_price` 分列。**趋势分析命脉,长期保留 1–2 年**。
|
||||||
|
|
||||||
|
### 5.5 house_transaction 成交记录
|
||||||
|
|
||||||
|
`deal_price` / `deal_unit_price` / `list_days`(挂牌到成交天数)/ `deal_date`。
|
||||||
|
|
||||||
|
### 5.6 house_facility 配套 POI
|
||||||
|
|
||||||
|
`name` / `type`(地铁/学校/医院/商圈) / `lng/lat` / `line`(地铁线路)。
|
||||||
|
|
||||||
|
### 5.7 house_community_facility 小区-配套关系
|
||||||
|
|
||||||
|
`community_id + facility_id`,`distance`(米) + `commute_minutes`(通勤分钟)。
|
||||||
|
|
||||||
|
### 5.8 house_school_district 学区划片
|
||||||
|
|
||||||
|
`school_name` / `community_id` / `district_polygon`(GeoJSON) / `district_year`(划片年度,版本化)。
|
||||||
|
|
||||||
|
### 5.9 house_preference 用户偏好画像
|
||||||
|
|
||||||
|
`budget_min/max` / `area_min/max` / `layouts`(JSON) / `subway_lines`(JSON) / `school_required` / `commute_target` / `commute_limit_min` / `weights`(权重 JSON)。
|
||||||
|
|
||||||
|
## 6. 后端模块设计(service.xpcool.com)
|
||||||
|
|
||||||
|
### 6.1 目录落位
|
||||||
|
|
||||||
|
```
|
||||||
|
api/house/<resource>/<resource>.go # 契约(g.Meta path/method)
|
||||||
|
internal/controller/house/*.go # 适配层
|
||||||
|
internal/service/house/<resource>/ # 领域服务(接口+实现+Register)
|
||||||
|
internal/model/dto/house.go # 服务边界 dto
|
||||||
|
internal/model/{entity,do} # gf gen dao 生成
|
||||||
|
manifest/sql/010_house_tables.sql # 建表
|
||||||
|
manifest/sql/011_house_menu.sql # 菜单+权限种子
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 接口清单(全 POST 动作式,前缀 /api/service/admin/house)
|
||||||
|
|
||||||
|
**管理 CRUD**:
|
||||||
|
```
|
||||||
|
/house/community/{list|create|update|delete}
|
||||||
|
/house/listing/{list|create|update|delete|batch-mark}
|
||||||
|
/house/snapshot/{list}
|
||||||
|
/house/transaction/{list}
|
||||||
|
/house/facility/{list|create|update|delete}
|
||||||
|
/house/district/{list|create|update|delete}
|
||||||
|
/house/crawl-task/{list|trigger|log}
|
||||||
|
```
|
||||||
|
|
||||||
|
**看板聚合/筛选**(统一 `FilterDto` 入参):
|
||||||
|
```
|
||||||
|
/house/dashboard/{overview|map-points|price-trend|aggregate-region}
|
||||||
|
/house/compare
|
||||||
|
/house/rank
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 权限
|
||||||
|
|
||||||
|
`admin_menu`:type=1 菜单(component 指向前端组件)+ type=2 API 权限(path=`POST /api/service/admin/house/...`);`admin_role_menu` 绑定超管(role_id=1)。
|
||||||
|
|
||||||
|
## 7. 前端模块设计(admin.xpcool.com)
|
||||||
|
|
||||||
|
### 7.1 页面
|
||||||
|
|
||||||
|
| 分组 | 页面 | 组件路径 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 数据管理 | 小区/楼盘管理 | house/community/index |
|
||||||
|
| 数据管理 | 房源管理(筛选/标记/批量) | house/listing/index |
|
||||||
|
| 数据管理 | 价格快照/成交/配套/学区 | house/data/index(后续拆分) |
|
||||||
|
| 可视化看板 | 看板(筛选器+图表联动) | house/dashboard/index |
|
||||||
|
|
||||||
|
### 7.2 交互
|
||||||
|
|
||||||
|
- 管理列表:查询表单 + vxe-table + 分页 + 行内操作 + 批量,复用 RBAC
|
||||||
|
- 看板:全局筛选器(区域/价格/户型/面积/地铁/学区/通勤)驱动图表联动;筛选器→图表、图表交叉过滤、列表↔地图双向;状态放 Pinia
|
||||||
|
|
||||||
|
## 8. Python 采集分析服务
|
||||||
|
|
||||||
|
```
|
||||||
|
house-data/
|
||||||
|
├── crawlers/ 各平台采集器
|
||||||
|
├── pipeline/ 清洗→软关联→标准化→入库
|
||||||
|
├── analysis/ pandas 趋势/议价空间/打分
|
||||||
|
├── scheduler/ APScheduler 调度 + 限频
|
||||||
|
└── notify/ 触发 Bark
|
||||||
|
```
|
||||||
|
|
||||||
|
只写库,不对外 API;独立 git 仓库(建议 E:\xxcool\project\house-data\)。
|
||||||
|
|
||||||
|
## 9. 可视化设计(地图图层)
|
||||||
|
|
||||||
|
底图腾讯地图 GL JS,自下而上叠加:区县/板块边界(DataV GeoJSON) → 地铁线(1/2/3号线 Polyline) → 学区划片(polygon,年度版本) → 楼盘/楼栋点(MultiMarker) → 价格热力(可切换)。
|
||||||
|
|
||||||
|
## 10. 推送设计(Bark)
|
||||||
|
|
||||||
|
| 触发 | level | group | 附 url |
|
||||||
|
|------|-------|-------|--------|
|
||||||
|
| 降价>3% | timeSensitive | 降价 | 跳房源对比页 |
|
||||||
|
| 新房上架 | active | 新房 | 跳详情 |
|
||||||
|
| 划片变更 | timeSensitive | 学区 | 跳小区 |
|
||||||
|
| 每日汇总 | passive | 汇总 | 跳看板 |
|
||||||
|
|
||||||
|
## 11. 分阶段路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 交付 |
|
||||||
|
|------|------|------|
|
||||||
|
| 0 方案定稿 | 1–2 天 | 建表 + gf gen dao + Python 骨架 |
|
||||||
|
| 1 MVP | ~1 周 | 贝壳二手房挂牌 + 价格快照;房源列表/详情 API;列表 + 趋势图 |
|
||||||
|
| 2 分析 | ~1 周 | 成交/网签 + 软关联对比 + 地图热力 + 楼盘对比 |
|
||||||
|
| 3 决策 | ~1 周 | 新房备案价 + 学区/配套 + 偏好打分 |
|
||||||
|
| 4 自动化 | ~1 周 | Bark 推送 + 迁腾讯云 7×24 |
|
||||||
|
|
||||||
|
## 12. 合规与风险
|
||||||
|
|
||||||
|
- 地图:仅腾讯/高德/百度/天地图;区县边界用 DataV 审图号数据;key 走代理不外泄;不采集他人个人位置
|
||||||
|
- 爬虫:遵守 robots、低频、代理池、只存公开信息、个人自用
|
||||||
|
- **最大风险**:贵阳网签/成交数据公开程度不如一线,MVP 用贝壳成交频道兜底
|
||||||
350
docs/recruitment-crawler-design.md
Normal file
350
docs/recruitment-crawler-design.md
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
# 招聘考试抓取推送模块 · 完善设计方案
|
||||||
|
|
||||||
|
> 模块路径:`api/recruitment/`、`internal/controller/recruitment/`、`internal/service/recruitment/`
|
||||||
|
> 独立数据库:`recruitment`(与主库 `service` 隔离)
|
||||||
|
> 文档定位:本次「抓取健壮性」专项设计,供评审后实施。
|
||||||
|
> **实施状态:阶段一已完成(2026-09-14)**,实施中的关键调整见文末「七、实施结果」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状总览
|
||||||
|
|
||||||
|
### 1.1 架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 调度层 auto_job 表(主库 service.auto_job,由 job 模块驱动) │
|
||||||
|
│ ├─ recruit-crawl-hourly 每小时 触发 crawl --all │
|
||||||
|
│ └─ recruit-push-daily 每日08:00 触发 push --daily │
|
||||||
|
└───────────────────────────┬─────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 服务层 internal/service/recruitment/ │
|
||||||
|
│ crawler.go 抓取主流程(静态两级 / SPA 占位 / curl 回退)│
|
||||||
|
│ scheduler.go RegisterTasks 注册 cron 到 auto_job │
|
||||||
|
│ bark.go Bark 推送(单条 + 早报汇总) │
|
||||||
|
│ recruitment.go 查询/统计/订阅 CRUD │
|
||||||
|
└───────────────────────────┬─────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据层 recruitment 库 │
|
||||||
|
│ crawl_source 数据源配置(source_type 1静态 2SPA 3登录 4附件)│
|
||||||
|
│ recruitment_info 公告主表(UNIQUE fingerprint 去重) │
|
||||||
|
│ crawl_log / push_log 运行日志 │
|
||||||
|
│ push_subscription / organization │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 已具备的能力
|
||||||
|
|
||||||
|
| 能力 | 实现位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 静态列表抓取 | `genericStaticCrawl` | 列表页 → 详情页两级抓取 |
|
||||||
|
| 反 WAF | `fetchHTML` | **curl 子进程优先**,失败回退 Go `gclient` |
|
||||||
|
| 编码兼容 | `fetchHTML` | gbk/gb2312 → utf-8 转码 |
|
||||||
|
| 去重 | `fingerprint` | `源ID + 标题 + 日期 + URL` 哈希,唯一索引 |
|
||||||
|
| 跨源聚合 | `group_key` | 标题+日期归一化,识别同公告多源转载 |
|
||||||
|
| 增量控制 | `crawlInfo` | `time.Since(publishDate) > 30天` 跳过 |
|
||||||
|
| 调度 | `auto_job` 表 | 支持启停 / 改 cron / 运行日志 |
|
||||||
|
| 推送 | `bark.go` | 订阅按地区+分类过滤,支持早报汇总 |
|
||||||
|
| 观测 | `crawl_log` / `fail_count` | 记录抓取量、错误、连续失败次数 |
|
||||||
|
|
||||||
|
### 1.3 数据源现状(`002_seed_sources.sql`)
|
||||||
|
|
||||||
|
| 源 | 类型 | 地区 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 贵阳市人社局-人事招考 | 1 静态 | 贵阳 | **启用** |
|
||||||
|
| 贵阳市政府-人事招考 | 1 静态 | 贵阳 | **启用** |
|
||||||
|
| 贵州人事考试信息网 | 2 SPA | 省直 | 禁用(待 POC) |
|
||||||
|
| 贵州国资央企招聘平台 | 2 SPA | 省直 | 禁用(待 POC) |
|
||||||
|
| 贵州茅台集团 | 4 附件型 | 省直 | 禁用(待 POC) |
|
||||||
|
|
||||||
|
> **关键结论**:当前「招聘聚合」实际只覆盖 **2 个贵阳本地静态源**,省直/央企/国企类公告完全未覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、问题清单
|
||||||
|
|
||||||
|
按严重度分级,本次专项聚焦 **P0 / P1**。
|
||||||
|
|
||||||
|
### P0-1 列表页链接过滤过严,存在系统性漏抓
|
||||||
|
|
||||||
|
**位置**:`crawler.go` → `filterArticleAnchors`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 现状伪代码
|
||||||
|
if urlHint && textHint { // 两个条件必须同时满足才认为是公告
|
||||||
|
keep(a)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:`textHint` 要求锚文本包含「招聘/考录/招考/公告」等词。政府站的公告标题变体极多,例如:
|
||||||
|
|
||||||
|
- 「XX局关于2026年**补充**工作人员的**通知**」→ 无「招聘」,漏
|
||||||
|
- 「XX市2026年**公开选调**公务员**简章**」→ 无标准词,漏
|
||||||
|
- 「XX单位**引进**高层次人才**启事**」→ 漏
|
||||||
|
- 列表页若用「more」分页锚点,锚文本为空 → 漏
|
||||||
|
|
||||||
|
**影响**:漏抓是静默的——`crawl_log` 只显示 fetched 变少,不会报错。这是当前最影响数据完整性的缺陷。
|
||||||
|
|
||||||
|
**方案**:改为**「URL 形态命中」为主、「文本命中」放宽**的组合评分:
|
||||||
|
|
||||||
|
```
|
||||||
|
score = 0
|
||||||
|
if url 匹配详情页正则(/art/、/\d{6,}\.html、/info/、content?id=) then score += 2
|
||||||
|
if 文本命中标准词(招聘|考录|招考|选调|遴选|引进|人才|公告|简章|启事|通知) then score += 2
|
||||||
|
if 文本命中排除词(政策解读|常见问题|办事指南|下载中心|联系我们) then score -= 5
|
||||||
|
if 锚文本为空 或 长度<6 then score -= 2
|
||||||
|
if score >= 2 then keep(a)
|
||||||
|
```
|
||||||
|
|
||||||
|
同时把「词表」外置到 `crawl_source.config`,不同站点可定制,无需改代码。
|
||||||
|
|
||||||
|
### P0-2 发布日期误取(整页第一个日期)
|
||||||
|
|
||||||
|
**位置**:`crawler.go` → `extractDetail` 调 `firstDate(html)`
|
||||||
|
|
||||||
|
**问题**:政府详情页头部常有「今天是2026年9月13日 星期五」,`firstDate` 取全页第一个日期即取到它。导致 `publish_date` 全部错成抓取当天,进而:
|
||||||
|
|
||||||
|
- 增量窗口判断失效(永远「30天内」,全量回抓)
|
||||||
|
- 看板趋势图失真(全部堆在当天)
|
||||||
|
- 推送早报的「今日新增」虚高
|
||||||
|
|
||||||
|
**方案**:按优先级分段抽取,取**第一个可信命中**:
|
||||||
|
|
||||||
|
1. 优先 `<meta name="PubDate" / "publishdate" / "og:published_time">`
|
||||||
|
2. 其次带语义容器的正则:`<div class="(time|date|pubdate|info)">...2026-09-13...`
|
||||||
|
3. 再次匹配「发布时间:/发布日期:/日期:」前缀后的日期
|
||||||
|
4. 全部未命中 → **置空**,并在 `crawl_log.error` 记「日期未识别」,而非盲目取第一个
|
||||||
|
|
||||||
|
### P1-3 增量窗口硬编码 30 天
|
||||||
|
|
||||||
|
**位置**:`crawler.go` → `crawlInfo` 内 `30*24*time.Hour`
|
||||||
|
|
||||||
|
**方案**:读取 `crawl_source.config.incrDays`,默认 30,允许按源配置。`Force` 手动触发时忽略该限制(现有 `force` 语义保留)。
|
||||||
|
|
||||||
|
### P1-4 `crawl_source.config` 字段完全未被使用
|
||||||
|
|
||||||
|
**现状**:表里有 `config VARCHAR(1000) COMMENT '适配器扩展配置(JSON)'`,代码中**零引用**。
|
||||||
|
|
||||||
|
**方案**:定义并启用 `SourceConfig` 结构,让该字段真正生效:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"incrDays": 30, // 增量窗口天数
|
||||||
|
"listSelector": "ul.list li a", // 列表链接选择器(覆盖默认猜测)
|
||||||
|
"detailSelector": ".content", // 详情正文容器
|
||||||
|
"includeWords": ["招聘","选调"], // 覆盖默认标准词
|
||||||
|
"excludeWords": ["政策解读"], // 覆盖默认排除词
|
||||||
|
"datePatterns": ["发布时间:(\\d{4}-\\d{2}-\\d{2})"],
|
||||||
|
"maxPages": 3, // 列表分页最大翻页数
|
||||||
|
"delayMs": 800 // 详情页抓取间隔,避免过快
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P1-5 `Sources()` 无 LIMIT 全表扫描
|
||||||
|
|
||||||
|
**位置**:`recruitment.go` → `Sources()`
|
||||||
|
|
||||||
|
```go
|
||||||
|
_ = dao.CrawlLog.Ctx(ctx).OrderDesc("id").Scan(&logs) // 无分页
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:`crawl_log` 只增不减,每次打开「数据源状态」页都全量拉取到内存,随运行时间线性劣化。
|
||||||
|
|
||||||
|
**方案**:改为**取每个源最近 1 条**。两种实现任选:
|
||||||
|
|
||||||
|
- 子查询:`WHERE id IN (SELECT MAX(id) FROM crawl_log GROUP BY source_id)`
|
||||||
|
- 或新增 `crawl_source.last_log_*` 冗余列(写入时同步),查询零 JOIN
|
||||||
|
|
||||||
|
推荐后者,顺带解决 P1-6。
|
||||||
|
|
||||||
|
### P1-6 失败无告警、无自动禁用
|
||||||
|
|
||||||
|
**现状**:`fail_count` 累加了,但**没有任何消费者**。源静默失效(改版/封禁)不会被发现。
|
||||||
|
|
||||||
|
**方案**:
|
||||||
|
- 连续失败达阈值(默认 5)→ 自动置 `enabled=0`,并推送一条「数据源已自动禁用」告警;
|
||||||
|
- 抓取成功 → `fail_count` 归零(当前是否归零需确认,应立即归零);
|
||||||
|
- 可选:失败达 3 次时先推送一次预警(未禁用)。
|
||||||
|
|
||||||
|
### P2-7 调试日志残留
|
||||||
|
|
||||||
|
**位置**:`crawler.go` 中 6 处 `g.Log().Warningf(ctx, "[recruit-debug] ...")`
|
||||||
|
|
||||||
|
**方案**:删除,或降为 `Debugf`。生产日志不应被调试信息污染。
|
||||||
|
|
||||||
|
### P2-8 Bark 模块英文日志(翻译遗漏)
|
||||||
|
|
||||||
|
**位置**:`bark.go` 第 92 / 98 / 128 / 174 行附近(`load subscriptions failed` 等)
|
||||||
|
|
||||||
|
**方案**:按项目「中文优先」约定统一中文化。
|
||||||
|
|
||||||
|
### P2-9 `push_time` 字段是死配置
|
||||||
|
|
||||||
|
**位置**:`push_subscription.push_time` vs `scheduler.go` 的 `recruit-push-daily` 固定 `0 0 8 * * *`
|
||||||
|
|
||||||
|
**问题**:订阅里设 09:30 不生效,永远 08:00 推送。
|
||||||
|
|
||||||
|
**方案**(本次不做,列入后续):`recruit-push-daily` 改为每小时跑一次,只处理 `push_time` 落在当前小时的订阅;或用动态 cron 按订阅分时注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、SPA / 附件型源接入(后续阶段)
|
||||||
|
|
||||||
|
本次不做,但设计上预留。三个禁用源的技术路径预判:
|
||||||
|
|
||||||
|
| 源 | 预判路径 | 难度 |
|
||||||
|
|---|---|---|
|
||||||
|
| 贵州人事考试信息网 | hash 路由背后通常是 `POST /api/xxx/list` 返回 JSON,需抓包定位接口 | 中 |
|
||||||
|
| 贵州国资央企招聘平台(iguopin) | 国聘系平台接口形态统一,通常有公开列表 API | 中 |
|
||||||
|
| 茅台集团官网 | 附件型:列表页→详情页→PDF 附件→解析 PDF 文本 | 高 |
|
||||||
|
|
||||||
|
**建议**:新增 `source_type=5 接口型`,`config` 存接口地址与字段映射,用 JSONPath 提取。这样 SPA 源无需模拟浏览器,直接调底层接口,稳定且快。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、表结构变更(本次仅 P1 相关)
|
||||||
|
|
||||||
|
沿用「不破坏现有数据」原则,全部为**新增列**,无需数据迁移。
|
||||||
|
|
||||||
|
### 4.1 `crawl_source` 新增列
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `crawl_source`
|
||||||
|
ADD COLUMN `last_log_at` DATETIME NULL DEFAULT NULL COMMENT '最近一次抓取时间(冗余,避免全表扫描 crawl_log)' AFTER `fail_count`,
|
||||||
|
ADD COLUMN `last_log_fetched` INT NOT NULL DEFAULT 0 COMMENT '最近一次抓取条数(冗余)' AFTER `last_log_at`,
|
||||||
|
ADD COLUMN `last_log_new` INT NOT NULL DEFAULT 0 COMMENT '最近一次新增条数(冗余)' AFTER `last_log_fetched`,
|
||||||
|
ADD COLUMN `last_log_error` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '最近一次错误信息(冗余)' AFTER `last_log_new`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 迁移脚本
|
||||||
|
|
||||||
|
新增 `manifest/sql/recruitment/004_crawl_source_status.sql`,幂等(`ADD COLUMN IF NOT EXISTS` 或部署前判存在)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、实施计划
|
||||||
|
|
||||||
|
### 阶段一:抓取健壮性(本次,P0 + P1)
|
||||||
|
|
||||||
|
| # | 任务 | 涉及文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 链接过滤改为评分制 + 词表可配 | `crawler.go` |
|
||||||
|
| 2 | 发布日期分段抽取 + 未识别记日志 | `crawler.go` |
|
||||||
|
| 3 | 增量窗口读 `config.incrDays` | `crawler.go` |
|
||||||
|
| 4 | 启用 `SourceConfig` 结构(含选择器覆盖、限速) | `crawler.go`(新增 `source_config.go`) |
|
||||||
|
| 5 | `Sources()` 查最近日志改为冗余列 | `recruitment.go` + 表变更 |
|
||||||
|
| 6 | 失败达阈值自动禁用 + 告警推送 | `crawler.go` + `bark.go` |
|
||||||
|
| 7 | 删除调试日志、`bark.go` 中文化 | `crawler.go`、`bark.go` |
|
||||||
|
| 8 | 迁移脚本 `004_crawl_source_status.sql` | `manifest/sql/recruitment/` |
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 2 个启用源抓取条数不低于手工核对数量(目标:不漏抓);
|
||||||
|
- `publish_date` 与页面实际发布日期一致(抽样 10 条);
|
||||||
|
- `crawl_log` 可按源查看最近一次结果;
|
||||||
|
- 手动触发 `force=true` 可绕过增量窗口全量回溯。
|
||||||
|
|
||||||
|
### 阶段二:推送体系(后续)
|
||||||
|
|
||||||
|
- `push_time` 真正生效(订阅分时推送);
|
||||||
|
- 推送结果统计(订阅级成功率);
|
||||||
|
- 失败重试(Bark 失败重试 2 次,指数退避)。
|
||||||
|
|
||||||
|
### 阶段三:源扩展(后续)
|
||||||
|
|
||||||
|
- `source_type=5 接口型` + JSONPath 映射;
|
||||||
|
- 接入贵州人事考试信息网、国资央企平台;
|
||||||
|
- 茅台附件型:PDF 下载 + 文本抽取。
|
||||||
|
|
||||||
|
### 阶段四:运维增强(后续)
|
||||||
|
|
||||||
|
- `crawl_log` / `push_log` 定期归档(保留 90 天);
|
||||||
|
- 抓取质量日报(新增数、失败源、异常波动告警)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、风险与注意事项
|
||||||
|
|
||||||
|
1. **抓取频率与合规**:政府站对高频访问敏感,`delayMs` 默认 800ms,单源单次抓取控制在分钟级;建议遵守 robots.txt 与站点条款。
|
||||||
|
2. **词表误伤**:评分制若阈值过低会引入噪声(如「招聘会预告」),需先用真实列表页离线验证,再上线。
|
||||||
|
3. **日期置空的影响**:P0-2 改为「未识别则置空」后,依赖 `publish_date` 的统计会短期波动,属预期(此前是错误数据)。
|
||||||
|
4. **自动禁用需谨慎**:阈值过低会因偶发网络抖动误禁。建议结合「连续」失败(中间成功即归零),并推送告警以便人工复核。
|
||||||
|
5. **表变更走迁移脚本**:禁止手工改库;生成代码(若后续跑 `gf gen dao`)需注意本模块 entity 为**手写**,勿被覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施结果(2026-09-14)
|
||||||
|
|
||||||
|
阶段一全部完成。实施过程中基于**真实站点数据**校准,方案有两处重要调整,
|
||||||
|
这两点也是本模块最容易踩的坑,特此记录。
|
||||||
|
|
||||||
|
### 7.1 变更清单
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `internal/service/recruitment/keywords.go` | 新增 | 词表与评分规则(含排除域名) |
|
||||||
|
| `internal/service/recruitment/source_config.go` | 新增 | `crawl_source.config` 解析与默认值兜底 |
|
||||||
|
| `internal/service/recruitment/keywords_test.go` | 新增 | 16 个评分用例 + 标题清洗 + 图片锚点回归 |
|
||||||
|
| `internal/service/recruitment/crawler.go` | 修改 | 评分制过滤、日期分段抽取、限速、自动禁用、图片锚点处理、调试日志清理 |
|
||||||
|
| `internal/service/recruitment/bark.go` | 修改 | 告警推送、英文日志中文化、占位符过滤 |
|
||||||
|
| `internal/service/recruitment/recruitment.go` | 修改 | `Sources()` 改读冗余列(零 JOIN) |
|
||||||
|
| `internal/model/entity/recruitment.go` | 修改 | `CrawlSource` 增 4 个冗余列 |
|
||||||
|
| `internal/model/do/recruitment.go` | 修改 | 同上 |
|
||||||
|
| `manifest/sql/recruitment/004_crawl_source_status.sql` | 新增 | 幂等迁移(存储过程判列存在)+ 启用源 config 初始化 |
|
||||||
|
|
||||||
|
### 7.2 关键调整一:评分以「URL 结尾形态」为准,而非路径关键词
|
||||||
|
|
||||||
|
**设计原方案**是按 URL 路径段(`/zfxxgk`、`/rszk` 等)加权。实测发现此方案**根本性错误**:
|
||||||
|
|
||||||
|
```
|
||||||
|
栏目页: /zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/ ← 以 / 结尾
|
||||||
|
正文页: /zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202609/t20260908_xxx.html ← 以 .html 结尾
|
||||||
|
```
|
||||||
|
|
||||||
|
两者**共享同一段栏目路径**,仅靠路径段无法区分;按路径段减分反而会把真实公告一并误杀
|
||||||
|
(实测第一版即因此把 19 条真实公告全部过滤,只剩 1 条)。
|
||||||
|
|
||||||
|
**最终方案**:只按「结尾形态」判定——
|
||||||
|
- 以 `.html`/`.shtml`/`.htm` 等结尾 → **+3**(正文)
|
||||||
|
- 以 `/` 结尾 → **-5**(栏目目录,必为列表页而非正文)
|
||||||
|
|
||||||
|
该判定经 16 个真实锚点用例验证,公告正文与栏目页/导航页全部分类正确。
|
||||||
|
|
||||||
|
### 7.3 关键调整二:图片型锚点会以「文件名」污染标题
|
||||||
|
|
||||||
|
实测发现 `依申请公开` 未被过滤,根因是:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href=".../ysqgk/index.html"><img src="ysqgk3.png" title="ysqgk3.png" /></a>
|
||||||
|
```
|
||||||
|
|
||||||
|
`stripTags` 把 `<img>` 替换为空白后,`title` 属性回退逻辑取到了 `title="ysqgk3.png"`,
|
||||||
|
于是锚点文本变成 `ysqgk3.png`——**绕过了所有中文排除词**,再叠加 `.html` 的 +3 分通过筛选。
|
||||||
|
|
||||||
|
**处理**:新增 `isImageFileName` 判定,图片型锚点优先取 `alt`,取不到则整体丢弃。
|
||||||
|
|
||||||
|
### 7.4 实测数据对比(源1:贵阳市人社局·人事招考)
|
||||||
|
|
||||||
|
| 指标 | 修复前 | 修复后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 增量模式抓取 | 8 条(含 6 个导航页噪声) | 2 条(全为真实公告) |
|
||||||
|
| 全量回溯(force) | 8 条 | **21 条真实公告** |
|
||||||
|
| 发布日期识别率 | 大量误取(取页面头部日期) | **100%**(0 条为空) |
|
||||||
|
| 标题污染 | 含 `\n\t\t\t` 与图片文件名 | 已清洗 |
|
||||||
|
|
||||||
|
> 增量模式抓取条数少是正确的:30 天窗口内源站确实只发布了 2 条。
|
||||||
|
|
||||||
|
### 7.5 验证方式
|
||||||
|
|
||||||
|
- **单元测试**:`go test ./internal/service/recruitment/` 全绿(4 个测试函数 / 16 个评分用例);
|
||||||
|
- **端到端**:启动服务 → 管理端登录 → 触发抓取 → 核对入库数据与冗余列;
|
||||||
|
- **失败链路**:构造坏源(`http://127.0.0.1:1`),第 5 次失败时 `enabled` 自动置 0,
|
||||||
|
日志输出「数据源 N 连续失败 5 次,已自动禁用」,告警推送按预期优雅降级(本地未配 Bark 仅记警告,不影响抓取)。
|
||||||
|
|
||||||
|
### 7.6 遗留事项
|
||||||
|
|
||||||
|
- 阶段二(推送分时)、阶段三(源扩展)、阶段四(运维增强)未做,见「五、实施计划」。
|
||||||
|
- `push_time` 字段仍未生效(`recruit-push-daily` 固定 08:00),属阶段二范围。
|
||||||
|
- `crawl_source.config` 的 `MaxPages`(翻页)与 `DatePatterns`(自定义日期正则)已实现解析但暂无源使用,
|
||||||
|
待具体站点需要时配置即可。
|
||||||
8
go.mod
8
go.mod
@ -2,7 +2,13 @@ module service.xpcool.com
|
|||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require github.com/gogf/gf/v2 v2.10.2
|
require (
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2
|
||||||
|
github.com/gogf/gf/v2 v2.10.2
|
||||||
|
golang.org/x/image v0.23.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -15,6 +15,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 h1:UdUV+7GhwYLpkwz7VrwIVO/1ZYodyzSL5is25NET24A=
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2/go.mod h1:eKc+0i3Il7efS2BBjmpy7T9wvN9NGRd67ZV94r9behA=
|
||||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@ -68,6 +72,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@ -4,8 +4,12 @@
|
|||||||
gfcli:
|
gfcli:
|
||||||
gen:
|
gen:
|
||||||
dao:
|
dao:
|
||||||
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
|
- link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com"
|
||||||
descriptionTag: true
|
descriptionTag: true
|
||||||
|
# 注意:tables 决定 `gf gen dao` 会生成/覆盖哪些表。
|
||||||
|
# 「只加表不改成生表」等于给下一次 re-gen 埋雷——漏列的表其 entity/do/dao
|
||||||
|
# 会被重新生成的版本覆盖成不完整状态,故新增表必须同步补到这里。
|
||||||
|
tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale,wallpaper,wallpaper_source"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
build: "-a amd64 -s linux -p temp -ew"
|
build: "-a amd64 -s linux -p temp -ew"
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
|
|
||||||
# Install/Update to the latest CLI tool.
|
# 安装/更新到最新的 CLI 工具。
|
||||||
.PHONY: cli
|
.PHONY: cli
|
||||||
cli:
|
cli:
|
||||||
@set -e; \
|
@set -e; \
|
||||||
echo "go install github.com/gogf/gf/cmd/gf/v2@latest"; \
|
echo "go install github.com/gogf/gf/cmd/gf/v2@latest"; \
|
||||||
go install github.com/gogf/gf/cmd/gf/v2@latest; \
|
go install github.com/gogf/gf/cmd/gf/v2@latest; \
|
||||||
echo "GoFame CLI installed successfully!"
|
echo "GoFrame CLI 安装成功!"
|
||||||
|
|
||||||
|
|
||||||
# Check and install CLI tool.
|
# 检查并安装 CLI 工具。
|
||||||
.PHONY: cli.install
|
.PHONY: cli.install
|
||||||
cli.install:
|
cli.install:
|
||||||
@set -e; \
|
@set -e; \
|
||||||
gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \
|
gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \
|
||||||
echo "GoFame CLI is not installed, start proceeding auto installation..."; \
|
echo "GoFrame CLI 未安装,开始自动安装..."; \
|
||||||
make cli; \
|
make cli; \
|
||||||
fi;
|
fi;
|
||||||
22
hack/hack.mk
22
hack/hack.mk
@ -1,37 +1,37 @@
|
|||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
# Update GoFrame and its CLI to latest stable version.
|
# 更新 GoFrame 及 CLI 到最新稳定版。
|
||||||
.PHONY: up
|
.PHONY: up
|
||||||
up: cli.install
|
up: cli.install
|
||||||
@gf up -a
|
@gf up -a
|
||||||
|
|
||||||
# Build binary using configuration from hack/config.yaml.
|
# 使用 hack/config.yaml 中的配置构建二进制。
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: cli.install
|
build: cli.install
|
||||||
@gf build -ew
|
@gf build -ew
|
||||||
|
|
||||||
# Parse api and generate controller/sdk.
|
# 解析 api 目录并生成 controller/sdk。
|
||||||
.PHONY: ctrl
|
.PHONY: ctrl
|
||||||
ctrl: cli.install
|
ctrl: cli.install
|
||||||
@gf gen ctrl
|
@gf gen ctrl
|
||||||
|
|
||||||
# Generate Go files for DAO/DO/Entity.
|
# 生成 DAO/DO/Entity 的 Go 代码。
|
||||||
.PHONY: dao
|
.PHONY: dao
|
||||||
dao: cli.install
|
dao: cli.install
|
||||||
@gf gen dao
|
@gf gen dao
|
||||||
|
|
||||||
# Parse current project go files and generate enums go file.
|
# 解析当前项目 Go 文件并生成枚举文件。
|
||||||
.PHONY: enums
|
.PHONY: enums
|
||||||
enums: cli.install
|
enums: cli.install
|
||||||
@gf gen enums
|
@gf gen enums
|
||||||
|
|
||||||
# Generate Go files for Service.
|
# 生成 Service 接口与实现代码。
|
||||||
.PHONY: service
|
.PHONY: service
|
||||||
service: cli.install
|
service: cli.install
|
||||||
@gf gen service
|
@gf gen service
|
||||||
|
|
||||||
|
|
||||||
# Build docker image.
|
# 构建 Docker 镜像。
|
||||||
.PHONY: image
|
.PHONY: image
|
||||||
image: cli.install
|
image: cli.install
|
||||||
$(eval _TAG = $(shell git rev-parse --short HEAD))
|
$(eval _TAG = $(shell git rev-parse --short HEAD))
|
||||||
@ -43,13 +43,13 @@ endif
|
|||||||
@gf docker ${_PUSH} -tn $(DOCKER_NAME):${_TAG};
|
@gf docker ${_PUSH} -tn $(DOCKER_NAME):${_TAG};
|
||||||
|
|
||||||
|
|
||||||
# Build docker image and automatically push to docker repo.
|
# 构建 Docker 镜像并自动推送到镜像仓库。
|
||||||
.PHONY: image.push
|
.PHONY: image.push
|
||||||
image.push: cli.install
|
image.push: cli.install
|
||||||
@make image PUSH=-p;
|
@make image PUSH=-p;
|
||||||
|
|
||||||
|
|
||||||
# Deploy image and yaml to current kubectl environment.
|
# 部署镜像与 yaml 到当前 kubectl 环境。
|
||||||
.PHONY: deploy
|
.PHONY: deploy
|
||||||
deploy: cli.install
|
deploy: cli.install
|
||||||
$(eval _TAG = $(if ${TAG}, ${TAG}, develop))
|
$(eval _TAG = $(if ${TAG}, ${TAG}, develop))
|
||||||
@ -64,12 +64,12 @@ deploy: cli.install
|
|||||||
fi;
|
fi;
|
||||||
|
|
||||||
|
|
||||||
# Parsing protobuf files and generating go files.
|
# 解析 protobuf 文件并生成 Go 代码。
|
||||||
.PHONY: pb
|
.PHONY: pb
|
||||||
pb: cli.install
|
pb: cli.install
|
||||||
@gf gen pb
|
@gf gen pb
|
||||||
|
|
||||||
# Generate protobuf files for database tables.
|
# 根据数据库表生成 protobuf 文件。
|
||||||
.PHONY: pbentity
|
.PHONY: pbentity
|
||||||
pbentity: cli.install
|
pbentity: cli.install
|
||||||
@gf gen pbentity
|
@gf gen pbentity
|
||||||
@ -5,48 +5,213 @@ import (
|
|||||||
|
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
"github.com/gogf/gf/v2/os/gcmd"
|
"github.com/gogf/gf/v2/os/gcmd"
|
||||||
|
"github.com/gogf/gf/v2/os/genv"
|
||||||
|
"github.com/gogf/gf/v2/text/gstr"
|
||||||
|
"github.com/gogf/gf/v2/util/gconv"
|
||||||
|
|
||||||
adminctl "service.xpcool.com/internal/controller/admin"
|
adminctl "service.xpcool.com/internal/controller/admin"
|
||||||
"service.xpcool.com/internal/controller/hello"
|
housectl "service.xpcool.com/internal/controller/house"
|
||||||
|
jobctl "service.xpcool.com/internal/controller/job"
|
||||||
|
noticectl "service.xpcool.com/internal/controller/notice"
|
||||||
|
openctl "service.xpcool.com/internal/controller/open"
|
||||||
|
recruitmentctl "service.xpcool.com/internal/controller/recruitment"
|
||||||
|
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
|
||||||
userctl "service.xpcool.com/internal/controller/user"
|
userctl "service.xpcool.com/internal/controller/user"
|
||||||
|
wallpaperctl "service.xpcool.com/internal/controller/wallpaper"
|
||||||
|
"service.xpcool.com/internal/library/crypto"
|
||||||
"service.xpcool.com/internal/library/jwt"
|
"service.xpcool.com/internal/library/jwt"
|
||||||
"service.xpcool.com/internal/middleware"
|
"service.xpcool.com/internal/middleware"
|
||||||
"service.xpcool.com/internal/service"
|
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
||||||
|
adminauth "service.xpcool.com/internal/service/admin/admin/login"
|
||||||
|
log "service.xpcool.com/internal/service/admin/base/log"
|
||||||
|
adminaudit "service.xpcool.com/internal/service/admin/system/log"
|
||||||
|
adminloginlog "service.xpcool.com/internal/service/admin/system/login_log"
|
||||||
|
adminmenu "service.xpcool.com/internal/service/admin/system/menu"
|
||||||
|
menu_manage "service.xpcool.com/internal/service/admin/system/menu_manage"
|
||||||
|
role "service.xpcool.com/internal/service/admin/system/role"
|
||||||
|
housecommunity "service.xpcool.com/internal/service/house/community"
|
||||||
|
housedashboard "service.xpcool.com/internal/service/house/dashboard"
|
||||||
|
houselisting "service.xpcool.com/internal/service/house/listing"
|
||||||
|
housepresale "service.xpcool.com/internal/service/house/presale"
|
||||||
|
housetransaction "service.xpcool.com/internal/service/house/transaction"
|
||||||
|
jobsvc "service.xpcool.com/internal/service/job"
|
||||||
|
noticesvc "service.xpcool.com/internal/service/notice"
|
||||||
|
recruitmentsvc "service.xpcool.com/internal/service/recruitment"
|
||||||
|
serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
|
||||||
|
userauth "service.xpcool.com/internal/service/user/auth"
|
||||||
|
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 开发环境默认值:任何启动方式(GoLand / 命令行 / go run)漏注入环境变量时兜底,
|
||||||
|
// 避免 ${DB_DSN} 占位符原样传入 gdb 导致全站接口 500。
|
||||||
|
// 生产(GF_GCFG_ENV=prod)不启用兜底,配置缺失应显性报错。
|
||||||
|
//
|
||||||
|
// 注意:库名以本机实际建库为准(本项目为 service / recruitment)。
|
||||||
|
// 若你的本地库名或账号口令不同,请通过 DB_DSN / RECRUITMENT_DB_DSN 覆盖。
|
||||||
|
const (
|
||||||
|
devDefaultDBDSN = "mysql:service:3103ed27e98fc13b6d60e6571c4a8f76@tcp(127.0.0.1:3306)/service?loc=Local"
|
||||||
|
devDefaultRecruitmentDSN = "mysql:service:3103ed27e98fc13b6d60e6571c4a8f76@tcp(127.0.0.1:3306)/recruitment?loc=Local"
|
||||||
|
devDefaultJWTSecret = "dev-only-secret-not-for-production"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setIfEmpty 仅当 env 存在时写入配置(优先级:env > 配置文件默认值)。
|
||||||
|
func setIfEmpty(adapter *gcfg.AdapterFile, key, envName string) {
|
||||||
|
if v := genv.Get(envName); !v.IsEmpty() {
|
||||||
|
_ = adapter.Set(key, v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// needFill 判断某个配置项是否「等于没配」:不存在、为空串、或仍是未被替换的
|
||||||
|
// ${PLACEHOLDER}。gf v2.10.2 起不再自动替换占位符,容器里跑的 config.yaml
|
||||||
|
// 也可能是旧版(没有对应的配置段),这两种情况都必须回填兜底值。
|
||||||
|
//
|
||||||
|
// 注意 adapter.Get 返回的是 any 而不是 *gvar.Var,取值统一走 gconv。
|
||||||
|
func needFill(ctx context.Context, adapter *gcfg.AdapterFile, key string) bool {
|
||||||
|
v, err := adapter.Get(ctx, key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s := gconv.String(v)
|
||||||
|
return s == "" || gstr.Contains(s, "${")
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectEnv 手动把关键环境变量写入配置系统。
|
||||||
|
// 注意:gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入,
|
||||||
|
// 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。
|
||||||
|
// 开发环境下若 env 缺失,回填本地默认值,保证「零配置可启动」。
|
||||||
|
func injectEnv(ctx context.Context) {
|
||||||
|
adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isProd := genv.Get("GF_GCFG_ENV").String() == "prod"
|
||||||
|
if !isProd {
|
||||||
|
// 开发兜底:仅当配置项仍是未解析的 ${...} 占位符时才写默认值,
|
||||||
|
// 不覆盖 .env.dev / GoLand 运行配置注入的真实值。
|
||||||
|
if v, _ := adapter.Get(ctx, "database.default.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
|
||||||
|
_ = adapter.Set("database.default.link", devDefaultDBDSN)
|
||||||
|
}
|
||||||
|
if v, _ := adapter.Get(ctx, "database.recruitment.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
|
||||||
|
_ = adapter.Set("database.recruitment.link", devDefaultRecruitmentDSN)
|
||||||
|
}
|
||||||
|
if v, _ := adapter.Get(ctx, "jwt.secret"); v != nil && gstr.Contains(gconv.String(v), "${") {
|
||||||
|
_ = adapter.Set("jwt.secret", devDefaultJWTSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 壁纸库路径同理,但要处理**三种**缺失形态:配置项不存在(容器里跑的
|
||||||
|
// config.yaml 可能是旧版、压根没有 wallpaper 段)、值为空、以及占位符
|
||||||
|
// 没被替换。任何一种漏掉都会掉进 NewStorage 的相对路径兜底
|
||||||
|
// (容器内 ./data/wallpaper),表现为「上传成功但 nginx 找不到文件」,
|
||||||
|
// 而且重启容器就全丢 —— 这种故障极难排查,所以这里必须兜死。
|
||||||
|
// 生产兜底到 /data/www/wallpaper(由 nginx 直出),开发兜底到仓库内 data/。
|
||||||
|
if needFill(ctx, adapter, "wallpaper.root") {
|
||||||
|
if isProd {
|
||||||
|
_ = adapter.Set("wallpaper.root", "/data/www/wallpaper")
|
||||||
|
} else {
|
||||||
|
_ = adapter.Set("wallpaper.root", "./data/wallpaper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needFill(ctx, adapter, "wallpaper.baseUrl") {
|
||||||
|
if isProd {
|
||||||
|
_ = adapter.Set("wallpaper.baseUrl", "https://xpcool.com/wallpaper")
|
||||||
|
} else {
|
||||||
|
_ = adapter.Set("wallpaper.baseUrl", "/wallpaper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIfEmpty(adapter, "database.default.link", "DB_DSN")
|
||||||
|
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
|
||||||
|
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
|
||||||
|
setIfEmpty(adapter, "database.recruitment.link", "RECRUITMENT_DB_DSN")
|
||||||
|
setIfEmpty(adapter, "bark.baseUrl", "BARK_BASE_URL")
|
||||||
|
setIfEmpty(adapter, "bark.deviceKey", "BARK_DEVICE_KEY")
|
||||||
|
setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
|
||||||
|
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
|
||||||
|
setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
|
||||||
|
// 登录加密开关(直接跑二进制始终加载 config.yaml,需 env 显式注入):
|
||||||
|
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密(生产);ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
|
||||||
|
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
|
||||||
|
setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN")
|
||||||
|
// 壁纸图库:上传文件的落盘根目录与对外访问前缀。
|
||||||
|
// 生产为 /data/www/wallpaper + https://xpcool.com/wallpaper(由 nginx 直出)。
|
||||||
|
setIfEmpty(adapter, "wallpaper.root", "WALLPAPER_ROOT")
|
||||||
|
setIfEmpty(adapter, "wallpaper.baseUrl", "WALLPAPER_BASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Main = gcmd.Command{
|
Main = gcmd.Command{
|
||||||
Name: "main",
|
Name: "main",
|
||||||
Usage: "main",
|
Usage: "main",
|
||||||
Brief: "start http server",
|
Brief: "start http server",
|
||||||
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
|
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
|
||||||
|
injectEnv(ctx)
|
||||||
s := g.Server()
|
s := g.Server()
|
||||||
tokens := jwt.New(ctx)
|
tokens := jwt.New(ctx)
|
||||||
service.RegisterUserAuth(service.NewUserAuth(tokens, nil, nil))
|
// 登录密码混合加密服务(RSA + AES-GCM):私钥加载/生成失败属致命错误,直接终止启动。
|
||||||
service.RegisterAdminAuth(service.NewAdminAuth(tokens))
|
cryptoSvc, err := crypto.New(ctx)
|
||||||
service.RegisterAdminAudit(service.NewAdminAudit())
|
if err != nil {
|
||||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
panic(err)
|
||||||
group.Middleware(middleware.Recover, middleware.CORS)
|
}
|
||||||
group.Middleware(ghttp.MiddlewareHandlerResponse)
|
userauth.RegisterUserAuth(userauth.NewUserAuth(tokens, nil, nil))
|
||||||
group.Bind(
|
adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens, cryptoSvc))
|
||||||
hello.NewV1(),
|
adminmenu.RegisterAdminMenu(adminmenu.NewAdminMenu())
|
||||||
)
|
admin.RegisterAdminManage(admin.NewAdminManage())
|
||||||
})
|
role.RegisterRoleManage(role.NewRoleManage())
|
||||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
menu_manage.RegisterMenuManage(menu_manage.NewMenuManage())
|
||||||
|
log.RegisterLogManage(log.NewLogManage())
|
||||||
|
adminaudit.RegisterAdminAudit(adminaudit.NewAdminAudit())
|
||||||
|
adminloginlog.RegisterAdminLoginLog(adminloginlog.NewAdminLoginLog())
|
||||||
|
housecommunity.RegisterCommunity(housecommunity.NewCommunity())
|
||||||
|
houselisting.RegisterListing(houselisting.NewListing())
|
||||||
|
housedashboard.RegisterDashboard(housedashboard.NewDashboard())
|
||||||
|
housetransaction.RegisterTransaction(housetransaction.NewTransaction())
|
||||||
|
housepresale.RegisterPresale(housepresale.NewPresale())
|
||||||
|
recruitmentsvc.RegisterRecruitment(recruitmentsvc.New())
|
||||||
|
noticesvc.RegisterNotice(noticesvc.New())
|
||||||
|
jobsvc.RegisterJob(jobsvc.New())
|
||||||
|
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
|
||||||
|
wallpapersvc.RegisterWallpaper(wallpapersvc.New(ctx))
|
||||||
|
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
group.Bind(userctl.New()) // Login and refresh routes are public.
|
group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。
|
||||||
|
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
|
||||||
|
// 壁纸前台读取:xpcool.com 是纯静态站,浏览器直连本服务取数据,故必须免鉴权。
|
||||||
|
group.Bind(wallpaperctl.NewOpen())
|
||||||
|
})
|
||||||
|
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
|
||||||
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
|
group.Bind(userctl.New()) // 登录与刷新令牌接口公开。
|
||||||
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
|
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
|
||||||
})
|
})
|
||||||
s.Group("/admin/v1", func(group *ghttp.RouterGroup) {
|
s.Group("/api/service/admin", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
// 全量请求/响应加密(生产 encrypt.fullBody=true):CORS → APICrypto → Recover → HandlerResponse。
|
||||||
group.Bind(adminctl.New()) // Admin login remains public; protected controllers mount separately.
|
// Recover 置于 APICrypto 内层,使 500 异常响应同样被加密;public-key 端点明文豁免。
|
||||||
|
group.Middleware(middleware.CORS, middleware.APICrypto(cryptoSvc), middleware.Recover, ghttp.MiddlewareHandlerResponse)
|
||||||
|
group.Bind(adminctl.NewAuth()) // 公开端点:仅登录。
|
||||||
|
group.Group("/", func(profile *ghttp.RouterGroup) {
|
||||||
|
// 仅登录端点:个人资料 / 权限码 / 菜单路由。
|
||||||
|
profile.Middleware(middleware.AdminAuthOnly(tokens))
|
||||||
|
profile.Bind(adminctl.NewProfile())
|
||||||
|
})
|
||||||
group.Group("/", func(protected *ghttp.RouterGroup) {
|
group.Group("/", func(protected *ghttp.RouterGroup) {
|
||||||
protected.Middleware(middleware.AdminAuth(tokens, service.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int) {
|
// 受权限保护端点:RBAC 管理、日志等。
|
||||||
service.AdminAudit().Record(ctx, service.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status})
|
// 权限由后端按「方法+路径」自动匹配,无需前端传 X-Permission。
|
||||||
|
protected.Middleware(middleware.AdminAuth(tokens, adminauth.AdminAuth().PermissionForPath, adminauth.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int, userAgent, errorMessage string) {
|
||||||
|
adminaudit.AdminAudit().Record(ctx, adminaudit.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status, ErrorMessage: errorMessage, UserAgent: userAgent})
|
||||||
}))
|
}))
|
||||||
|
protected.Bind(adminctl.New())
|
||||||
|
protected.Bind(housectl.New())
|
||||||
|
protected.Bind(recruitmentctl.New())
|
||||||
|
protected.Bind(noticectl.New())
|
||||||
|
protected.Bind(jobctl.New())
|
||||||
|
protected.Bind(serversecurityctl.NewManage())
|
||||||
|
protected.Bind(wallpaperctl.New())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
// 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。
|
||||||
|
recruitmentsvc.RegisterTasks()
|
||||||
|
jobsvc.Job().StartScheduler(ctx)
|
||||||
s.Run()
|
s.Run()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
93
internal/controller/admin/admin.go
Normal file
93
internal/controller/admin/admin.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
adminv1 "service.xpcool.com/api/admin/admin/admin"
|
||||||
|
"service.xpcool.com/internal/consts"
|
||||||
|
cryptolib "service.xpcool.com/internal/library/crypto"
|
||||||
|
"service.xpcool.com/internal/library/response"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolvePassword 解析创建/重置管理员时的密码:
|
||||||
|
// - 全量加密模式(fullBody):传输层已整体解密,直接使用明文 password;
|
||||||
|
// - 仅密码加密模式:密文(encryptedKey+encryptedData,RSA+AES 混合加密)优先;明文仅开发 allowPlain。
|
||||||
|
func resolvePassword(ctx context.Context, encryptedKey, encryptedData, password string) (string, error) {
|
||||||
|
if cryptolib.Get().FullBody() {
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
if encryptedKey != "" && encryptedData != "" {
|
||||||
|
v, err := cryptolib.Get().DecryptField(ctx, encryptedKey, encryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return "", response.Error(consts.CodeInvalidParam, err.Error())
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if cryptolib.Get().AllowPlain() {
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
return "", response.Error(consts.CodeInvalidParam, "必须提交加密凭据")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList 分页查询管理员。
|
||||||
|
func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (res *adminv1.AdminListRes, err error) {
|
||||||
|
items, total, err := admin.AdminManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*adminv1.AdminItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &adminv1.AdminItem{
|
||||||
|
Id: it.Id, Username: it.Username, Nickname: it.Nickname, Status: it.Status,
|
||||||
|
RoleIds: it.RoleIds, RoleNames: it.RoleNames, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &adminv1.AdminListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreate 创建管理员。
|
||||||
|
func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) {
|
||||||
|
password, err := resolvePassword(ctx, req.EncryptedKey, req.EncryptedData, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := admin.AdminManage().Create(ctx, dto.AdminCreateInput{
|
||||||
|
Username: req.Username, Password: password, Nickname: req.Nickname,
|
||||||
|
BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate 更新管理员。
|
||||||
|
func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateReq) (res *adminv1.AdminUpdateRes, err error) {
|
||||||
|
if err = admin.AdminManage().Update(ctx, dto.AdminUpdateInput{Id: req.Id, Nickname: req.Nickname, Status: req.Status,
|
||||||
|
BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPwd 重置管理员密码。
|
||||||
|
func (c *Controller) AdminResetPwd(ctx context.Context, req *adminv1.AdminResetPwdReq) (res *adminv1.AdminResetPwdRes, err error) {
|
||||||
|
password, err := resolvePassword(ctx, req.EncryptedKey, req.EncryptedData, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = admin.AdminManage().ResetPassword(ctx, req.Id, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminResetPwdRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDelete 删除管理员。
|
||||||
|
func (c *Controller) AdminDelete(ctx context.Context, req *adminv1.AdminDeleteReq) (res *adminv1.AdminDeleteRes, err error) {
|
||||||
|
if err = admin.AdminManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminDeleteRes{}, nil
|
||||||
|
}
|
||||||
@ -2,18 +2,72 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
adminv1 "service.xpcool.com/api/admin/v1"
|
|
||||||
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
|
|
||||||
|
authv1 "service.xpcool.com/api/admin/admin/login"
|
||||||
"service.xpcool.com/internal/model/dto"
|
"service.xpcool.com/internal/model/dto"
|
||||||
"service.xpcool.com/internal/service"
|
auth "service.xpcool.com/internal/service/admin/admin/login"
|
||||||
|
loginlog "service.xpcool.com/internal/service/admin/system/login_log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Controller struct{}
|
// AuthController 仅暴露公开的登录相关端点。
|
||||||
|
type AuthController struct{}
|
||||||
|
|
||||||
func New() *Controller { return &Controller{} }
|
// NewAuth 创建公开的管理端认证控制器(仅登录)。
|
||||||
func (c *Controller) Login(ctx context.Context, req *adminv1.LoginReq) (res *adminv1.LoginRes, err error) {
|
func NewAuth() *AuthController { return &AuthController{} }
|
||||||
p, id, err := service.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: req.Username, Password: req.Password})
|
|
||||||
|
// Login 校验管理员身份并签发令牌对,成功与失败均写入登录日志。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:先解出明文用户名/密码再走原校验逻辑。
|
||||||
|
func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res *authv1.LoginRes, err error) {
|
||||||
|
// 从请求上下文提取来源信息用于登录审计
|
||||||
|
r := ghttp.RequestFromCtx(ctx)
|
||||||
|
ip, ua := "", ""
|
||||||
|
if r != nil {
|
||||||
|
ip = r.GetClientIp()
|
||||||
|
ua = r.Header.Get("User-Agent")
|
||||||
|
}
|
||||||
|
// 解析登录凭据:密文(encryptedKey/encryptedData)优先;明文仅开发环境 allowPlain 时可用。
|
||||||
|
username, password, err := auth.AdminAuth().ResolveLogin(ctx, req.EncryptedKey, req.EncryptedData, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
_ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: "(encrypted)", IP: ip, UserAgent: ua, Status: 0, FailReason: err.Error()})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, id, err := auth.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: username, Password: password})
|
||||||
|
if err != nil {
|
||||||
|
// 登录失败也落库(含失败原因),便于排查异常登录;日志失败不回传
|
||||||
|
_ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: username, IP: ip, UserAgent: ua, Status: 0, FailReason: err.Error()})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 登录成功落库;日志落库失败不影响登录结果
|
||||||
|
_ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: username, IP: ip, UserAgent: ua, Status: 1})
|
||||||
|
return &authv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKey 返回登录加密 RSA 公钥(公开端点,前端登录前获取用于混合加密密码)。
|
||||||
|
func (c *AuthController) PublicKey(ctx context.Context, _ *authv1.PublicKeyReq) (res *authv1.PublicKeyRes, err error) {
|
||||||
|
pub, err := auth.AdminAuth().PublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &adminv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
|
return &authv1.PublicKeyRes{PublicKey: pub}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 使用有效刷新令牌轮换管理员令牌对。
|
||||||
|
func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (res *authv1.RefreshRes, err error) {
|
||||||
|
p, id, err := auth.AdminAuth().Refresh(ctx, req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &authv1.RefreshRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 结束管理员会话。无状态 JWT 的 access token 无法立即作废(等自然过期),
|
||||||
|
// 但携带 refreshToken 时可撤销刷新会话,阻止该设备继续续期。
|
||||||
|
// 幂等设计:即使令牌无效也返回成功,保证前端登出流程不阻塞。
|
||||||
|
func (c *AuthController) Logout(ctx context.Context, req *authv1.LogoutReq) (res *authv1.LogoutRes, err error) {
|
||||||
|
if req.RefreshToken != "" {
|
||||||
|
_ = auth.AdminAuth().Revoke(ctx, req.RefreshToken)
|
||||||
|
}
|
||||||
|
return &authv1.LogoutRes{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
21
internal/controller/admin/controller.go
Normal file
21
internal/controller/admin/controller.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller 实现受权限保护的后台管理端点
|
||||||
|
// (RBAC management, logs, etc.). Bind behind AdminAuth (X-Permission).
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建受保护的后台管理控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
|
|
||||||
|
// adminID 返回认证中间件写入的管理员 id。
|
||||||
|
func adminID(ctx context.Context) uint64 {
|
||||||
|
return g.RequestFromCtx(ctx).GetCtxVar(middleware.AdminIDKey).Uint64()
|
||||||
|
}
|
||||||
58
internal/controller/admin/log.go
Normal file
58
internal/controller/admin/log.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
logv1 "service.xpcool.com/api/admin/base/log"
|
||||||
|
systemlogv1 "service.xpcool.com/api/admin/system/log"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
log "service.xpcool.com/internal/service/admin/base/log"
|
||||||
|
adminlog "service.xpcool.com/internal/service/admin/system/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogFiles 列出服务器日志文件。
|
||||||
|
func (c *Controller) LogFiles(ctx context.Context, req *logv1.LogFilesReq) (res *logv1.LogFilesRes, err error) {
|
||||||
|
dir, files, err := log.LogManage().Files(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*logv1.LogFile, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
list = append(list, &logv1.LogFile{Name: f.Name, Path: f.Path, Size: f.Size, ModTime: f.ModTime})
|
||||||
|
}
|
||||||
|
return &logv1.LogFilesRes{Dir: dir, Files: list}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTail 读取日志文件尾部,支持关键字过滤。
|
||||||
|
func (c *Controller) LogTail(ctx context.Context, req *logv1.LogTailReq) (res *logv1.LogTailRes, err error) {
|
||||||
|
lines, err := log.LogManage().Tail(ctx, req.File, req.Lines, req.Keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &logv1.LogTailRes{Lines: lines}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogList 分页查询 admin 系统日志(操作审计记录)。
|
||||||
|
func (c *Controller) LogList(ctx context.Context, req *systemlogv1.LogListReq) (res *systemlogv1.LogListRes, err error) {
|
||||||
|
items, total, err := adminlog.AdminAudit().List(ctx, dto.LogQuery{
|
||||||
|
Page: req.Page, Size: req.Size, AdminID: req.AdminID, Username: req.Username,
|
||||||
|
IP: req.IP, IpLocation: req.IpLocation, Method: req.Method, Status: req.Status,
|
||||||
|
Keyword: req.Keyword, StartTime: req.StartTime, EndTime: req.EndTime,
|
||||||
|
MinDuration: req.MinDuration, MaxDuration: req.MaxDuration,
|
||||||
|
OrderBy: req.OrderBy, OrderDir: req.OrderDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*systemlogv1.LogItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &systemlogv1.LogItem{
|
||||||
|
Id: it.Id, AdminID: it.AdminID, AdminUsername: it.AdminUsername,
|
||||||
|
Permission: it.Permission, Method: it.Method, Path: it.Path, IP: it.IP,
|
||||||
|
IpLocation: it.IpLocation, Param: it.Param, DurationMS: it.DurationMS,
|
||||||
|
StatusCode: it.StatusCode, ErrorMessage: it.ErrorMessage,
|
||||||
|
UserAgent: it.UserAgent, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &systemlogv1.LogListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
27
internal/controller/admin/login_log.go
Normal file
27
internal/controller/admin/login_log.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
loginlogv1 "service.xpcool.com/api/admin/system/login_log"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
loginlog "service.xpcool.com/internal/service/admin/system/login_log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginLogList 分页查询管理员登录日志。
|
||||||
|
func (c *Controller) LoginLogList(ctx context.Context, req *loginlogv1.LoginLogListReq) (res *loginlogv1.LoginLogListRes, err error) {
|
||||||
|
items, total, err := loginlog.AdminLoginLog().List(ctx, dto.LoginLogQuery{
|
||||||
|
Page: req.Page, Size: req.Size, Username: req.Username, Status: req.Status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*loginlogv1.LoginLogItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &loginlogv1.LoginLogItem{
|
||||||
|
Id: it.Id, Username: it.Username, IP: it.IP, UserAgent: it.UserAgent,
|
||||||
|
Status: it.Status, FailReason: it.FailReason, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &loginlogv1.LoginLogListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
64
internal/controller/admin/menu.go
Normal file
64
internal/controller/admin/menu.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
menuv1 "service.xpcool.com/api/admin/system/menu_manage"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
menu_manage "service.xpcool.com/internal/service/admin/system/menu_manage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MenuTree 返回完整菜单树(菜单 + 按钮权限)。
|
||||||
|
func (c *Controller) MenuTree(ctx context.Context, req *menuv1.MenuTreeReq) (res *menuv1.MenuTreeRes, err error) {
|
||||||
|
tree, err := menu_manage.MenuManage().Tree(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*menuv1.MenuItem, 0, len(tree))
|
||||||
|
for _, n := range tree {
|
||||||
|
out = append(out, menuNodeToV1(n))
|
||||||
|
}
|
||||||
|
return &menuv1.MenuTreeRes{Tree: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreate 创建菜单或按钮节点。
|
||||||
|
func (c *Controller) MenuCreate(ctx context.Context, req *menuv1.MenuCreateReq) (res *menuv1.MenuCreateRes, err error) {
|
||||||
|
id, err := menu_manage.MenuManage().Create(ctx, dto.MenuCreateInput{
|
||||||
|
ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
|
||||||
|
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &menuv1.MenuCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdate 更新菜单或按钮节点。
|
||||||
|
func (c *Controller) MenuUpdate(ctx context.Context, req *menuv1.MenuUpdateReq) (res *menuv1.MenuUpdateRes, err error) {
|
||||||
|
if err = menu_manage.MenuManage().Update(ctx, dto.MenuUpdateInput{
|
||||||
|
Id: req.Id, ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
|
||||||
|
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &menuv1.MenuUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuDelete 删除菜单节点。
|
||||||
|
func (c *Controller) MenuDelete(ctx context.Context, req *menuv1.MenuDeleteReq) (res *menuv1.MenuDeleteRes, err error) {
|
||||||
|
if err = menu_manage.MenuManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &menuv1.MenuDeleteRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuNodeToV1(n *dto.MenuNode) *menuv1.MenuItem {
|
||||||
|
item := &menuv1.MenuItem{
|
||||||
|
Id: n.Id, ParentId: n.ParentId, Name: n.Name, Icon: n.Icon, Type: n.Type, Path: n.Path,
|
||||||
|
Component: n.Component, Permission: n.Permission, Sort: n.Sort, Status: n.Status, Hidden: n.Hidden,
|
||||||
|
}
|
||||||
|
for _, c := range n.Children {
|
||||||
|
item.Children = append(item.Children, menuNodeToV1(c))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
69
internal/controller/admin/profile.go
Normal file
69
internal/controller/admin/profile.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
authv1 "service.xpcool.com/api/admin/admin/login"
|
||||||
|
menuv1 "service.xpcool.com/api/admin/system/menu"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
auth "service.xpcool.com/internal/service/admin/admin/login"
|
||||||
|
menu "service.xpcool.com/internal/service/admin/system/menu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileController 暴露当前登录管理员的资料端点
|
||||||
|
// (login-only, no X-Permission required). Bind behind AdminAuthOnly.
|
||||||
|
type ProfileController struct{}
|
||||||
|
|
||||||
|
// NewProfile 创建资料控制器。
|
||||||
|
func NewProfile() *ProfileController { return &ProfileController{} }
|
||||||
|
|
||||||
|
// Info 返回当前管理员资料。
|
||||||
|
func (c *ProfileController) Info(ctx context.Context, req *authv1.InfoReq) (res *authv1.InfoRes, err error) {
|
||||||
|
info, err := auth.AdminAuth().Info(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &authv1.InfoRes{AdminID: info.AdminID, Username: info.Username, Nickname: info.Nickname, Roles: info.Roles}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codes 返回当前管理员的按钮级权限码。
|
||||||
|
func (c *ProfileController) Codes(ctx context.Context, req *authv1.CodesReq) (res *authv1.CodesRes, err error) {
|
||||||
|
codes, err := auth.AdminAuth().Codes(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &authv1.CodesRes{Codes: codes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes 返回当前管理员的可见菜单树(vben 路由)。
|
||||||
|
func (c *ProfileController) Routes(ctx context.Context, req *menuv1.MenuRoutesReq) (res *menuv1.MenuRoutesRes, err error) {
|
||||||
|
routes, err := menu.AdminMenu().Routes(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*menuv1.RouteItem, 0, len(routes))
|
||||||
|
for _, r := range routes {
|
||||||
|
out = append(out, toV1Route(r))
|
||||||
|
}
|
||||||
|
return &menuv1.MenuRoutesRes{Routes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toV1Route 将 dto 路由树转换为 API 层结构。
|
||||||
|
func toV1Route(r *dto.RouteItem) *menuv1.RouteItem {
|
||||||
|
item := &menuv1.RouteItem{
|
||||||
|
Name: r.Name,
|
||||||
|
Path: r.Path,
|
||||||
|
Component: r.Component,
|
||||||
|
Meta: menuv1.RouteMeta{
|
||||||
|
Title: r.Meta.Title,
|
||||||
|
Icon: r.Meta.Icon,
|
||||||
|
Order: r.Meta.Order,
|
||||||
|
Authority: r.Meta.Authority,
|
||||||
|
HideInMenu: r.Meta.HideInMenu,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range r.Children {
|
||||||
|
item.Children = append(item.Children, toV1Route(c))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
49
internal/controller/admin/role.go
Normal file
49
internal/controller/admin/role.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
rolev1 "service.xpcool.com/api/admin/system/role"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
role "service.xpcool.com/internal/service/admin/system/role"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleList 分页查询角色。
|
||||||
|
func (c *Controller) RoleList(ctx context.Context, req *rolev1.RoleListReq) (res *rolev1.RoleListRes, err error) {
|
||||||
|
items, total, err := role.RoleManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*rolev1.RoleItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &rolev1.RoleItem{
|
||||||
|
Id: it.Id, Code: it.Code, Name: it.Name, Status: it.Status, MenuIds: it.MenuIds, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &rolev1.RoleListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreate 创建角色。
|
||||||
|
func (c *Controller) RoleCreate(ctx context.Context, req *rolev1.RoleCreateReq) (res *rolev1.RoleCreateRes, err error) {
|
||||||
|
id, err := role.RoleManage().Create(ctx, dto.RoleCreateInput{Code: req.Code, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rolev1.RoleCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdate 更新角色。
|
||||||
|
func (c *Controller) RoleUpdate(ctx context.Context, req *rolev1.RoleUpdateReq) (res *rolev1.RoleUpdateRes, err error) {
|
||||||
|
if err = role.RoleManage().Update(ctx, dto.RoleUpdateInput{Id: req.Id, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rolev1.RoleUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleDelete 删除角色。
|
||||||
|
func (c *Controller) RoleDelete(ctx context.Context, req *rolev1.RoleDeleteReq) (res *rolev1.RoleDeleteRes, err error) {
|
||||||
|
if err = role.RoleManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rolev1.RoleDeleteRes{}, nil
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
// =================================================================================
|
|
||||||
// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish.
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
package hello
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
// =================================================================================
|
|
||||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
package hello
|
|
||||||
|
|
||||||
import (
|
|
||||||
"service.xpcool.com/api/hello"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ControllerV1 struct{}
|
|
||||||
|
|
||||||
func NewV1() hello.IHelloV1 {
|
|
||||||
return &ControllerV1{}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package hello
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
|
||||||
|
|
||||||
"service.xpcool.com/api/hello/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *ControllerV1) Hello(ctx context.Context, req *v1.HelloReq) (res *v1.HelloRes, err error) {
|
|
||||||
g.RequestFromCtx(ctx).Response.Writeln("Hello World!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
64
internal/controller/house/community.go
Normal file
64
internal/controller/house/community.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package house
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
communityv1 "service.xpcool.com/api/house/community"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
community "service.xpcool.com/internal/service/house/community"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommunityList 分页查询小区。
|
||||||
|
func (c *Controller) CommunityList(ctx context.Context, req *communityv1.CommunityListReq) (res *communityv1.CommunityListRes, err error) {
|
||||||
|
list, total, err := community.Community().List(ctx, req.Page, req.Size, req.Keyword, req.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*communityv1.CommunityItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
e := &list[i]
|
||||||
|
out = append(out, &communityv1.CommunityItem{
|
||||||
|
Id: e.Id, Name: e.Name, Region: e.Region, BusinessDistrict: e.BusinessDistrict,
|
||||||
|
Address: e.Address, Lng: e.Lng, Lat: e.Lat, BuildYear: e.BuildYear,
|
||||||
|
Households: e.Households, PlotRatio: e.PlotRatio, GreenRate: e.GreenRate,
|
||||||
|
PropertyCompany: e.PropertyCompany, PropertyFee: e.PropertyFee, Developer: e.Developer, Source: e.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &communityv1.CommunityListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityCreate 新增小区。
|
||||||
|
func (c *Controller) CommunityCreate(ctx context.Context, req *communityv1.CommunityCreateReq) (res *communityv1.CommunityCreateRes, err error) {
|
||||||
|
id, err := community.Community().Create(ctx, dto.HouseCommunityInput{
|
||||||
|
Name: req.Name, Region: req.Region, BusinessDistrict: req.BusinessDistrict,
|
||||||
|
Address: req.Address, Lng: req.Lng, Lat: req.Lat, BuildYear: req.BuildYear,
|
||||||
|
Households: req.Households, PlotRatio: req.PlotRatio, GreenRate: req.GreenRate,
|
||||||
|
PropertyCompany: req.PropertyCompany, PropertyFee: req.PropertyFee, Developer: req.Developer, Source: req.Source,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &communityv1.CommunityCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityUpdate 更新小区。
|
||||||
|
func (c *Controller) CommunityUpdate(ctx context.Context, req *communityv1.CommunityUpdateReq) (res *communityv1.CommunityUpdateRes, err error) {
|
||||||
|
err = community.Community().Update(ctx, dto.HouseCommunityInput{
|
||||||
|
Id: req.Id, Name: req.Name, Region: req.Region, BusinessDistrict: req.BusinessDistrict,
|
||||||
|
Address: req.Address, Lng: req.Lng, Lat: req.Lat, BuildYear: req.BuildYear,
|
||||||
|
Households: req.Households, PlotRatio: req.PlotRatio, GreenRate: req.GreenRate,
|
||||||
|
PropertyCompany: req.PropertyCompany, PropertyFee: req.PropertyFee, Developer: req.Developer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &communityv1.CommunityUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityDelete 删除小区。
|
||||||
|
func (c *Controller) CommunityDelete(ctx context.Context, req *communityv1.CommunityDeleteReq) (res *communityv1.CommunityDeleteRes, err error) {
|
||||||
|
if err = community.Community().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &communityv1.CommunityDeleteRes{}, nil
|
||||||
|
}
|
||||||
8
internal/controller/house/controller.go
Normal file
8
internal/controller/house/controller.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Package house 实现看房模块的管理端点,绑定在 admin 受权限保护分组下。
|
||||||
|
package house
|
||||||
|
|
||||||
|
// Controller 实现看房模块的所有端点。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建看房模块控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
67
internal/controller/house/dashboard.go
Normal file
67
internal/controller/house/dashboard.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package house
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
dashboardv1 "service.xpcool.com/api/house/dashboard"
|
||||||
|
dashboard "service.xpcool.com/internal/service/house/dashboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DashboardOverview 看板统计概览。
|
||||||
|
func (c *Controller) DashboardOverview(ctx context.Context, req *dashboardv1.OverviewReq) (res *dashboardv1.OverviewRes, err error) {
|
||||||
|
o, err := dashboard.Dashboard().Overview(ctx, req.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dashboardv1.OverviewRes{
|
||||||
|
CommunityCount: o.CommunityCount, ListingCount: o.ListingCount, BargainCount: o.BargainCount,
|
||||||
|
LowConfidence: o.LowConfidence, AvgUnitPrice: o.AvgUnitPrice, AvgTotalPrice: o.AvgTotalPrice, AvgListDays: o.AvgListDays,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardMapPoints 地图点位聚合。
|
||||||
|
func (c *Controller) DashboardMapPoints(ctx context.Context, req *dashboardv1.MapPointsReq) (res *dashboardv1.MapPointsRes, err error) {
|
||||||
|
pts, err := dashboard.Dashboard().MapPoints(ctx, req.Region, req.PriceMin, req.PriceMax, req.Status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*dashboardv1.MapPoint, 0, len(pts))
|
||||||
|
for i := range pts {
|
||||||
|
p := &pts[i]
|
||||||
|
out = append(out, &dashboardv1.MapPoint{
|
||||||
|
CommunityId: p.CommunityId, Name: p.Name, Region: p.Region, Lng: p.Lng, Lat: p.Lat,
|
||||||
|
AvgUnitPrice: p.AvgUnitPrice, ListingCount: p.ListingCount, BargainCount: p.BargainCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &dashboardv1.MapPointsRes{Points: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardPriceTrend 价格趋势聚合。
|
||||||
|
func (c *Controller) DashboardPriceTrend(ctx context.Context, req *dashboardv1.PriceTrendReq) (res *dashboardv1.PriceTrendRes, err error) {
|
||||||
|
trend, err := dashboard.Dashboard().PriceTrend(ctx, req.CommunityId, req.Region, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*dashboardv1.TrendPoint, 0, len(trend))
|
||||||
|
for i := range trend {
|
||||||
|
t := &trend[i]
|
||||||
|
out = append(out, &dashboardv1.TrendPoint{Date: t.Date, AvgListPrice: t.AvgListPrice, AvgDealPrice: t.AvgDealPrice})
|
||||||
|
}
|
||||||
|
return &dashboardv1.PriceTrendRes{Trend: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardAggregateRegion 区域聚合。
|
||||||
|
func (c *Controller) DashboardAggregateRegion(ctx context.Context, req *dashboardv1.AggregateRegionReq) (res *dashboardv1.AggregateRegionRes, err error) {
|
||||||
|
list, err := dashboard.Dashboard().AggregateRegion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*dashboardv1.RegionAgg, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
r := &list[i]
|
||||||
|
out = append(out, &dashboardv1.RegionAgg{
|
||||||
|
Region: r.Region, AvgUnitPrice: r.AvgUnitPrice, ListingCount: r.ListingCount, BargainCount: r.BargainCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &dashboardv1.AggregateRegionRes{List: out}, nil
|
||||||
|
}
|
||||||
85
internal/controller/house/listing.go
Normal file
85
internal/controller/house/listing.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package house
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
listingv1 "service.xpcool.com/api/house/listing"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
listing "service.xpcool.com/internal/service/house/listing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListingList 分页查询房源(带多维筛选)。
|
||||||
|
func (c *Controller) ListingList(ctx context.Context, req *listingv1.ListingListReq) (res *listingv1.ListingListRes, err error) {
|
||||||
|
list, total, err := listing.Listing().List(ctx, dto.HouseListingFilter{
|
||||||
|
Page: req.Page, Size: req.Size, CommunityId: req.CommunityId, Keyword: req.Keyword,
|
||||||
|
Layout: req.Layout, Region: req.Region, Source: req.Source,
|
||||||
|
PriceMin: req.PriceMin, PriceMax: req.PriceMax, AreaMin: req.AreaMin, AreaMax: req.AreaMax,
|
||||||
|
Status: req.Status, IsBargain: req.IsBargain, Confidence: req.Confidence,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*listingv1.ListingItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &listingv1.ListingItem{
|
||||||
|
Id: v.Id, CommunityId: v.CommunityId, CommunityName: v.CommunityName, BuildingId: v.BuildingId,
|
||||||
|
HouseNo: v.HouseNo, Layout: v.Layout, Area: v.Area, UsableArea: v.UsableArea,
|
||||||
|
Orientation: v.Orientation, Floor: v.Floor, TotalFloors: v.TotalFloors, Decoration: v.Decoration,
|
||||||
|
TotalPrice: v.TotalPrice, UnitPrice: v.UnitPrice, ListPrice: v.ListPrice,
|
||||||
|
Source: v.Source, SourceHouseId: v.SourceHouseId, SourceUrl: v.SourceUrl,
|
||||||
|
MatchGroupId: v.MatchGroupId, OnMarketDays: v.OnMarketDays, PriceChangeCount: v.PriceChangeCount,
|
||||||
|
Status: v.Status, Confidence: v.Confidence, IsBargain: v.IsBargain,
|
||||||
|
ListingTime: v.ListingTime, CreatedAt: v.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &listingv1.ListingListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingCreate 新增房源。
|
||||||
|
func (c *Controller) ListingCreate(ctx context.Context, req *listingv1.ListingCreateReq) (res *listingv1.ListingCreateRes, err error) {
|
||||||
|
id, err := listing.Listing().Create(ctx, dto.HouseListingInput{
|
||||||
|
CommunityId: req.CommunityId, BuildingId: req.BuildingId, HouseNo: req.HouseNo, Layout: req.Layout,
|
||||||
|
Area: req.Area, UsableArea: req.UsableArea, Orientation: req.Orientation, Floor: req.Floor,
|
||||||
|
TotalFloors: req.TotalFloors, Decoration: req.Decoration, TotalPrice: req.TotalPrice, UnitPrice: req.UnitPrice,
|
||||||
|
ListPrice: req.ListPrice, Source: req.Source, SourceHouseId: req.SourceHouseId, SourceUrl: req.SourceUrl,
|
||||||
|
MatchGroupId: req.MatchGroupId, OnMarketDays: req.OnMarketDays, PriceChangeCount: req.PriceChangeCount,
|
||||||
|
Status: req.Status, Confidence: req.Confidence, IsBargain: req.IsBargain,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &listingv1.ListingCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingUpdate 更新房源。
|
||||||
|
func (c *Controller) ListingUpdate(ctx context.Context, req *listingv1.ListingUpdateReq) (res *listingv1.ListingUpdateRes, err error) {
|
||||||
|
err = listing.Listing().Update(ctx, dto.HouseListingInput{
|
||||||
|
Id: req.Id, CommunityId: req.CommunityId, BuildingId: req.BuildingId, HouseNo: req.HouseNo,
|
||||||
|
Layout: req.Layout, Area: req.Area, UsableArea: req.UsableArea, Orientation: req.Orientation,
|
||||||
|
Floor: req.Floor, TotalFloors: req.TotalFloors, Decoration: req.Decoration, TotalPrice: req.TotalPrice,
|
||||||
|
UnitPrice: req.UnitPrice, ListPrice: req.ListPrice, MatchGroupId: req.MatchGroupId,
|
||||||
|
Status: req.Status, Confidence: req.Confidence, IsBargain: req.IsBargain,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &listingv1.ListingUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingDelete 删除房源。
|
||||||
|
func (c *Controller) ListingDelete(ctx context.Context, req *listingv1.ListingDeleteReq) (res *listingv1.ListingDeleteRes, err error) {
|
||||||
|
if err = listing.Listing().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &listingv1.ListingDeleteRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListingBatchMark 批量标记房源。
|
||||||
|
func (c *Controller) ListingBatchMark(ctx context.Context, req *listingv1.ListingBatchMarkReq) (res *listingv1.ListingBatchMarkRes, err error) {
|
||||||
|
affected, err := listing.Listing().BatchMark(ctx, req.Ids, req.Field, req.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &listingv1.ListingBatchMarkRes{Affected: affected}, nil
|
||||||
|
}
|
||||||
36
internal/controller/house/presale.go
Normal file
36
internal/controller/house/presale.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package house
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
presalev1 "service.xpcool.com/api/house/presale"
|
||||||
|
presale "service.xpcool.com/internal/service/house/presale"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PresaleList 分页查询新房预售证。
|
||||||
|
func (c *Controller) PresaleList(ctx context.Context, req *presalev1.PresaleListReq) (res *presalev1.PresaleListRes, err error) {
|
||||||
|
list, total, err := presale.Presale().List(ctx, req.Page, req.Size, req.Region, req.Keyword, req.Purpose)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*presalev1.PresaleItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &presalev1.PresaleItem{
|
||||||
|
Id: v.Id,
|
||||||
|
PresaleNo: v.PresaleNo,
|
||||||
|
CommunityName: v.CommunityName,
|
||||||
|
Developer: v.Developer,
|
||||||
|
Region: v.Region,
|
||||||
|
Address: v.Address,
|
||||||
|
BuildingNo: v.BuildingNo,
|
||||||
|
HouseCount: v.HouseCount,
|
||||||
|
Area: v.Area,
|
||||||
|
Purpose: v.Purpose,
|
||||||
|
IssueDate: v.IssueDate,
|
||||||
|
PublishDate: v.PublishDate,
|
||||||
|
Source: v.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &presalev1.PresaleListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
26
internal/controller/house/transaction.go
Normal file
26
internal/controller/house/transaction.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package house
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
transactionv1 "service.xpcool.com/api/house/transaction"
|
||||||
|
transaction "service.xpcool.com/internal/service/house/transaction"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransactionList 分页查询成交记录。
|
||||||
|
func (c *Controller) TransactionList(ctx context.Context, req *transactionv1.TransactionListReq) (res *transactionv1.TransactionListRes, err error) {
|
||||||
|
list, total, err := transaction.Transaction().List(ctx, req.Page, req.Size, req.Region, req.Keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*transactionv1.TransactionItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &transactionv1.TransactionItem{
|
||||||
|
Id: v.Id, CommunityId: v.CommunityId, CommunityName: v.CommunityName,
|
||||||
|
Layout: v.Layout, Area: v.Area, DealPrice: v.DealPrice, DealUnitPrice: v.DealUnitPrice,
|
||||||
|
ListDays: v.ListDays, DealDate: v.DealDate, Source: v.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &transactionv1.TransactionListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
68
internal/controller/job/job.go
Normal file
68
internal/controller/job/job.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Package job 自动任务模块控制器(绑定 admin 受权限保护组)。
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
jobv1 "service.xpcool.com/api/job"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller 实现自动任务端点。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建自动任务控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
|
|
||||||
|
// List 自动任务列表。
|
||||||
|
func (c *Controller) List(ctx context.Context, req *jobv1.AutoJobListReq) (res *jobv1.AutoJobListRes, err error) {
|
||||||
|
list, err := job.Job().List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*jobv1.JobItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &jobv1.JobItem{
|
||||||
|
Id: v.Id, Name: v.Name, Code: v.Code, JobType: v.JobType, CronExpr: v.CronExpr,
|
||||||
|
Enabled: v.Enabled, Remark: v.Remark, LastRunAt: v.LastRunAt,
|
||||||
|
LastResult: v.LastResult, LastError: v.LastError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &jobv1.AutoJobListRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save 保存自动任务(cron/启用/备注)。
|
||||||
|
func (c *Controller) Save(ctx context.Context, req *jobv1.AutoJobSaveReq) (res *jobv1.AutoJobSaveRes, err error) {
|
||||||
|
if err = job.Job().Save(ctx, dto.JobInput{Id: req.Id, CronExpr: req.CronExpr, Enabled: req.Enabled, Remark: req.Remark}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &jobv1.AutoJobSaveRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 手动触发任务。
|
||||||
|
func (c *Controller) Trigger(ctx context.Context, req *jobv1.AutoJobTriggerReq) (res *jobv1.AutoJobTriggerRes, err error) {
|
||||||
|
summary, ok, err := job.Job().Trigger(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &jobv1.AutoJobTriggerRes{Summary: summary, Ok: ok}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogList 任务运行日志分页。
|
||||||
|
func (c *Controller) LogList(ctx context.Context, req *jobv1.AutoJobLogListReq) (res *jobv1.AutoJobLogListRes, err error) {
|
||||||
|
list, total, err := job.Job().LogList(ctx, dto.JobLogFilter{Page: req.Page, Size: req.Size, JobId: req.JobId})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*jobv1.JobLogItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &jobv1.JobLogItem{
|
||||||
|
Id: v.Id, JobId: v.JobId, JobName: v.JobName, RunAt: v.RunAt,
|
||||||
|
Result: v.Result, Error: v.Error, Summary: v.Summary, DurationMs: v.DurationMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &jobv1.AutoJobLogListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
177
internal/controller/notice/notice.go
Normal file
177
internal/controller/notice/notice.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
// Package notice 通知模块控制器(绑定 admin 受权限保护组)。
|
||||||
|
package notice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
noticev1 "service.xpcool.com/api/notice"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service/notice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller 实现通知模块端点。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建通知控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
|
|
||||||
|
// ChannelList 通知渠道列表。
|
||||||
|
func (c *Controller) ChannelList(ctx context.Context, req *noticev1.NoticeChannelListReq) (res *noticev1.NoticeChannelListRes, err error) {
|
||||||
|
list, err := notice.Notice().ChannelList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*noticev1.NoticeChannelItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, ¬icev1.NoticeChannelItem{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark})
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeChannelListRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelSave 保存通知渠道。
|
||||||
|
func (c *Controller) ChannelSave(ctx context.Context, req *noticev1.NoticeChannelSaveReq) (res *noticev1.NoticeChannelSaveRes, err error) {
|
||||||
|
id, err := notice.Notice().ChannelSave(ctx, dto.NoticeChannelInput{
|
||||||
|
Id: req.Id, Code: req.Code, Name: req.Name, Enabled: req.Enabled, Config: req.Config, Remark: req.Remark,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeChannelSaveRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleList 通知规则列表。
|
||||||
|
func (c *Controller) RuleList(ctx context.Context, req *noticev1.NoticeRuleListReq) (res *noticev1.NoticeRuleListRes, err error) {
|
||||||
|
list, err := notice.Notice().RuleList(ctx, req.EventType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*noticev1.NoticeRuleItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, ¬icev1.NoticeRuleItem{
|
||||||
|
Id: v.Id, Name: v.Name, EventType: v.EventType, ChannelCodes: v.ChannelCodes,
|
||||||
|
UserIds: v.UserIds, TitleTemplate: v.TitleTemplate, BodyTemplate: v.BodyTemplate, Enabled: v.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeRuleListRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleSave 保存通知规则。
|
||||||
|
func (c *Controller) RuleSave(ctx context.Context, req *noticev1.NoticeRuleSaveReq) (res *noticev1.NoticeRuleSaveRes, err error) {
|
||||||
|
id, err := notice.Notice().RuleSave(ctx, dto.NoticeRuleInput{
|
||||||
|
Id: req.Id, Name: req.Name, EventType: req.EventType, ChannelCodes: req.ChannelCodes,
|
||||||
|
UserIds: req.UserIds, TitleTemplate: req.TitleTemplate, BodyTemplate: req.BodyTemplate, Enabled: req.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeRuleSaveRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleDelete 删除通知规则。
|
||||||
|
func (c *Controller) RuleDelete(ctx context.Context, req *noticev1.NoticeRuleDeleteReq) (res *noticev1.NoticeRuleDeleteRes, err error) {
|
||||||
|
if err = notice.Notice().RuleDelete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeRuleDeleteRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogList 通知历史记录分页(含统计概览)。
|
||||||
|
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
|
||||||
|
list, total, stats, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
|
||||||
|
Page: req.Page, Size: req.Size, Keyword: req.Keyword,
|
||||||
|
NoticeType: req.NoticeType, Group: req.Group, EventType: req.EventType,
|
||||||
|
ChannelCode: req.ChannelCode, UserId: req.UserId, BatchId: req.BatchId,
|
||||||
|
Status: req.Status, Result: req.Result,
|
||||||
|
DateFrom: req.DateFrom, DateTo: req.DateTo,
|
||||||
|
OrderBy: req.OrderBy, OrderDir: req.OrderDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*noticev1.NoticeLogItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out = append(out, toLogItem(&list[i]))
|
||||||
|
}
|
||||||
|
res = ¬icev1.NoticeLogListRes{List: out, Total: total}
|
||||||
|
if stats != nil {
|
||||||
|
res.Stats = ¬icev1.NoticeLogStatsItem{
|
||||||
|
Total: stats.Total, Success: stats.Success,
|
||||||
|
Failed: stats.Failed, SuccessRate: stats.SuccessRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDetail 通知历史记录详情。
|
||||||
|
func (c *Controller) LogDetail(ctx context.Context, req *noticev1.NoticeLogDetailReq) (res *noticev1.NoticeLogDetailRes, err error) {
|
||||||
|
vo, err := notice.Notice().LogDetail(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeLogDetailRes{Item: toLogItem(vo)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDelete 删除通知历史记录(支持批量)。
|
||||||
|
func (c *Controller) LogDelete(ctx context.Context, req *noticev1.NoticeLogDeleteReq) (res *noticev1.NoticeLogDeleteRes, err error) {
|
||||||
|
n, err := notice.Notice().LogDelete(ctx, req.Ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeLogDeleteRes{Deleted: n}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogClear 清空通知历史记录。
|
||||||
|
func (c *Controller) LogClear(ctx context.Context, req *noticev1.NoticeLogClearReq) (res *noticev1.NoticeLogClearRes, err error) {
|
||||||
|
n, err := notice.Notice().LogClear(ctx, req.KeepDays, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeLogClearRes{Deleted: n}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaOptions 通知字典选项(渠道/事件/类型/分组)。
|
||||||
|
func (c *Controller) MetaOptions(ctx context.Context, req *noticev1.NoticeMetaOptionsReq) (res *noticev1.NoticeMetaOptionsRes, err error) {
|
||||||
|
return ¬icev1.NoticeMetaOptionsRes{
|
||||||
|
Channels: toMetaItems(dto.NoticeChannelOptions()),
|
||||||
|
Events: toMetaItems(dto.NoticeEventOptions()),
|
||||||
|
Types: toMetaItems(dto.NoticeTypeOptions()),
|
||||||
|
Groups: toMetaItems(dto.NoticeGroupOptions()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 测试通知发送。
|
||||||
|
func (c *Controller) Test(ctx context.Context, req *noticev1.NoticeTestReq) (res *noticev1.NoticeTestRes, err error) {
|
||||||
|
ok, msg, err := notice.Notice().Test(ctx, req.RuleId, req.ChannelCode, req.Target, req.UserIds, req.Title, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬icev1.NoticeTestRes{Result: ok, Message: msg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLogItem dto → API 契约转换。
|
||||||
|
func toLogItem(v *dto.NoticeLogVO) *noticev1.NoticeLogItem {
|
||||||
|
return ¬icev1.NoticeLogItem{
|
||||||
|
Id: v.Id, BatchId: v.BatchId, RuleId: v.RuleId, RuleName: v.RuleName,
|
||||||
|
EventType: v.EventType, EventName: v.EventName,
|
||||||
|
NoticeType: v.NoticeType, TypeName: v.TypeName,
|
||||||
|
Group: v.Group, GroupName: v.GroupName,
|
||||||
|
ChannelCode: v.ChannelCode, ChannelName: v.ChannelName,
|
||||||
|
UserId: v.UserId, UserName: v.UserName, Target: v.Target,
|
||||||
|
Title: v.Title, Body: v.Body,
|
||||||
|
Status: v.Status, StatusName: v.StatusName,
|
||||||
|
Result: v.Result, Error: v.Error,
|
||||||
|
RetryCount: v.RetryCount, DurationMs: v.DurationMs,
|
||||||
|
Source: v.Source, Remark: v.Remark, CreatedAt: v.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMetaItems dto 字典项 → API 契约转换。
|
||||||
|
func toMetaItems(list []dto.NoticeMetaItem) []*noticev1.NoticeMetaItem {
|
||||||
|
out := make([]*noticev1.NoticeMetaItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out = append(out, ¬icev1.NoticeMetaItem{Code: list[i].Code, Name: list[i].Name})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
11
internal/controller/open/controller.go
Normal file
11
internal/controller/open/controller.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Package open 实现公开的开放接口(/api/open)。
|
||||||
|
// 这些控制器是 common/tools Go 包的薄适配层,
|
||||||
|
// 无需认证。每个子功能位于本目录下的独立文件,
|
||||||
|
// 与 api/open/tools/<name>/index.go 一一对应。
|
||||||
|
package open
|
||||||
|
|
||||||
|
// Controller 实现 /api/open 端点。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建开放接口控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
16
internal/controller/open/ip.go
Normal file
16
internal/controller/open/ip.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
ipapi "service.xpcool.com/api/open/tools/ip"
|
||||||
|
"service.xpcool.com/common/tools/ip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IP 返回调用方 IP 及是否为内网地址。
|
||||||
|
func (c *Controller) IP(ctx context.Context, req *ipapi.IPReq) (res *ipapi.IPRes, err error) {
|
||||||
|
clientIP := g.RequestFromCtx(ctx).GetClientIp()
|
||||||
|
return &ipapi.IPRes{IP: clientIP, Internal: ip.IsInternal(clientIP)}, nil
|
||||||
|
}
|
||||||
13
internal/controller/open/md5.go
Normal file
13
internal/controller/open/md5.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
md5api "service.xpcool.com/api/open/tools/md5"
|
||||||
|
"service.xpcool.com/common/tools/md5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD5 计算给定文本的 MD5 摘要。
|
||||||
|
func (c *Controller) MD5(ctx context.Context, req *md5api.MD5Req) (res *md5api.MD5Res, err error) {
|
||||||
|
return &md5api.MD5Res{MD5: md5.Md5Hex(req.Text)}, nil
|
||||||
|
}
|
||||||
22
internal/controller/open/random.go
Normal file
22
internal/controller/open/random.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
randomapi "service.xpcool.com/api/open/tools/random"
|
||||||
|
"service.xpcool.com/common/tools/random"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Random 生成指定类型和长度的随机字符串。
|
||||||
|
func (c *Controller) Random(ctx context.Context, req *randomapi.RandomReq) (res *randomapi.RandomRes, err error) {
|
||||||
|
var value string
|
||||||
|
switch req.Type {
|
||||||
|
case "digits":
|
||||||
|
value = random.Digits(req.Length)
|
||||||
|
case "letters":
|
||||||
|
value = random.Letters(req.Length)
|
||||||
|
default:
|
||||||
|
value = random.String(req.Length)
|
||||||
|
}
|
||||||
|
return &randomapi.RandomRes{Value: value}, nil
|
||||||
|
}
|
||||||
18
internal/controller/open/time.go
Normal file
18
internal/controller/open/time.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
timeapi "service.xpcool.com/api/open/tools/time"
|
||||||
|
"service.xpcool.com/common/tools/timex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Time 返回当前服务器时间戳与格式化时间。
|
||||||
|
func (c *Controller) Time(ctx context.Context, req *timeapi.TimeReq) (res *timeapi.TimeRes, err error) {
|
||||||
|
now := timex.Now()
|
||||||
|
return &timeapi.TimeRes{
|
||||||
|
Timestamp: now.Timestamp(),
|
||||||
|
DateTime: now.Layout(timex.LayoutDateTime),
|
||||||
|
Date: now.Layout(timex.LayoutDate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
16
internal/controller/open/uuid.go
Normal file
16
internal/controller/open/uuid.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
uuidapi "service.xpcool.com/api/open/tools/uuid"
|
||||||
|
"service.xpcool.com/common/tools/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UUID 生成唯一 ID(默认 32 位,short=true 时为 8 位)。
|
||||||
|
func (c *Controller) UUID(ctx context.Context, req *uuidapi.UUIDReq) (res *uuidapi.UUIDRes, err error) {
|
||||||
|
if req.Short {
|
||||||
|
return &uuidapi.UUIDRes{UUID: uuid.Short(8)}, nil
|
||||||
|
}
|
||||||
|
return &uuidapi.UUIDRes{UUID: uuid.New()}, nil
|
||||||
|
}
|
||||||
8
internal/controller/recruitment/controller.go
Normal file
8
internal/controller/recruitment/controller.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Package recruitment 实现招聘考试聚合模块管理端点,绑定在 admin 受权限保护分组下。
|
||||||
|
package recruitment
|
||||||
|
|
||||||
|
// Controller 实现招聘考试聚合模块的所有端点。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 创建招聘模块控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
159
internal/controller/recruitment/recruitment.go
Normal file
159
internal/controller/recruitment/recruitment.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package recruitment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
recruitmentv1 "service.xpcool.com/api/recruitment"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service/recruitment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toItem 将服务层 VO 映射为 API 出参项。
|
||||||
|
func toItem(v *dto.RecruitmentInfoVO) *recruitmentv1.RecruitmentItem {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentItem{
|
||||||
|
Id: v.Id, Title: v.Title, SourceId: v.SourceId, SourceName: v.SourceName,
|
||||||
|
OrgId: v.OrgId, OrgName: v.OrgName, Category: v.Category, CategoryName: v.CategoryName,
|
||||||
|
Region: v.Region, PublishDate: v.PublishDate, Deadline: v.Deadline, ExamDate: v.ExamDate,
|
||||||
|
Url: v.Url, Content: v.Content, Attachments: v.Attachments, Status: v.Status,
|
||||||
|
StatusName: v.StatusName, GroupKey: v.GroupKey, CreatedAt: v.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoList 招聘公告列表(多维筛选)。
|
||||||
|
func (c *Controller) InfoList(ctx context.Context, req *recruitmentv1.RecruitmentListReq) (res *recruitmentv1.RecruitmentListRes, err error) {
|
||||||
|
list, total, err := recruitment.Recruitment().List(ctx, dto.RecruitmentFilter{
|
||||||
|
Page: req.Page, Size: req.Size, Region: req.Region, Category: req.Category,
|
||||||
|
Keyword: req.Keyword, Status: req.Status, DateFrom: req.DateFrom, DateTo: req.DateTo,
|
||||||
|
OrgName: req.OrgName, SourceId: req.SourceId, OnlyNewToday: req.OnlyNewToday,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*recruitmentv1.RecruitmentItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out = append(out, toItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoDetail 招聘公告详情。
|
||||||
|
func (c *Controller) InfoDetail(ctx context.Context, req *recruitmentv1.RecruitmentDetailReq) (res *recruitmentv1.RecruitmentDetailRes, err error) {
|
||||||
|
info, err := recruitment.Recruitment().Detail(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentDetailRes{Info: toItem(info)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 招聘数据看板统计。
|
||||||
|
func (c *Controller) Stats(ctx context.Context, req *recruitmentv1.RecruitmentStatsReq) (res *recruitmentv1.RecruitmentStatsRes, err error) {
|
||||||
|
s, err := recruitment.Recruitment().Stats(ctx, req.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byCat := make([]recruitmentv1.CategoryAggItem, 0, len(s.ByCategory))
|
||||||
|
for _, v := range s.ByCategory {
|
||||||
|
byCat = append(byCat, recruitmentv1.CategoryAggItem{Category: v.Category, CategoryName: v.CategoryName, Count: v.Count})
|
||||||
|
}
|
||||||
|
byRegion := make([]recruitmentv1.RegionAggItem, 0, len(s.ByRegion))
|
||||||
|
for _, v := range s.ByRegion {
|
||||||
|
byRegion = append(byRegion, recruitmentv1.RegionAggItem{Region: v.Region, Count: v.Count})
|
||||||
|
}
|
||||||
|
trend := make([]recruitmentv1.TrendPointItem, 0, len(s.RecentTrend))
|
||||||
|
for _, v := range s.RecentTrend {
|
||||||
|
trend = append(trend, recruitmentv1.TrendPointItem{Date: v.Date, Count: v.Count})
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentStatsRes{
|
||||||
|
Total: s.Total, TodayNew: s.TodayNew, WeekNew: s.WeekNew,
|
||||||
|
ByCategory: byCat, ByRegion: byRegion, RecentTrend: trend,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trend 招聘公告趋势(按日)。
|
||||||
|
func (c *Controller) Trend(ctx context.Context, req *recruitmentv1.RecruitmentTrendReq) (res *recruitmentv1.RecruitmentTrendRes, err error) {
|
||||||
|
pts, err := recruitment.Recruitment().Trend(ctx, req.Region, req.Category, req.Days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]recruitmentv1.TrendPointItem, 0, len(pts))
|
||||||
|
for _, v := range pts {
|
||||||
|
out = append(out, recruitmentv1.TrendPointItem{Date: v.Date, Count: v.Count})
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentTrendRes{Trend: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceList 数据源列表与运行状态。
|
||||||
|
func (c *Controller) SourceList(ctx context.Context, req *recruitmentv1.RecruitmentSourcesReq) (res *recruitmentv1.RecruitmentSourcesRes, err error) {
|
||||||
|
list, err := recruitment.Recruitment().Sources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*recruitmentv1.SourceItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &recruitmentv1.SourceItem{
|
||||||
|
Id: v.Id, Name: v.Name, BaseUrl: v.BaseUrl, SourceType: v.SourceType,
|
||||||
|
Category: v.Category, Region: v.Region, Enabled: v.Enabled,
|
||||||
|
LastSuccessAt: v.LastSuccessAt, FailCount: v.FailCount, LastSummary: v.LastSummary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentSourcesRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrawlTrigger 手动触发抓取(单源或全量)。
|
||||||
|
func (c *Controller) CrawlTrigger(ctx context.Context, req *recruitmentv1.RecruitmentTriggerReq) (res *recruitmentv1.RecruitmentTriggerRes, err error) {
|
||||||
|
n, summary, err := recruitment.Recruitment().Trigger(ctx, req.SourceId, req.Force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentTriggerRes{Triggered: n, Summary: summary}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushTest Bark 推送测试。
|
||||||
|
func (c *Controller) PushTest(ctx context.Context, req *recruitmentv1.RecruitmentPushTestReq) (res *recruitmentv1.RecruitmentPushTestRes, err error) {
|
||||||
|
ok, msg, err := recruitment.Recruitment().PushTest(ctx, req.SubscriptionId, req.Title, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentPushTestRes{Result: ok, Message: msg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionList 推送订阅列表。
|
||||||
|
func (c *Controller) SubscriptionList(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionListReq) (res *recruitmentv1.RecruitmentSubscriptionListRes, err error) {
|
||||||
|
list, err := recruitment.Recruitment().SubscriptionList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*recruitmentv1.SubscriptionItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &recruitmentv1.SubscriptionItem{
|
||||||
|
Id: v.Id, Name: v.Name, DeviceKey: v.DeviceKey, Regions: v.Regions,
|
||||||
|
Categories: v.Categories, OnlyNew: v.OnlyNew, PushTime: v.PushTime, Enabled: v.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentSubscriptionListRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionSave 保存推送订阅(新增/更新)。
|
||||||
|
func (c *Controller) SubscriptionSave(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionSaveReq) (res *recruitmentv1.RecruitmentSubscriptionSaveRes, err error) {
|
||||||
|
id, err := recruitment.Recruitment().SubscriptionSave(ctx, dto.SubscriptionInput{
|
||||||
|
Id: req.Id, Name: req.Name, DeviceKey: req.DeviceKey, Regions: req.Regions,
|
||||||
|
Categories: req.Categories, OnlyNew: req.OnlyNew, PushTime: req.PushTime, Enabled: req.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentSubscriptionSaveRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionDelete 删除推送订阅。
|
||||||
|
func (c *Controller) SubscriptionDelete(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionDeleteReq) (res *recruitmentv1.RecruitmentSubscriptionDeleteRes, err error) {
|
||||||
|
if err = recruitment.Recruitment().SubscriptionDelete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &recruitmentv1.RecruitmentSubscriptionDeleteRes{}, nil
|
||||||
|
}
|
||||||
21
internal/controller/serversecurity/controller.go
Normal file
21
internal/controller/serversecurity/controller.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Package serversecurity 服务器安全日志模块控制器。
|
||||||
|
// 分为两个 Controller:ReportController 绑定 open 组(宿主机脚本上报,内部令牌鉴权);
|
||||||
|
// ManageController 绑定 admin 受权限保护组(查询/统计)。
|
||||||
|
package serversecurity
|
||||||
|
|
||||||
|
import (
|
||||||
|
serversecurityv1 "service.xpcool.com/api/serversecurity"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toInput 将 API 上报项转为服务层入参。
|
||||||
|
func toInput(items []serversecurityv1.SecurityLogItem) []dto.SecurityLogInput {
|
||||||
|
out := make([]dto.SecurityLogInput, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, dto.SecurityLogInput{
|
||||||
|
LogTime: it.LogTime, SrcIp: it.SrcIp, SrcPort: it.SrcPort,
|
||||||
|
DestPort: it.DestPort, EventType: it.EventType, Detail: it.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
57
internal/controller/serversecurity/manage.go
Normal file
57
internal/controller/serversecurity/manage.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Package serversecurity 管理控制器:绑定 admin 受权限保护组(查询/统计)。
|
||||||
|
package serversecurity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
serversecurityv1 "service.xpcool.com/api/serversecurity"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service/serversecurity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManageController 实现安全日志查询与统计端点。
|
||||||
|
type ManageController struct{}
|
||||||
|
|
||||||
|
// NewManage 创建管理控制器。
|
||||||
|
func NewManage() *ManageController { return &ManageController{} }
|
||||||
|
|
||||||
|
// ListLog 安全日志分页查询(时间倒序)。
|
||||||
|
func (c *ManageController) ListLog(ctx context.Context, req *serversecurityv1.SecurityLogListReq) (res *serversecurityv1.SecurityLogListRes, err error) {
|
||||||
|
list, total, err := serversecurity.Security().List(ctx, dto.SecurityLogFilter{
|
||||||
|
Page: req.Page, Size: req.Size, SrcIp: req.SrcIp, EventType: req.EventType,
|
||||||
|
DestPort: req.DestPort, DateFrom: req.DateFrom, DateTo: req.DateTo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*serversecurityv1.SecurityLogListItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := &list[i]
|
||||||
|
out = append(out, &serversecurityv1.SecurityLogListItem{
|
||||||
|
Id: v.Id, LogTime: v.LogTime, SrcIp: v.SrcIp, SrcPort: v.SrcPort,
|
||||||
|
DestPort: v.DestPort, EventType: v.EventType, EventName: v.EventName,
|
||||||
|
Detail: v.Detail, CreatedAt: v.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &serversecurityv1.SecurityLogListRes{List: out, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 安全日志统计(默认最近24小时)。
|
||||||
|
func (c *ManageController) Stats(ctx context.Context, req *serversecurityv1.SecurityLogStatsReq) (res *serversecurityv1.SecurityLogStatsRes, err error) {
|
||||||
|
st, err := serversecurity.Security().Stats(ctx, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topIps := make([]serversecurityv1.SecurityTopIp, 0, len(st.TopIps))
|
||||||
|
for _, v := range st.TopIps {
|
||||||
|
topIps = append(topIps, serversecurityv1.SecurityTopIp{SrcIp: v.SrcIp, Count: v.Count})
|
||||||
|
}
|
||||||
|
byType := make([]serversecurityv1.SecurityByType, 0, len(st.ByType))
|
||||||
|
for _, v := range st.ByType {
|
||||||
|
byType = append(byType, serversecurityv1.SecurityByType{EventType: v.EventType, EventName: v.EventName, Count: v.Count})
|
||||||
|
}
|
||||||
|
return &serversecurityv1.SecurityLogStatsRes{
|
||||||
|
Total: st.Total, Failed: st.Failed, Banned: st.Banned, Accepted: st.Accepted,
|
||||||
|
TopIps: topIps, ByType: byType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
24
internal/controller/serversecurity/report.go
Normal file
24
internal/controller/serversecurity/report.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Package serversecurity 上报控制器:绑定 open 组(无登录态),仅宿主机采集脚本调用。
|
||||||
|
package serversecurity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
serversecurityv1 "service.xpcool.com/api/serversecurity"
|
||||||
|
"service.xpcool.com/internal/service/serversecurity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportController 实现安全日志上报端点。
|
||||||
|
type ReportController struct{}
|
||||||
|
|
||||||
|
// NewReport 创建上报控制器。
|
||||||
|
func NewReport() *ReportController { return &ReportController{} }
|
||||||
|
|
||||||
|
// ReportLog 接收宿主机采集脚本上报的安全日志并入库(内部令牌鉴权)。
|
||||||
|
func (c *ReportController) ReportLog(ctx context.Context, req *serversecurityv1.SecurityLogReportReq) (res *serversecurityv1.SecurityLogReportRes, err error) {
|
||||||
|
n, err := serversecurity.Security().Report(ctx, req.Token, toInput(req.List))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &serversecurityv1.SecurityLogReportRes{Accepted: n}, nil
|
||||||
|
}
|
||||||
@ -2,25 +2,25 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
userv1 "service.xpcool.com/api/user/v1"
|
authv1 "service.xpcool.com/api/user/auth"
|
||||||
"service.xpcool.com/internal/model/dto"
|
"service.xpcool.com/internal/model/dto"
|
||||||
"service.xpcool.com/internal/service"
|
auth "service.xpcool.com/internal/service/user/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Controller struct{}
|
type Controller struct{}
|
||||||
|
|
||||||
func New() *Controller { return &Controller{} }
|
func New() *Controller { return &Controller{} }
|
||||||
func (c *Controller) Login(ctx context.Context, req *userv1.LoginReq) (res *userv1.LoginRes, err error) {
|
func (c *Controller) Login(ctx context.Context, req *authv1.LoginReq) (res *authv1.LoginRes, err error) {
|
||||||
p, id, err := service.UserAuth().Login(ctx, dto.UserLoginInput{LoginType: req.LoginType, Code: req.Code, Mobile: req.Mobile, VerifyCode: req.VerifyCode, Account: req.Account, Password: req.Password, Terminal: req.Terminal})
|
p, id, err := auth.UserAuth().Login(ctx, dto.UserLoginInput{LoginType: req.LoginType, Code: req.Code, Mobile: req.Mobile, VerifyCode: req.VerifyCode, Account: req.Account, Password: req.Password, Terminal: req.Terminal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &userv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, UserID: id}, nil
|
return &authv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, UserID: id}, nil
|
||||||
}
|
}
|
||||||
func (c *Controller) Refresh(ctx context.Context, req *userv1.RefreshReq) (res *userv1.RefreshRes, err error) {
|
func (c *Controller) Refresh(ctx context.Context, req *authv1.RefreshReq) (res *authv1.RefreshRes, err error) {
|
||||||
p, id, err := service.UserAuth().Refresh(ctx, req.RefreshToken)
|
p, id, err := auth.UserAuth().Refresh(ctx, req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &userv1.RefreshRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, UserID: id}, nil
|
return &authv1.RefreshRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, UserID: id}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
203
internal/controller/wallpaper/admin.go
Normal file
203
internal/controller/wallpaper/admin.go
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
package wallpaper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
|
|
||||||
|
wallpaperv1 "service.xpcool.com/api/wallpaper"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller 后台管理接口(走 RBAC 权限)。
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New 构造后台控制器。
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 自建图库
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// List 图库列表(含已停用项,便于重新启用)。
|
||||||
|
func (c *Controller) List(ctx context.Context, req *wallpaperv1.AdminListReq) (res *wallpaperv1.AdminListRes, err error) {
|
||||||
|
q := dto.WallpaperQuery{
|
||||||
|
Query: req.Query,
|
||||||
|
Orientation: req.Orientation,
|
||||||
|
Page: req.Page,
|
||||||
|
Size: req.Size,
|
||||||
|
}
|
||||||
|
items, total, err := wallpapersvc.Wallpaper().LibraryAdminList(ctx, q, req.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminListRes{List: toAPIItems(items), Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 批量上传壁纸。
|
||||||
|
//
|
||||||
|
// 刻意「逐文件返回结果」而不是整体成功/失败:一次传 20 张,其中 1 张格式不对时,
|
||||||
|
// 用户希望的是「19 张成功、1 张告诉我为什么失败」,而不是全部回滚重来。
|
||||||
|
func (c *Controller) Upload(ctx context.Context, req *wallpaperv1.AdminUploadReq) (res *wallpaperv1.AdminUploadRes, err error) {
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return nil, gerror.New("没有收到文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]*wallpaperv1.UploadItem, 0, len(req.Files))
|
||||||
|
okCount, failCount := 0, 0
|
||||||
|
for _, f := range req.Files {
|
||||||
|
row := &wallpaperv1.UploadItem{FileName: f.Filename}
|
||||||
|
data, readErr := readUpload(f)
|
||||||
|
if readErr != nil {
|
||||||
|
row.Message = readErr.Error()
|
||||||
|
failCount++
|
||||||
|
out = append(out, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, duplicated, upErr := wallpapersvc.Wallpaper().Upload(ctx, f.Filename, data, dto.WallpaperSaveInput{
|
||||||
|
Title: req.Title,
|
||||||
|
Tags: req.Tags,
|
||||||
|
Category: req.Category,
|
||||||
|
Sort: req.Sort,
|
||||||
|
Enabled: 1,
|
||||||
|
})
|
||||||
|
if upErr != nil {
|
||||||
|
row.Message = upErr.Error()
|
||||||
|
failCount++
|
||||||
|
out = append(out, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row.Ok = true
|
||||||
|
row.Duplicated = duplicated
|
||||||
|
row.Item = toAPIItem(item)
|
||||||
|
if duplicated {
|
||||||
|
row.Message = "内容重复,已指向图库中已有的图片"
|
||||||
|
} else {
|
||||||
|
row.Message = "上传成功"
|
||||||
|
}
|
||||||
|
okCount++
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminUploadRes{List: out, OkCount: okCount, FailCount: failCount}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUpload 把一个上传文件读成字节切片。
|
||||||
|
//
|
||||||
|
// 先看 Size 再读:超限的文件直接拒绝,避免把一个 200MB 的文件读进内存才发现。
|
||||||
|
func readUpload(f *ghttp.UploadFile) ([]byte, error) {
|
||||||
|
if f == nil {
|
||||||
|
return nil, gerror.New("文件为空")
|
||||||
|
}
|
||||||
|
if f.Size <= 0 {
|
||||||
|
return nil, gerror.New("文件为空")
|
||||||
|
}
|
||||||
|
if f.Size > wallpapersvc.MaxUploadBytes {
|
||||||
|
return nil, gerror.Newf("文件超过 %d MB 上限", wallpapersvc.MaxUploadBytes>>20)
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.New("读取上传文件失败")
|
||||||
|
}
|
||||||
|
defer func() { _ = rc.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, wallpapersvc.MaxUploadBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.New("读取上传文件失败")
|
||||||
|
}
|
||||||
|
if int64(len(data)) > wallpapersvc.MaxUploadBytes {
|
||||||
|
return nil, gerror.Newf("文件超过 %d MB 上限", wallpapersvc.MaxUploadBytes>>20)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save 保存壁纸元数据。
|
||||||
|
func (c *Controller) Save(ctx context.Context, req *wallpaperv1.AdminSaveReq) (res *wallpaperv1.AdminSaveRes, err error) {
|
||||||
|
err = wallpapersvc.Wallpaper().SaveMeta(ctx, dto.WallpaperSaveInput{
|
||||||
|
Id: req.Id,
|
||||||
|
Title: req.Title,
|
||||||
|
Tags: req.Tags,
|
||||||
|
Category: req.Category,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Sort: req.Sort,
|
||||||
|
Remark: req.Remark,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminSaveRes{Message: "保存成功"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除壁纸。
|
||||||
|
func (c *Controller) Delete(ctx context.Context, req *wallpaperv1.AdminDeleteReq) (res *wallpaperv1.AdminDeleteRes, err error) {
|
||||||
|
if err = wallpapersvc.Wallpaper().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminDeleteRes{Message: "删除成功"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 图库概览。
|
||||||
|
func (c *Controller) Stats(ctx context.Context, req *wallpaperv1.AdminStatsReq) (res *wallpaperv1.AdminStatsRes, err error) {
|
||||||
|
st, err := wallpapersvc.Wallpaper().Stat(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminStatsRes{
|
||||||
|
Total: st.Total,
|
||||||
|
Enabled: st.Enabled,
|
||||||
|
Disabled: st.Disabled,
|
||||||
|
Portrait: st.Portrait,
|
||||||
|
Landscape: st.Landscape,
|
||||||
|
TotalBytes: st.TotalBytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 开源平台配置
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SourceList 平台配置列表。
|
||||||
|
func (c *Controller) SourceList(ctx context.Context, req *wallpaperv1.AdminSourceListReq) (res *wallpaperv1.AdminSourceListRes, err error) {
|
||||||
|
list, err := wallpapersvc.Wallpaper().Sources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*wallpaperv1.SourceInfo, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
// 后台带配置:编辑界面需要回显 defaultQuery / purity / baseUrl 等,
|
||||||
|
// 否则用户只改开关也会把这些字段一并清空。
|
||||||
|
out = append(out, toAPISource(&list[i], true))
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminSourceListRes{List: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceSave 保存平台配置。
|
||||||
|
func (c *Controller) SourceSave(ctx context.Context, req *wallpaperv1.AdminSourceSaveReq) (res *wallpaperv1.AdminSourceSaveRes, err error) {
|
||||||
|
err = wallpapersvc.Wallpaper().SaveSource(ctx, dto.WallpaperSourceSaveInput{
|
||||||
|
Code: req.Code,
|
||||||
|
Name: req.Name,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Sort: req.Sort,
|
||||||
|
Remark: req.Remark,
|
||||||
|
Config: req.Config,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminSourceSaveRes{Message: "保存成功"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceTest 测试平台连通性。
|
||||||
|
func (c *Controller) SourceTest(ctx context.Context, req *wallpaperv1.AdminSourceTestReq) (res *wallpaperv1.AdminSourceTestRes, err error) {
|
||||||
|
msg, err := wallpapersvc.Wallpaper().TestSource(ctx, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
// 测试失败是「预期内的结果」而不是接口异常,故把原因作为数据返回,
|
||||||
|
// 让前端能直接在界面上展示,而不是弹一个通用错误。
|
||||||
|
g.Log().Infof(ctx, "平台连通性测试未通过 code=%s: %v", req.Code, err)
|
||||||
|
return &wallpaperv1.AdminSourceTestRes{Ok: false, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return &wallpaperv1.AdminSourceTestRes{Ok: true, Message: msg}, nil
|
||||||
|
}
|
||||||
83
internal/controller/wallpaper/controller.go
Normal file
83
internal/controller/wallpaper/controller.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// Package wallpaper 壁纸模块控制器。
|
||||||
|
//
|
||||||
|
// 分两个控制器:
|
||||||
|
// - Controller(admin):后台图库管理与平台配置,走 RBAC 权限;
|
||||||
|
// - OpenController(open):前台读取,免鉴权。
|
||||||
|
//
|
||||||
|
// 控制器只做「参数搬运 + 结构转换」,业务逻辑一律在 internal/service/wallpaper。
|
||||||
|
package wallpaper
|
||||||
|
|
||||||
|
import (
|
||||||
|
wallpaperv1 "service.xpcool.com/api/wallpaper"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toAPIItem 把领域 DTO 转成接口输出结构。
|
||||||
|
// 两者字段一一对应,单独转换是为了让 API 契约与内部结构可以各自演进。
|
||||||
|
func toAPIItem(v *dto.WallpaperItem) *wallpaperv1.Item {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &wallpaperv1.Item{
|
||||||
|
Id: v.Id,
|
||||||
|
Title: v.Title,
|
||||||
|
Width: v.Width,
|
||||||
|
Height: v.Height,
|
||||||
|
Orientation: v.Orientation,
|
||||||
|
ThumbUrl: v.ThumbUrl,
|
||||||
|
PreviewUrl: v.PreviewUrl,
|
||||||
|
FullUrl: v.FullUrl,
|
||||||
|
Source: v.Source,
|
||||||
|
SourceName: v.SourceName,
|
||||||
|
FromOpen: v.FromOpen,
|
||||||
|
Author: v.Author,
|
||||||
|
AuthorUrl: v.AuthorUrl,
|
||||||
|
PageUrl: v.PageUrl,
|
||||||
|
License: v.License,
|
||||||
|
Tags: v.Tags,
|
||||||
|
Category: v.Category,
|
||||||
|
Filesize: v.Filesize,
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
Sort: v.Sort,
|
||||||
|
Remark: v.Remark,
|
||||||
|
CreatedAt: v.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toAPIItems 批量转换。
|
||||||
|
func toAPIItems(list []dto.WallpaperItem) []*wallpaperv1.Item {
|
||||||
|
out := make([]*wallpaperv1.Item, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out = append(out, toAPIItem(&list[i]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toAPISource 把平台信息转成接口输出结构。
|
||||||
|
//
|
||||||
|
// withConfig 控制是否下发脱敏后的配置:后台需要它回显,前台不需要
|
||||||
|
// (自建反代地址之类属于内部信息,没必要给公网浏览器)。
|
||||||
|
func toAPISource(v *dto.WallpaperSourceInfo, withConfig bool) *wallpaperv1.SourceInfo {
|
||||||
|
out := &wallpaperv1.SourceInfo{
|
||||||
|
Code: v.Code,
|
||||||
|
Name: v.Name,
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
Sort: v.Sort,
|
||||||
|
Configured: v.Configured,
|
||||||
|
Available: v.Available,
|
||||||
|
Hint: v.Hint,
|
||||||
|
Remark: v.Remark,
|
||||||
|
HasApiKey: v.HasApiKey,
|
||||||
|
}
|
||||||
|
if withConfig && v.Config != nil {
|
||||||
|
out.Config = &wallpaperv1.SourceConfig{
|
||||||
|
ApiKey: v.Config.ApiKey,
|
||||||
|
ApiSecret: v.Config.ApiSecret,
|
||||||
|
DefaultQuery: v.Config.DefaultQuery,
|
||||||
|
Purity: v.Config.Purity,
|
||||||
|
Categories: v.Config.Categories,
|
||||||
|
BaseUrl: v.Config.BaseUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
88
internal/controller/wallpaper/open.go
Normal file
88
internal/controller/wallpaper/open.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package wallpaper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
wallpaperv1 "service.xpcool.com/api/wallpaper"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenController 前台读取接口(免鉴权,供 xpcool.com 调用)。
|
||||||
|
type OpenController struct{}
|
||||||
|
|
||||||
|
// NewOpen 构造前台控制器。
|
||||||
|
func NewOpen() *OpenController { return &OpenController{} }
|
||||||
|
|
||||||
|
// Sources 返回全部来源清单。
|
||||||
|
//
|
||||||
|
// 除开源平台外还带回自建图库的启用数量:前台据此决定要不要显示
|
||||||
|
// 「我的图库」这个入口 —— 一张图都没有时显示它只会让人点进去看空白。
|
||||||
|
func (c *OpenController) Sources(ctx context.Context, req *wallpaperv1.OpenSourceListReq) (res *wallpaperv1.OpenSourceListRes, err error) {
|
||||||
|
svc := wallpapersvc.Wallpaper()
|
||||||
|
list, err := svc.Sources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 图库计数失败不影响平台列表:把 0 当成「没有自建图」,入口不显示即可,
|
||||||
|
// 没必要因为一个计数把整个来源接口拖垮。
|
||||||
|
mineCount, err := svc.MineCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
g.Log().Warningf(ctx, "统计自建图库数量失败: %v", err)
|
||||||
|
mineCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]*wallpaperv1.SourceInfo, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
// 前台不带平台配置:公开接口没必要下发 baseUrl 等内部信息。
|
||||||
|
out = append(out, toAPISource(&list[i], false))
|
||||||
|
}
|
||||||
|
return &wallpaperv1.OpenSourceListRes{List: out, MineCount: mineCount}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 按来源分页取图。
|
||||||
|
func (c *OpenController) List(ctx context.Context, req *wallpaperv1.OpenListReq) (res *wallpaperv1.OpenListRes, err error) {
|
||||||
|
q := dto.WallpaperQuery{
|
||||||
|
Source: req.Source,
|
||||||
|
Query: req.Query,
|
||||||
|
Orientation: req.Orientation,
|
||||||
|
Tag: req.Tag,
|
||||||
|
Page: req.Page,
|
||||||
|
Size: req.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
items, total, err := wallpapersvc.Wallpaper().List(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
// 前台是公开接口,平台侧的原始错误(含 URL、Key 片段)不应直接抛给浏览器
|
||||||
|
g.Log().Warningf(ctx, "壁纸列表获取失败 source=%s: %v", req.Source, err)
|
||||||
|
return nil, gerror.New("该来源暂时不可用,请换一个来源试试")
|
||||||
|
}
|
||||||
|
return &wallpaperv1.OpenListRes{
|
||||||
|
List: toAPIItems(items),
|
||||||
|
Total: total,
|
||||||
|
Page: q.Page,
|
||||||
|
Size: q.Size,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random 跨来源随机取一张。
|
||||||
|
func (c *OpenController) Random(ctx context.Context, req *wallpaperv1.OpenRandomReq) (res *wallpaperv1.OpenRandomRes, err error) {
|
||||||
|
item, err := wallpapersvc.Wallpaper().Random(ctx, req.Sources, req.Orientation)
|
||||||
|
if err != nil {
|
||||||
|
g.Log().Warningf(ctx, "随机取壁纸失败 sources=%v: %v", req.Sources, err)
|
||||||
|
return nil, gerror.New("暂时取不到壁纸,请稍后再试")
|
||||||
|
}
|
||||||
|
return &wallpaperv1.OpenRandomRes{Item: toAPIItem(item)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackDownload 下载回调(目前仅 Unsplash 需要)。
|
||||||
|
// 刻意做成「永远返回成功」:这是统计性质的回调,不能因为它失败而挡住用户下载。
|
||||||
|
func (c *OpenController) TrackDownload(ctx context.Context, req *wallpaperv1.OpenTrackDownloadReq) (res *wallpaperv1.OpenTrackDownloadRes, err error) {
|
||||||
|
if err = wallpapersvc.Wallpaper().TrackDownload(ctx, req.Source, req.Id); err != nil {
|
||||||
|
g.Log().Warningf(ctx, "下载回调失败 source=%s id=%s: %v", req.Source, req.Id, err)
|
||||||
|
}
|
||||||
|
return &wallpaperv1.OpenTrackDownloadRes{Message: "ok"}, nil
|
||||||
|
}
|
||||||
22
internal/dao/admin_login_log.go
Normal file
22
internal/dao/admin_login_log.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// =================================================================================
|
||||||
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"service.xpcool.com/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminLoginLogDao 是表 admin_login_log 的数据访问对象。
|
||||||
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
|
type adminLoginLogDao struct {
|
||||||
|
*internal.AdminLoginLogDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// AdminLoginLog 是表 admin_login_log 的全局可访问操作对象。
|
||||||
|
AdminLoginLog = adminLoginLogDao{internal.NewAdminLoginLogDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在下方添加你的自定义方法。
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
|
|
||||||
package dao
|
package dao
|
||||||
@ -8,15 +8,15 @@ import (
|
|||||||
"service.xpcool.com/internal/dao/internal"
|
"service.xpcool.com/internal/dao/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminMenuDao is the data access object for the table admin_menu.
|
// adminMenuDao 是表 admin_menu 的数据访问对象。
|
||||||
// You can define custom methods on it to extend its functionality as needed.
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
type adminMenuDao struct {
|
type adminMenuDao struct {
|
||||||
*internal.AdminMenuDao
|
*internal.AdminMenuDao
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AdminMenu is a globally accessible object for table admin_menu operations.
|
// AdminMenu 是表 admin_menu 的全局可访问操作对象。
|
||||||
AdminMenu = adminMenuDao{internal.NewAdminMenuDao()}
|
AdminMenu = adminMenuDao{internal.NewAdminMenuDao()}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add your custom methods and functionality below.
|
// 在下方添加你的自定义方法。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
|
|
||||||
package dao
|
package dao
|
||||||
@ -8,15 +8,15 @@ import (
|
|||||||
"service.xpcool.com/internal/dao/internal"
|
"service.xpcool.com/internal/dao/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminOperationLogDao is the data access object for the table admin_operation_log.
|
// adminOperationLogDao 是表 admin_operation_log 的数据访问对象。
|
||||||
// You can define custom methods on it to extend its functionality as needed.
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
type adminOperationLogDao struct {
|
type adminOperationLogDao struct {
|
||||||
*internal.AdminOperationLogDao
|
*internal.AdminOperationLogDao
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AdminOperationLog is a globally accessible object for table admin_operation_log operations.
|
// AdminOperationLog 是表 admin_operation_log 的全局可访问操作对象。
|
||||||
AdminOperationLog = adminOperationLogDao{internal.NewAdminOperationLogDao()}
|
AdminOperationLog = adminOperationLogDao{internal.NewAdminOperationLogDao()}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add your custom methods and functionality below.
|
// 在下方添加你的自定义方法。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
|
|
||||||
package dao
|
package dao
|
||||||
@ -8,15 +8,15 @@ import (
|
|||||||
"service.xpcool.com/internal/dao/internal"
|
"service.xpcool.com/internal/dao/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminRoleDao is the data access object for the table admin_role.
|
// adminRoleDao 是表 admin_role 的数据访问对象。
|
||||||
// You can define custom methods on it to extend its functionality as needed.
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
type adminRoleDao struct {
|
type adminRoleDao struct {
|
||||||
*internal.AdminRoleDao
|
*internal.AdminRoleDao
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AdminRole is a globally accessible object for table admin_role operations.
|
// AdminRole 是表 admin_role 的全局可访问操作对象。
|
||||||
AdminRole = adminRoleDao{internal.NewAdminRoleDao()}
|
AdminRole = adminRoleDao{internal.NewAdminRoleDao()}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add your custom methods and functionality below.
|
// 在下方添加你的自定义方法。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
|
|
||||||
package dao
|
package dao
|
||||||
@ -8,15 +8,15 @@ import (
|
|||||||
"service.xpcool.com/internal/dao/internal"
|
"service.xpcool.com/internal/dao/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminRoleMenuDao is the data access object for the table admin_role_menu.
|
// adminRoleMenuDao 是表 admin_role_menu 的数据访问对象。
|
||||||
// You can define custom methods on it to extend its functionality as needed.
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
type adminRoleMenuDao struct {
|
type adminRoleMenuDao struct {
|
||||||
*internal.AdminRoleMenuDao
|
*internal.AdminRoleMenuDao
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AdminRoleMenu is a globally accessible object for table admin_role_menu operations.
|
// AdminRoleMenu 是表 admin_role_menu 的全局可访问操作对象。
|
||||||
AdminRoleMenu = adminRoleMenuDao{internal.NewAdminRoleMenuDao()}
|
AdminRoleMenu = adminRoleMenuDao{internal.NewAdminRoleMenuDao()}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add your custom methods and functionality below.
|
// 在下方添加你的自定义方法。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// =================================================================================
|
// =================================================================================
|
||||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
// 本文件由 GoFrame CLI 工具自动生成,可按需修改。
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
|
|
||||||
package dao
|
package dao
|
||||||
@ -8,15 +8,15 @@ import (
|
|||||||
"service.xpcool.com/internal/dao/internal"
|
"service.xpcool.com/internal/dao/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminUserDao is the data access object for the table admin_user.
|
// adminUserDao 是表 admin_user 的数据访问对象。
|
||||||
// You can define custom methods on it to extend its functionality as needed.
|
// 可在其上定义自定义方法以扩展其功能。
|
||||||
type adminUserDao struct {
|
type adminUserDao struct {
|
||||||
*internal.AdminUserDao
|
*internal.AdminUserDao
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AdminUser is a globally accessible object for table admin_user operations.
|
// AdminUser 是表 admin_user 的全局可访问操作对象。
|
||||||
AdminUser = adminUserDao{internal.NewAdminUserDao()}
|
AdminUser = adminUserDao{internal.NewAdminUserDao()}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add your custom methods and functionality below.
|
// 在下方添加你的自定义方法。
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user