diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md
index 90e84fb..1c5d195 100644
--- a/.workbuddy/memory/CHANGELOG.md
+++ b/.workbuddy/memory/CHANGELOG.md
@@ -1,6 +1,10 @@
# service.xpcool.com 变更记录
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
+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(仓库内尚未配置,待补)
diff --git a/api/house/presale/presale.go b/api/house/presale/presale.go
new file mode 100644
index 0000000..e359953
--- /dev/null
+++ b/api/house/presale/presale.go
@@ -0,0 +1,36 @@
+// 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"`
+ 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"`
+}
diff --git a/go.sum b/go.sum
index dba8ac4..1dcf7ac 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lionsoul2014/ip2region v3.17.0+incompatible h1:91PbwotwoEMPV6nfAALL+WTmYHmTuRNFgJX6T8uPUNY=
+github.com/lionsoul2014/ip2region v3.17.0+incompatible/go.mod h1:+ZBN7PBoh5gG6/y0ZQ85vJDBe21WnfbRrQQwTfliJJI=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
diff --git a/hack/config.yaml b/hack/config.yaml
index 76d7735..093485a 100644
--- a/hack/config.yaml
+++ b/hack/config.yaml
@@ -6,7 +6,7 @@ gfcli:
dao:
- link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com"
descriptionTag: true
- tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference"
+ tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale"
docker:
build: "-a amd64 -s linux -p temp -ew"
diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go
index fa7a495..27a27de 100644
--- a/internal/cmd/cmd.go
+++ b/internal/cmd/cmd.go
@@ -12,6 +12,7 @@ import (
adminctl "service.xpcool.com/internal/controller/admin"
housectl "service.xpcool.com/internal/controller/house"
openctl "service.xpcool.com/internal/controller/open"
+ recruitmentctl "service.xpcool.com/internal/controller/recruitment"
userctl "service.xpcool.com/internal/controller/user"
"service.xpcool.com/internal/library/jwt"
"service.xpcool.com/internal/middleware"
@@ -28,6 +29,8 @@ import (
housedashboard "service.xpcool.com/internal/service/house/dashboard"
houselisting "service.xpcool.com/internal/service/house/listing"
housetransaction "service.xpcool.com/internal/service/house/transaction"
+ housepresale "service.xpcool.com/internal/service/house/presale"
+ recruitmentsvc "service.xpcool.com/internal/service/recruitment"
)
// injectEnv 手动把关键环境变量写入配置系统。
@@ -44,6 +47,19 @@ func injectEnv(ctx context.Context) {
if v := genv.Get("JWT_SECRET"); !v.IsEmpty() {
_ = adapter.Set("jwt.secret", v.String())
}
+ // 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
+ if v := genv.Get("RECRUITMENT_DB_DSN"); !v.IsEmpty() {
+ _ = adapter.Set("database.recruitment.link", v.String())
+ }
+ if v := genv.Get("BARK_BASE_URL"); !v.IsEmpty() {
+ _ = adapter.Set("bark.baseUrl", v.String())
+ }
+ if v := genv.Get("BARK_DEVICE_KEY"); !v.IsEmpty() {
+ _ = adapter.Set("bark.deviceKey", v.String())
+ }
+ if v := genv.Get("BARK_PUSH_TIME"); !v.IsEmpty() {
+ _ = adapter.Set("bark.pushTime", v.String())
+ }
}
var (
@@ -68,6 +84,8 @@ var (
houselisting.RegisterListing(houselisting.NewListing())
housedashboard.RegisterDashboard(housedashboard.NewDashboard())
housetransaction.RegisterTransaction(housetransaction.NewTransaction())
+ housepresale.RegisterPresale(housepresale.NewPresale())
+ recruitmentsvc.RegisterRecruitment(recruitmentsvc.New())
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(openctl.New()) // Open tools API for frontends, no auth required.
@@ -88,13 +106,16 @@ var (
group.Group("/", func(protected *ghttp.RouterGroup) {
// 受权限保护端点:RBAC 管理、日志等。
// 权限由后端按「方法+路径」自动匹配,无需前端传 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) {
- adminaudit.AdminAudit().Record(ctx, adminaudit.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status})
+ 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())
})
})
+ // 启动招聘模块定时任务(每日增量抓取 + 早报推送)。
+ recruitmentsvc.StartScheduler(ctx)
s.Run()
return nil
},
diff --git a/internal/controller/admin/log.go b/internal/controller/admin/log.go
index 467e520..0d353b7 100644
--- a/internal/controller/admin/log.go
+++ b/internal/controller/admin/log.go
@@ -34,16 +34,24 @@ func (c *Controller) LogTail(ctx context.Context, req *logv1.LogTailReq) (res *l
// 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, Permission: req.Keyword})
+ 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, Permission: it.Permission, Method: it.Method,
- Path: it.Path, IP: it.IP, Param: it.Param, DurationMS: it.DurationMS,
- StatusCode: it.StatusCode, CreatedAt: it.CreatedAt,
+ 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
diff --git a/internal/controller/house/presale.go b/internal/controller/house/presale.go
new file mode 100644
index 0000000..2cb794f
--- /dev/null
+++ b/internal/controller/house/presale.go
@@ -0,0 +1,35 @@
+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,
+ Source: v.Source,
+ })
+ }
+ return &presalev1.PresaleListRes{List: out, Total: total}, nil
+}
diff --git a/internal/dao/house_presale.go b/internal/dao/house_presale.go
new file mode 100644
index 0000000..8f4a288
--- /dev/null
+++ b/internal/dao/house_presale.go
@@ -0,0 +1,22 @@
+// =================================================================================
+// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
+// =================================================================================
+
+package dao
+
+import (
+ "service.xpcool.com/internal/dao/internal"
+)
+
+// housePresaleDao is the data access object for the table house_presale.
+// You can define custom methods on it to extend its functionality as needed.
+type housePresaleDao struct {
+ *internal.HousePresaleDao
+}
+
+var (
+ // HousePresale is a globally accessible object for table house_presale operations.
+ HousePresale = housePresaleDao{internal.NewHousePresaleDao()}
+)
+
+// Add your custom methods and functionality below.
diff --git a/internal/dao/internal/house_community.go b/internal/dao/internal/house_community.go
index 89d6557..59c893e 100644
--- a/internal/dao/internal/house_community.go
+++ b/internal/dao/internal/house_community.go
@@ -32,6 +32,7 @@ type HouseCommunityColumns struct {
Households string // 总户数
PlotRatio string // 容积率
GreenRate string // 绿化率
+ AvgPrice string // 小区参考均价(元/平米)
PropertyCompany string // 物业公司
PropertyFee string // 物业费(元/月/平米)
Developer string // 开发商
@@ -54,6 +55,7 @@ var houseCommunityColumns = HouseCommunityColumns{
Households: "households",
PlotRatio: "plot_ratio",
GreenRate: "green_rate",
+ AvgPrice: "avg_price",
PropertyCompany: "property_company",
PropertyFee: "property_fee",
Developer: "developer",
diff --git a/internal/dao/internal/house_presale.go b/internal/dao/internal/house_presale.go
new file mode 100644
index 0000000..c432901
--- /dev/null
+++ b/internal/dao/internal/house_presale.go
@@ -0,0 +1,107 @@
+// ==========================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// ==========================================================================
+
+package internal
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// HousePresaleDao is the data access object for the table house_presale.
+type HousePresaleDao struct {
+ table string // table is the underlying table name of the DAO.
+ group string // group is the database configuration group name of the current DAO.
+ columns HousePresaleColumns // columns contains all the column names of Table for convenient usage.
+ handlers []gdb.ModelHandler // handlers for customized model modification.
+}
+
+// HousePresaleColumns defines and stores column names for the table house_presale.
+type HousePresaleColumns struct {
+ Id string //
+ PresaleNo string // 预售证号
+ CommunityName string // 楼盘/项目名
+ Developer string // 房地产开发企业
+ Region string // 区县
+ Address string // 建筑位置
+ BuildingNo string // 楼栋号
+ HouseCount string // 套数
+ Area string // 建筑面积(平米)
+ Purpose string // 规划用途(住宅/商业)
+ IssueDate string // 初始核发日期
+ Source string // 数据来源
+ CreatedAt string //
+ UpdatedAt string //
+ DeletedAt string //
+}
+
+// housePresaleColumns holds the columns for the table house_presale.
+var housePresaleColumns = HousePresaleColumns{
+ Id: "id",
+ PresaleNo: "presale_no",
+ CommunityName: "community_name",
+ Developer: "developer",
+ Region: "region",
+ Address: "address",
+ BuildingNo: "building_no",
+ HouseCount: "house_count",
+ Area: "area",
+ Purpose: "purpose",
+ IssueDate: "issue_date",
+ Source: "source",
+ CreatedAt: "created_at",
+ UpdatedAt: "updated_at",
+ DeletedAt: "deleted_at",
+}
+
+// NewHousePresaleDao creates and returns a new DAO object for table data access.
+func NewHousePresaleDao(handlers ...gdb.ModelHandler) *HousePresaleDao {
+ return &HousePresaleDao{
+ group: "default",
+ table: "house_presale",
+ columns: housePresaleColumns,
+ handlers: handlers,
+ }
+}
+
+// DB retrieves and returns the underlying raw database management object of the current DAO.
+func (dao *HousePresaleDao) DB() gdb.DB {
+ return g.DB(dao.group)
+}
+
+// Table returns the table name of the current DAO.
+func (dao *HousePresaleDao) Table() string {
+ return dao.table
+}
+
+// Columns returns all column names of the current DAO.
+func (dao *HousePresaleDao) Columns() HousePresaleColumns {
+ return dao.columns
+}
+
+// Group returns the database configuration group name of the current DAO.
+func (dao *HousePresaleDao) Group() string {
+ return dao.group
+}
+
+// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
+func (dao *HousePresaleDao) Ctx(ctx context.Context) *gdb.Model {
+ model := dao.DB().Model(dao.table)
+ for _, handler := range dao.handlers {
+ model = handler(model)
+ }
+ return model.Safe().Ctx(ctx)
+}
+
+// Transaction wraps the transaction logic using function f.
+// It rolls back the transaction and returns the error if function f returns a non-nil error.
+// It commits the transaction and returns nil if function f returns nil.
+//
+// Note: Do not commit or roll back the transaction in function f,
+// as it is automatically handled by this function.
+func (dao *HousePresaleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
+ return dao.Ctx(ctx).Transaction(ctx, f)
+}
diff --git a/internal/library/iploc/ip2region.xdb b/internal/library/iploc/ip2region.xdb
new file mode 100644
index 0000000..0e75dc8
Binary files /dev/null and b/internal/library/iploc/ip2region.xdb differ
diff --git a/internal/library/iploc/iploc.go b/internal/library/iploc/iploc.go
new file mode 100644
index 0000000..2028a50
--- /dev/null
+++ b/internal/library/iploc/iploc.go
@@ -0,0 +1,99 @@
+// Package iploc 提供离线 IP 归属地解析(省/市/运营商)。
+// 底层数据使用 ip2region 的 xdb(v4) 数据库,通过 go:embed 直接打进二进制,
+// 无需外部文件、无需联网,单次查询为内存二分查找,性能稳定。
+package iploc
+
+import (
+ _ "embed"
+ "encoding/binary"
+ "net"
+ "strings"
+)
+
+//go:embed ip2region.xdb
+var xdbContent []byte
+
+// xdb v4 文件结构常量(与官方 C binding 保持一致)。
+const (
+ headerInfoLength = 256 // 头部信息长度
+ vectorIndexCols = 256 // 向量索引列数
+ vectorIndexSize = 8 // 每条向量索引 8 字节(起始 ptr + 结束 ptr)
+ v4IndexSize = 14 // IPv4 段索引块:4(IP 起) + 4(IP 止) + 2(数据长度) + 4(数据偏移)
+)
+
+// Locate 解析 IPv4 的归属地,返回形如 "广东 深圳 电信" 的可读字符串。
+// 内部/无效/解析失败的 IP 返回空字符串(调用方据此决定展示策略)。
+// 备注:当前仅支持 IPv4;IPv6 直接返回空,避免误判。
+func Locate(ipStr string) string {
+ ip := net.ParseIP(ipStr)
+ if ip == nil {
+ return ""
+ }
+ v4 := ip.To4()
+ if v4 == nil {
+ // 暂不支持 IPv6 归属地解析,返回空。
+ return ""
+ }
+ // v4 已是网络序 4 字节 [a,b,c,d],按大端整型比较即可与段索引块中的起止 IP 对齐。
+ ipUint := binary.BigEndian.Uint32(v4)
+
+ // 1) 由前两个字节定位向量索引,得到段索引区间 [sPtr, ePtr]。
+ il0 := int(v4[0])
+ il1 := int(v4[1])
+ idx := il0*vectorIndexCols*vectorIndexSize + il1*vectorIndexSize
+ vOff := headerInfoLength + idx
+ if vOff+8 > len(xdbContent) {
+ return ""
+ }
+ sPtr := binary.LittleEndian.Uint32(xdbContent[vOff : vOff+4])
+ ePtr := binary.LittleEndian.Uint32(xdbContent[vOff+4 : vOff+8])
+ if sPtr == 0 || ePtr == 0 {
+ // 该前缀段无数据,归属地未知。
+ return ""
+ }
+
+ // 2) 在段索引区间中二分查找命中 IP 的段(段索引块按起始 IP 有序)。
+ l, h := 0, int((ePtr-sPtr)/v4IndexSize)
+ dataPtr, dataLen := uint32(0), uint16(0)
+ for l <= h {
+ m := (l + h) >> 1
+ p := sPtr + uint32(m)*v4IndexSize
+ if int(p)+v4IndexSize > len(xdbContent) {
+ break
+ }
+ startIP := binary.BigEndian.Uint32(xdbContent[p : p+4])
+ endIP := binary.BigEndian.Uint32(xdbContent[p+4 : p+8])
+ switch {
+ case ipUint < startIP:
+ h = m - 1
+ case ipUint > endIP:
+ l = m + 1
+ default:
+ // 命中:数据长度在偏移 8(小端 uint16),数据偏移在偏移 10(小端 uint32)。
+ dataLen = binary.LittleEndian.Uint16(xdbContent[p+8 : p+10])
+ dataPtr = binary.LittleEndian.Uint32(xdbContent[p+10 : p+14])
+ l, h = 0, -1 // 退出循环
+ }
+ }
+ if dataLen == 0 {
+ return ""
+ }
+ if int(dataPtr)+int(dataLen) > len(xdbContent) {
+ return ""
+ }
+ return cleanRegion(string(xdbContent[dataPtr : dataPtr+uint32(dataLen)]))
+}
+
+// cleanRegion 将 "国家|区域|省份|城市|运营商" 中的 0/空 段剔除,拼接为可读归属地。
+func cleanRegion(raw string) string {
+ parts := strings.Split(raw, "|")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" || p == "0" {
+ continue
+ }
+ out = append(out, p)
+ }
+ return strings.Join(out, " ")
+}
diff --git a/internal/library/iploc/iploc_test.go b/internal/library/iploc/iploc_test.go
new file mode 100644
index 0000000..dd01553
--- /dev/null
+++ b/internal/library/iploc/iploc_test.go
@@ -0,0 +1,20 @@
+package iploc
+
+import "testing"
+
+func TestLocateSmoke(t *testing.T) {
+ cases := []string{
+ "8.8.8.8", // Google 公共 DNS(美国)
+ "1.1.1.1", // Cloudflare
+ "114.114.114.114", // 国内公共 DNS
+ "180.76.76.76", // 百度
+ "218.4.116.1", // 江苏电信
+ "192.168.1.1", // 内网
+ "127.0.0.1", // 回环
+ "not-an-ip", // 非法
+ "::1", // IPv6(暂不支持)
+ }
+ for _, ip := range cases {
+ t.Logf("ip=%-18s -> %q", ip, Locate(ip))
+ }
+}
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
index 04c52ea..e47dff3 100644
--- a/internal/middleware/auth.go
+++ b/internal/middleware/auth.go
@@ -2,6 +2,7 @@ package middleware
import (
"context"
+ "encoding/json"
"github.com/gogf/gf/v2/net/ghttp"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/library/jwt"
@@ -30,6 +31,25 @@ func auditParam(raw string) string {
}
return raw
}
+
+// extractErrorMsg 当响应状态码 >= 400 时,从响应体 JSON 中提取 msg 作为失败原因摘要。
+// 非失败场景返回空字符串,避免无意义写入。
+func extractErrorMsg(r *ghttp.Request) string {
+ if r.Response.Status < 400 {
+ return ""
+ }
+ body := r.Response.BufferString()
+ if len(body) == 0 {
+ return ""
+ }
+ var out struct {
+ Msg string `json:"msg"`
+ }
+ if err := json.Unmarshal([]byte(body), &out); err == nil && out.Msg != "" {
+ return out.Msg
+ }
+ return string(body)
+}
func UserAuth(s *jwt.Service) ghttp.HandlerFunc {
// 用户端仅接受 scope=user 的 access token。
return func(r *ghttp.Request) {
@@ -55,7 +75,7 @@ func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc {
r.Middleware.Next()
}
}
-func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, string) (string, error), permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int)) ghttp.HandlerFunc {
+func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, string) (string, error), permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int, string, string)) ghttp.HandlerFunc {
// 管理端接口鉴权:先解析 admin token,再按「请求方法+路径」反查所需权限码
// (admin_menu type=2 行的 path 映射),最后校验该管理员是否拥有该权限码。
// 未配置映射的接口一律拒绝,防止用任意已拥有权限码越权访问。
@@ -77,7 +97,8 @@ func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, st
return
}
defer func() {
- audit(r.Context(), c.Subject, permission, r.Method, r.URL.Path, r.GetClientIp(), auditParam(r.GetBodyString()), int(time.Since(start).Milliseconds()), r.Response.Status)
+ // 失败时从响应体解析 msg 作为错误摘要;UA 直接取请求头。
+ audit(r.Context(), c.Subject, permission, r.Method, r.URL.Path, r.GetClientIp(), auditParam(r.GetBodyString()), int(time.Since(start).Milliseconds()), r.Response.Status, r.Header.Get("User-Agent"), extractErrorMsg(r))
}()
r.SetCtxVar(AdminIDKey, c.Subject)
r.SetCtxVar(PermissionKey, permission)
diff --git a/internal/model/do/admin_operation_log.go b/internal/model/do/admin_operation_log.go
index f699865..4e5c7e1 100644
--- a/internal/model/do/admin_operation_log.go
+++ b/internal/model/do/admin_operation_log.go
@@ -11,17 +11,21 @@ import (
// AdminOperationLog 是表 admin_operation_log 的 Go 结构体,供 DAO 的 Where/Data 等操作使用。
type AdminOperationLog struct {
- g.Meta `orm:"table:admin_operation_log, do:true"`
- Id any //
- AdminUserId any //
- Permission any //
- Method any //
- Path any //
- Ip any //
- RequestParam any //
- DurationMs any //
- StatusCode any //
- CreatedAt *gtime.Time //
- UpdatedAt *gtime.Time //
- DeletedAt *gtime.Time //
+ g.Meta `orm:"table:admin_operation_log, do:true"`
+ Id any //
+ AdminUserId any //
+ AdminUsername any // 管理员账号(冗余)
+ Permission any //
+ Method any //
+ Path any //
+ Ip any //
+ IpLocation any // IP 归属地
+ RequestParam any //
+ DurationMs any //
+ StatusCode any //
+ ErrorMessage any // 失败原因
+ UserAgent any // 浏览器 UA
+ CreatedAt *gtime.Time //
+ UpdatedAt *gtime.Time //
+ DeletedAt *gtime.Time //
}
diff --git a/internal/model/do/house_community.go b/internal/model/do/house_community.go
index 77f9281..6be2b5b 100644
--- a/internal/model/do/house_community.go
+++ b/internal/model/do/house_community.go
@@ -22,6 +22,7 @@ type HouseCommunity struct {
Households any // 总户数
PlotRatio any // 容积率
GreenRate any // 绿化率
+ AvgPrice any // 小区参考均价(元/平米)
PropertyCompany any // 物业公司
PropertyFee any // 物业费(元/月/平米)
Developer any // 开发商
diff --git a/internal/model/do/house_presale.go b/internal/model/do/house_presale.go
new file mode 100644
index 0000000..6daae79
--- /dev/null
+++ b/internal/model/do/house_presale.go
@@ -0,0 +1,29 @@
+// =================================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// =================================================================================
+
+package do
+
+import (
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// HousePresale is the golang structure of table house_presale for DAO operations like Where/Data.
+type HousePresale struct {
+ g.Meta `orm:"table:house_presale, do:true"`
+ Id any //
+ PresaleNo any // 预售证号
+ CommunityName any // 楼盘/项目名
+ Developer any // 房地产开发企业
+ Region any // 区县
+ Address any // 建筑位置
+ BuildingNo any // 楼栋号
+ HouseCount any // 套数
+ Area any // 建筑面积(平米)
+ Purpose any // 规划用途(住宅/商业)
+ IssueDate any // 初始核发日期
+ Source any // 数据来源
+ CreatedAt any //
+ UpdatedAt any //
+ DeletedAt any //
+}
diff --git a/internal/model/dto/house.go b/internal/model/dto/house.go
index c8f0234..32db80e 100644
--- a/internal/model/dto/house.go
+++ b/internal/model/dto/house.go
@@ -79,6 +79,11 @@ type HouseTransactionVO struct {
CommunityName string `json:"communityName" orm:"community_name"`
}
+// HousePresaleVO 新房预售证查询结果(表内已含楼盘名,无需关联)。
+type HousePresaleVO struct {
+ entity.HousePresale
+}
+
// DashboardOverview 看板统计概览。
type DashboardOverview struct {
CommunityCount int
diff --git a/internal/model/dto/log.go b/internal/model/dto/log.go
index 2f63f16..33cf8eb 100644
--- a/internal/model/dto/log.go
+++ b/internal/model/dto/log.go
@@ -9,26 +9,41 @@ type LogFile struct {
ModTime string
}
-// LogQuery 管理员操作日志(admin 系统日志)分页查询参数。
+// LogQuery 管理员操作日志(admin 系统日志)分页查询参数,覆盖时间、账号、IP、方法、结果、耗时、排序等维度。
type LogQuery struct {
- Page int
- Size int
- AdminID uint64 // 按管理员过滤
- Permission string // 按权限码/路径关键字过滤
+ Page int
+ Size int
+ AdminID uint64 // 按管理员ID精确过滤
+ Username string // 按管理员账号模糊过滤(冗余字段,便于检索)
+ IP string // 按来源 IP 模糊过滤
+ IpLocation string // 按 IP 归属地模糊过滤
+ Method string // 按 HTTP 方法精确过滤(GET/POST/PUT/DELETE...)
+ Status int // 结果筛选:0 全部 / 1 成功(2xx) / 2 失败(非2xx)
+ Keyword string // 关键字:匹配 permission / path / ip
+ StartTime string // 起始时间 created_at >=,格式 2006-01-02 15:04:05
+ EndTime string // 结束时间 created_at <=,格式 2006-01-02 15:04:05
+ MinDuration int // 耗时下限(ms),0 表示不限
+ MaxDuration int // 耗时上限(ms),0 表示不限
+ OrderBy string // 排序字段:createdAt(默认) | durationMs
+ OrderDir string // 排序方向:desc(默认) | asc
}
// LogItem 一条管理员操作日志记录。
type LogItem struct {
- Id uint64
- AdminID uint64
- Permission string
- Method string
- Path string
- IP string
- Param string
- DurationMS uint
- StatusCode int
- CreatedAt string
+ Id uint64
+ AdminID uint64
+ AdminUsername string
+ Permission string
+ Method string
+ Path string
+ IP string
+ IpLocation string
+ Param string
+ DurationMS uint
+ StatusCode int
+ ErrorMessage string
+ UserAgent string
+ CreatedAt string
}
// LoginLogQuery 管理员登录日志分页查询参数。
diff --git a/internal/model/dto/recruitment.go b/internal/model/dto/recruitment.go
index 97c27e9..6176449 100644
--- a/internal/model/dto/recruitment.go
+++ b/internal/model/dto/recruitment.go
@@ -116,11 +116,11 @@ type SubscriptionItem struct {
// CrawlRunResult 单次抓取运行结果(用于写日志与返回)。
type CrawlRunResult struct {
- SourceId uint64
- Fetched int
- NewCount int
- Updated int
- Err error
+ SourceId uint64
+ Fetched int
+ NewCount int
+ UpdatedCount int
+ Err error
}
// SubscriptionInput 推送订阅写入入参。
diff --git a/internal/model/entity/admin_operation_log.go b/internal/model/entity/admin_operation_log.go
index 4f0aa76..319a877 100644
--- a/internal/model/entity/admin_operation_log.go
+++ b/internal/model/entity/admin_operation_log.go
@@ -10,16 +10,20 @@ import (
// AdminOperationLog 是表 admin_operation_log 的 Go 结构体。
type AdminOperationLog struct {
- Id uint64 `json:"id" orm:"id" description:""` //
- AdminUserId uint64 `json:"adminUserId" orm:"admin_user_id" description:""` //
- Permission string `json:"permission" orm:"permission" description:""` //
- Method string `json:"method" orm:"method" description:""` //
- Path string `json:"path" orm:"path" description:""` //
- Ip string `json:"ip" orm:"ip" description:""` //
- RequestParam string `json:"requestParam" orm:"request_param" description:""` //
- DurationMs uint `json:"durationMs" orm:"duration_ms" description:""` //
- StatusCode int `json:"statusCode" orm:"status_code" description:""` //
- CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
- UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
- DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
+ Id uint64 `json:"id" orm:"id" description:""` //
+ AdminUserId uint64 `json:"adminUserId" orm:"admin_user_id" description:""` //
+ AdminUsername string `json:"adminUsername" orm:"admin_username" description:"管理员账号(冗余)"` //
+ Permission string `json:"permission" orm:"permission" description:""` //
+ Method string `json:"method" orm:"method" description:""` //
+ Path string `json:"path" orm:"path" description:""` //
+ Ip string `json:"ip" orm:"ip" description:""` //
+ IpLocation string `json:"ipLocation" orm:"ip_location" description:"IP 归属地"` //
+ RequestParam string `json:"requestParam" orm:"request_param" description:""` //
+ DurationMs uint `json:"durationMs" orm:"duration_ms" description:""` //
+ StatusCode int `json:"statusCode" orm:"status_code" description:""` //
+ ErrorMessage string `json:"errorMessage" orm:"error_message" description:"失败原因"` //
+ UserAgent string `json:"userAgent" orm:"user_agent" description:"浏览器 UA"` //
+ CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
+ UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
+ DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
}
diff --git a/internal/model/entity/house_community.go b/internal/model/entity/house_community.go
index 5aafbcc..087c227 100644
--- a/internal/model/entity/house_community.go
+++ b/internal/model/entity/house_community.go
@@ -17,6 +17,7 @@ type HouseCommunity struct {
Households int `json:"households" orm:"households" description:"总户数"` // 总户数
PlotRatio float64 `json:"plotRatio" orm:"plot_ratio" description:"容积率"` // 容积率
GreenRate float64 `json:"greenRate" orm:"green_rate" description:"绿化率"` // 绿化率
+ AvgPrice float64 `json:"avgPrice" orm:"avg_price" description:"小区参考均价(元/平米)"` // 小区参考均价(元/平米)
PropertyCompany string `json:"propertyCompany" orm:"property_company" description:"物业公司"` // 物业公司
PropertyFee float64 `json:"propertyFee" orm:"property_fee" description:"物业费(元/月/平米)"` // 物业费(元/月/平米)
Developer string `json:"developer" orm:"developer" description:"开发商"` // 开发商
diff --git a/internal/model/entity/house_presale.go b/internal/model/entity/house_presale.go
new file mode 100644
index 0000000..2bad8ab
--- /dev/null
+++ b/internal/model/entity/house_presale.go
@@ -0,0 +1,24 @@
+// =================================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// =================================================================================
+
+package entity
+
+// HousePresale is the golang structure for table house_presale.
+type HousePresale struct {
+ Id uint64 `json:"id" orm:"id" description:""` //
+ PresaleNo string `json:"presaleNo" orm:"presale_no" description:"预售证号"` // 预售证号
+ CommunityName string `json:"communityName" orm:"community_name" description:"楼盘/项目名"` // 楼盘/项目名
+ Developer string `json:"developer" orm:"developer" description:"房地产开发企业"` // 房地产开发企业
+ Region string `json:"region" orm:"region" description:"区县"` // 区县
+ Address string `json:"address" orm:"address" description:"建筑位置"` // 建筑位置
+ BuildingNo string `json:"buildingNo" orm:"building_no" description:"楼栋号"` // 楼栋号
+ HouseCount int `json:"houseCount" orm:"house_count" description:"套数"` // 套数
+ Area float64 `json:"area" orm:"area" description:"建筑面积(平米)"` // 建筑面积(平米)
+ Purpose string `json:"purpose" orm:"purpose" description:"规划用途(住宅/商业)"` // 规划用途(住宅/商业)
+ IssueDate string `json:"issueDate" orm:"issue_date" description:"初始核发日期"` // 初始核发日期
+ Source string `json:"source" orm:"source" description:"数据来源"` // 数据来源
+ CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
+ UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
+ DeletedAt string `json:"deletedAt" orm:"deleted_at" description:""` //
+}
diff --git a/internal/service/house/listing/listing.go b/internal/service/house/listing/listing.go
index 265a5b9..8c1211d 100644
--- a/internal/service/house/listing/listing.go
+++ b/internal/service/house/listing/listing.go
@@ -42,7 +42,6 @@ func RegisterListing(i IListing) { localListing = i }
// List 分页查询房源,关联小区名,支持多维筛选(管理列表与看板共用)。
func (s *listing) List(ctx context.Context, f dto.HouseListingFilter) ([]dto.HouseListingVO, int, error) {
m := dao.HouseListing.Ctx(ctx).As("l").LeftJoin("house_community c", "l.community_id=c.id")
- m = m.Fields("l.*, c.name AS community_name")
if f.CommunityId > 0 {
m = m.Where("l.community_id", f.CommunityId)
}
@@ -84,7 +83,7 @@ func (s *listing) List(ctx context.Context, f dto.HouseListingFilter) ([]dto.Hou
return nil, 0, gerror.Wrap(err, "count listing")
}
var list []dto.HouseListingVO
- if err = m.Clone().Page(f.Page, f.Size).OrderDesc("l.id").Scan(&list); err != nil {
+ if err = m.Clone().Fields("l.*, c.name AS community_name").Page(f.Page, f.Size).OrderDesc("l.id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query listing list")
}
return list, total, nil
diff --git a/internal/service/house/presale/presale.go b/internal/service/house/presale/presale.go
new file mode 100644
index 0000000..8dceb5c
--- /dev/null
+++ b/internal/service/house/presale/presale.go
@@ -0,0 +1,57 @@
+// Package house_presale 提供新房预售证领域服务。
+package house_presale
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+
+ "service.xpcool.com/internal/dao"
+ "service.xpcool.com/internal/model/dto"
+)
+
+// IPresale 新房预售证领域服务接口。
+type IPresale interface {
+ List(context.Context, int, int, string, string, string) ([]dto.HousePresaleVO, int, error)
+}
+
+type presale struct{}
+
+var localPresale IPresale
+
+func NewPresale() IPresale { return &presale{} }
+
+// Presale 返回已注册的预售证服务实现。
+func Presale() IPresale {
+ if localPresale == nil {
+ panic("Presale implementation not registered")
+ }
+ return localPresale
+}
+
+// RegisterPresale 注册预售证服务实现。
+func RegisterPresale(i IPresale) { localPresale = i }
+
+// List 分页查询新房预售证,按 id 倒序(最新发证在前)。
+func (s *presale) List(ctx context.Context, page, size int, region, keyword, purpose string) ([]dto.HousePresaleVO, int, error) {
+ m := dao.HousePresale.Ctx(ctx)
+ if region != "" {
+ m = m.Where("region", region)
+ }
+ if purpose != "" {
+ m = m.Where("purpose", purpose)
+ }
+ if keyword != "" {
+ m = m.Where("community_name LIKE ? OR developer LIKE ? OR presale_no LIKE ?",
+ "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
+ }
+ total, err := m.Clone().Count()
+ if err != nil {
+ return nil, 0, gerror.Wrap(err, "count presale")
+ }
+ var list []dto.HousePresaleVO
+ if err = m.Clone().Page(page, size).OrderDesc("id").Scan(&list); err != nil {
+ return nil, 0, gerror.Wrap(err, "query presale list")
+ }
+ return list, total, nil
+}
diff --git a/internal/service/house/transaction/transaction.go b/internal/service/house/transaction/transaction.go
index f415267..6a2a29f 100644
--- a/internal/service/house/transaction/transaction.go
+++ b/internal/service/house/transaction/transaction.go
@@ -35,8 +35,7 @@ func RegisterTransaction(i ITransaction) { localTransaction = i }
// List 分页查询成交记录,关联小区名,按成交日期倒序。
func (s *transaction) List(ctx context.Context, page, size int, region, keyword string) ([]dto.HouseTransactionVO, int, error) {
m := dao.HouseTransaction.Ctx(ctx).As("t").
- LeftJoin("house_community c", "t.community_id=c.id").
- Fields("t.*, c.name AS community_name")
+ LeftJoin("house_community c", "t.community_id=c.id")
if region != "" {
m = m.Where("c.region", region)
}
@@ -48,7 +47,7 @@ func (s *transaction) List(ctx context.Context, page, size int, region, keyword
return nil, 0, gerror.Wrap(err, "count transaction")
}
var list []dto.HouseTransactionVO
- if err = m.Clone().Page(page, size).OrderDesc("t.deal_date").Scan(&list); err != nil {
+ if err = m.Clone().Fields("t.*, c.name AS community_name").Page(page, size).OrderDesc("t.deal_date").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query transaction list")
}
return list, total, nil
diff --git a/internal/service/recruitment/bark.go b/internal/service/recruitment/bark.go
new file mode 100644
index 0000000..e99e06a
--- /dev/null
+++ b/internal/service/recruitment/bark.go
@@ -0,0 +1,176 @@
+package recruitment
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ "service.xpcool.com/internal/dao"
+ "service.xpcool.com/internal/model/do"
+ "service.xpcool.com/internal/model/dto"
+ "service.xpcool.com/internal/model/entity"
+)
+
+// barkPayload 对应自建 Bark 服务的 /push 接口入参。
+type barkPayload struct {
+ DeviceKey string `json:"device_key"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Group string `json:"group"` // 通知分组
+ Level string `json:"level"` // 优先级
+ URL string `json:"url"` // 点击跳转
+}
+
+// defaultDeviceKey 读取环境变量注入的兜底设备密钥(BARK_DEVICE_KEY -> bark.deviceKey)。
+// 当数据库没有任何订阅记录时,推送与测试推送回退到该密钥,做到「填了 env 即可推送」。
+func defaultDeviceKey(ctx context.Context) string {
+ return g.Cfg().MustGet(ctx, "bark.deviceKey", "").String()
+}
+
+// defaultSubscription 构造一个使用兜底密钥的默认订阅(全地区/全分类/仅新公告)。
+func defaultSubscription(ctx context.Context) *entity.PushSubscription {
+ key := defaultDeviceKey(ctx)
+ if key == "" {
+ return nil
+ }
+ return &entity.PushSubscription{
+ Id: 0, Name: "默认(Bark环境变量)", DeviceKey: key,
+ Regions: "[]", Categories: "[]", OnlyNew: 1, PushTime: "08:00", Enabled: 1,
+ }
+}
+
+// barkPush 向指定设备密钥发送 Bark 推送(自建服务:POST {baseUrl}/push)。
+func barkPush(ctx context.Context, deviceKey, title, body string) (bool, string, error) {
+ baseURL := g.Cfg().MustGet(ctx, "bark.baseUrl", "").String()
+ if baseURL == "" {
+ return false, "未配置 bark.baseUrl", fmt.Errorf("bark baseUrl empty")
+ }
+ if deviceKey == "" {
+ // 未显式传入则回退到环境变量兜底密钥。
+ deviceKey = defaultDeviceKey(ctx)
+ }
+ if deviceKey == "" {
+ return false, "设备密钥为空", fmt.Errorf("device key empty")
+ }
+ payload := barkPayload{
+ DeviceKey: deviceKey,
+ Title: title,
+ Body: body,
+ Group: "recruit_daily",
+ Level: "active",
+ }
+ client := g.Client()
+ client.SetTimeout(10 * time.Second)
+ resp, err := client.Post(ctx, strings.TrimRight(baseURL, "/")+"/push", payload)
+ if err != nil {
+ return false, err.Error(), err
+ }
+ defer resp.Close()
+ var out struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal(resp.ReadAll(), &out)
+ ok := resp.StatusCode == 200 && out.Code == 200
+ msg := out.Message
+ if msg == "" {
+ msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
+ }
+ return ok, msg, nil
+}
+
+// sendDailyDigest 对所有启用订阅发送「招聘早报」汇总(自建 Bark)。
+// 若库内无任何订阅,则回退到环境变量兜底密钥(BARK_DEVICE_KEY)发一份全量早报。
+func sendDailyDigest(ctx context.Context) {
+ var subs []entity.PushSubscription
+ if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err != nil {
+ g.Log().Errorf(ctx, "load subscriptions failed: %v", err)
+ return
+ }
+ if len(subs) == 0 {
+ def := defaultSubscription(ctx)
+ if def == nil {
+ g.Log().Infof(ctx, "no subscription and no default bark device key, skip digest")
+ return
+ }
+ subs = []entity.PushSubscription{*def}
+ }
+ for _, sub := range subs {
+ title, body := buildDigest(ctx, sub)
+ if title == "" && body == "" {
+ // 仅推送新公告且今日无新:跳过,避免打扰。
+ continue
+ }
+ ok, msg, _ := barkPush(ctx, sub.DeviceKey, title, body)
+ recordPushLog(ctx, sub.Id, title, body, ok, msg)
+ }
+}
+
+// buildDigest 按订阅过滤条件生成早报内容;仅推送新公告且无新时返回空串。
+func buildDigest(ctx context.Context, sub entity.PushSubscription) (string, string) {
+ regions := parseJSONStrings(sub.Regions)
+ cats := parseJSONInts(sub.Categories)
+ since := gtime.Now().StartOfDay().String()
+ m := dao.RecruitmentInfo.Ctx(ctx).Where("status", g.Slice{0, 1}).Where("created_at >= ?", since)
+ if len(regions) > 0 {
+ m = m.Where("region IN (?)", regions)
+ }
+ if len(cats) > 0 {
+ m = m.Where("category IN (?)", cats)
+ }
+ var list []dto.RecruitmentInfoVO
+ if err := m.Fields("title, category, region, deadline").OrderDesc("publish_date").Scan(&list); err != nil {
+ g.Log().Errorf(ctx, "build digest query failed: %v", err)
+ return "", ""
+ }
+ if sub.OnlyNew == 1 && len(list) == 0 {
+ return "", "" // 无新公告则跳过
+ }
+ counts := map[int]int{}
+ for _, v := range list {
+ counts[v.Category]++
+ }
+ var sb strings.Builder
+ sb.WriteString("今日新增:")
+ parts := make([]string, 0, len(counts))
+ for c, n := range counts {
+ parts = append(parts, dto.CategoryName(c)+" "+itoa(n))
+ }
+ sb.WriteString(strings.Join(parts, " · "))
+ sb.WriteString("\n")
+ limit := 8
+ for i, v := range list {
+ if i >= limit {
+ break
+ }
+ line := "· " + v.Title
+ if v.Deadline != "" {
+ line += "(截止 " + v.Deadline + ")"
+ }
+ sb.WriteString(line + "\n")
+ }
+ title := "贵州招聘早报 · " + gtime.Now().Format("Y-m-d")
+ if len(regions) == 1 {
+ title += " · " + regions[0]
+ }
+ return title, strings.TrimRight(sb.String(), "\n")
+}
+
+// recordPushLog 写推送记录。
+func recordPushLog(ctx context.Context, subId uint64, title, body string, ok bool, msg string) {
+ res := 0
+ if ok {
+ res = 1
+ }
+ if _, err := dao.PushLog.Ctx(ctx).Data(do.PushLog{
+ SubscriptionId: subId, PushAt: gtime.Now(), Title: title, Body: body,
+ Result: res, Error: msg, CreatedAt: gtime.Now(),
+ }).Insert(); err != nil {
+ g.Log().Errorf(ctx, "write push log failed: %v", err)
+ }
+}
diff --git a/internal/service/recruitment/crawler.go b/internal/service/recruitment/crawler.go
new file mode 100644
index 0000000..b77d38e
--- /dev/null
+++ b/internal/service/recruitment/crawler.go
@@ -0,0 +1,596 @@
+package recruitment
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ "service.xpcool.com/internal/dao"
+ "service.xpcool.com/internal/model/do"
+ "service.xpcool.com/internal/model/dto"
+ "service.xpcool.com/internal/model/entity"
+)
+
+// crawlerRun 按数据源类型分发抓取,返回运行结果(供写日志/统计)。
+func crawlerRun(ctx context.Context, src entity.CrawlSource, force bool) dto.CrawlRunResult {
+ res := dto.CrawlRunResult{SourceId: src.Id}
+ defer func() {
+ // 防止单源解析panic拖垮整体调度。
+ if r := recover(); r != nil {
+ res.Err = gerror.Newf("crawler panic: %v", r)
+ }
+ }()
+ var (
+ infos []dto.RecruitmentInput
+ err error
+ )
+ switch src.SourceType {
+ case 1: // 静态列表页
+ infos, err = genericStaticCrawl(ctx, src, force)
+ case 2: // SPA / JS 渲染(需底层 JSON 接口,首批 POC)
+ infos, err = spaCrawl(ctx, src, force)
+ case 3: // 需登录/验证码,首批已排除
+ err = gerror.New("该源需登录/验证码,首批已排除")
+ case 4: // 附件型,先按静态抓取,附件解析为后续增强
+ infos, err = genericStaticCrawl(ctx, src, force)
+ default:
+ err = gerror.Newf("未知源类型 %d", src.SourceType)
+ }
+ res.Err = err
+ res.Fetched = len(infos)
+ if err != nil {
+ return res
+ }
+ newCount, updated := 0, 0
+ for i := range infos {
+ isNew, e := upsertInfo(ctx, infos[i])
+ if e != nil {
+ res.Err = e
+ continue
+ }
+ if isNew {
+ newCount++
+ } else {
+ updated++
+ }
+ }
+ res.NewCount = newCount
+ res.UpdatedCount = updated
+ return res
+}
+
+// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
+func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
+ listURL := joinURL(src.BaseUrl, src.ListPath)
+ if listURL == "" {
+ listURL = src.BaseUrl
+ }
+ html, err := fetchHTML(ctx, listURL)
+ if err != nil {
+ return nil, err
+ }
+ g.Log().Infof(ctx, "[recruit-debug] src=%d listURL=%s htmlLen=%d", src.Id, listURL, len(html))
+ raw := extractAnchors(html, listURL)
+ anchors := filterArticleAnchors(raw)
+ g.Log().Infof(ctx, "[recruit-debug] src=%d rawAnchors=%d filtered=%d", src.Id, len(raw), len(anchors))
+ var out []dto.RecruitmentInput
+ limit := 60
+ if force {
+ limit = 300 // 全量回溯放宽上限
+ }
+ for _, a := range anchors {
+ if len(out) >= limit {
+ break
+ }
+ dHtml, e := fetchHTML(ctx, a.URL)
+ if e != nil {
+ continue
+ }
+ title, content, pub, dl, ex := extractDetail(dHtml)
+ if title == "" {
+ title = a.Text
+ }
+ pubDate := normalizeDate(pub)
+ // 增量模式:跳过 30 天前的公告,避免无效回扫。
+ if !force && pubDate != "" {
+ if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
+ if time.Since(t) > 30*24*time.Hour {
+ continue
+ }
+ }
+ }
+ out = append(out, dto.RecruitmentInput{
+ Title: title,
+ SourceId: src.Id,
+ OrgName: src.Name,
+ Category: src.Category,
+ Region: src.Region,
+ PublishDate: pubDate,
+ Deadline: normalizeDate(dl),
+ ExamDate: normalizeDate(ex),
+ Url: a.URL,
+ Content: content,
+ Status: 0,
+ })
+ }
+ return out, nil
+}
+
+// spaCrawl SPA 源抓取(最佳实践:逆向底层 JSON 接口)。当前为占位实现,
+// 若服务端渲染无锚点则返回明确错误,便于在阶段0 POC 中补齐接口。
+func spaCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
+ listURL := joinURL(src.BaseUrl, src.ListPath)
+ if listURL == "" {
+ listURL = src.BaseUrl
+ }
+ html, err := fetchHTML(ctx, listURL)
+ if err != nil {
+ return nil, err
+ }
+ // 部分 SPA 会在 HTML 内联 __NEXT_DATA__ / 初始 state,可从中抽取。
+ if data := extractFromInlineJSON(html); len(data) > 0 {
+ return data, nil
+ }
+ // 纯客户端渲染(hash 路由)无服务端内容,需 POC 接入接口。
+ if len(extractAnchors(html, listURL)) == 0 {
+ return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口(阶段0 POC)")
+ }
+ return genericStaticCrawl(ctx, src, force)
+}
+
+// extractFromInlineJSON 从 SPA 内联 JSON(__NEXT_DATA__ / window.__INITIAL_STATE__)抽取标题与链接。
+// 适配 Vue/Next 等 SSR/CSR 混合页面,作为 SPA 源的兜底解析。
+func extractFromInlineJSON(html string) []dto.RecruitmentInput {
+ var out []dto.RecruitmentInput
+ // 匹配常见内联 JSON 块,逐块扫描其中的标题/链接字段。
+ blocks := inlineJSONRe.FindAllStringSubmatch(html, -1)
+ for _, b := range blocks {
+ raw := b[1]
+ var generic map[string]any
+ if err := json.Unmarshal([]byte(raw), &generic); err != nil {
+ continue
+ }
+ walkJSON(generic, &out)
+ }
+ return out
+}
+
+// walkJSON 递归遍历内联 JSON,提取含标题与链接的条目(最佳实践,避免硬规则)。
+func walkJSON(node any, out *[]dto.RecruitmentInput) {
+ switch v := node.(type) {
+ case map[string]any:
+ title, _ := v["title"].(string)
+ link, _ := v["url"].(string)
+ if link == "" {
+ link, _ = v["href"].(string)
+ }
+ if title != "" && link != "" {
+ *out = append(*out, dto.RecruitmentInput{
+ Title: title, Url: link, Status: 0,
+ })
+ }
+ for _, val := range v {
+ walkJSON(val, out)
+ }
+ case []any:
+ for _, item := range v {
+ walkJSON(item, out)
+ }
+ }
+}
+
+// fetchHTML 带超时与浏览器 UA 的请求,降低被反爬概率。
+func fetchHTML(ctx context.Context, u string) (string, error) {
+ client := g.Client()
+ client.SetTimeout(15 * time.Second)
+ client.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
+ client.SetHeader("Accept", "text/html,application/xhtml+xml,*/*")
+ resp, err := client.Get(ctx, u)
+ if err != nil {
+ return "", gerror.Wrapf(err, "fetch %s", u)
+ }
+ defer resp.Close()
+ if resp.StatusCode != 200 {
+ return "", gerror.Newf("HTTP %d for %s", resp.StatusCode, u)
+ }
+ return resp.ReadAllString(), nil
+}
+
+type anchor struct {
+ URL string
+ Text string
+}
+
+var (
+ // anchorBlockRe 匹配完整 ... 块;hrefRe/titleAttrRe 从块内分别抽取链接与 title 属性。
+ anchorBlockRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`)
+ hrefRe = regexp.MustCompile(`(?i)\bhref\s*=\s*["']([^"']+)["']`)
+ titleAttrRe = regexp.MustCompile(`(?i)\btitle\s*=\s*["']([^"']+)["']`)
+ tagRe = regexp.MustCompile(`<[^>]+>`)
+ scriptRe = regexp.MustCompile(`(?is)