图片恢复支持对象存储(R2);专题也改成归人不归数据集
R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
这比「没恢复」更难懂。
专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。
导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
This commit is contained in:
@@ -44,15 +44,19 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
// ---------- 查询 ----------
|
||||
|
||||
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
||||
func (s *Server) loadTopics(ctx context.Context, datasetID int64, onlyEnabled bool) ([]Topic, error) {
|
||||
//
|
||||
// 按**属主**取而不是按数据集:专题是「一段时间 + 一块区域」的定义,本不该随数据集生灭。
|
||||
// 早期按 dataset_id 过滤 ⇒ 删掉旧数据集时专题被级联删掉(实测丢过一批,且没有恢复来源)。
|
||||
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
||||
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
|
||||
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
||||
radius_km, show_fog, enabled, sort_order
|
||||
FROM topics WHERE dataset_id=$1`
|
||||
FROM topics WHERE user_id=$1`
|
||||
if onlyEnabled {
|
||||
q += ` AND enabled`
|
||||
}
|
||||
q += ` ORDER BY sort_order, id`
|
||||
rows, err := s.db.QueryContext(ctx, q, datasetID)
|
||||
rows, err := s.db.QueryContext(ctx, q, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -282,28 +286,31 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
p.Flatpickr = true
|
||||
d := TopicAdminData{Page: p}
|
||||
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||||
u := userFrom(ctx)
|
||||
// 专题归**人**不归数据集,所以没有生效数据集时也能看、也能改(只是统计不出来)。
|
||||
// 早期版本这里要 ds != nil 才加载,删数据集期间专题就整个不见了。
|
||||
list, err := s.loadTopics(ctx, u.ID, false)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
ds, err := s.datasetFor(ctx)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if ds != nil {
|
||||
list, err := s.loadTopics(ctx, ds.ID, false)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// 后台管理页:站长看全部
|
||||
// 后台管理页:站长看全部;统计照当前生效数据集算
|
||||
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
d.Topics = list
|
||||
// 编辑模式:?edit=<id>
|
||||
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
|
||||
for _, t := range list {
|
||||
if t.ID == int64(id) {
|
||||
d.Form = t
|
||||
}
|
||||
}
|
||||
d.Topics = list
|
||||
// 编辑模式:?edit=<id>
|
||||
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
|
||||
for _, t := range list {
|
||||
if t.ID == int64(id) {
|
||||
d.Form = t
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,8 +347,8 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
||||
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
||||
show_fog=$11, enabled=$12, sort_order=$13
|
||||
WHERE id=$1 AND dataset_id=$2`,
|
||||
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
WHERE id=$1 AND user_id=$2`,
|
||||
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
@@ -350,6 +357,8 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var newID int64
|
||||
// dataset_id 照写不动,但只当**出处**记:这一份专题是在哪份数据上建的。
|
||||
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
||||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
||||
@@ -363,27 +372,23 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND dataset_id=$2`, id, dsID)
|
||||
s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return err
|
||||
}, "专题已删除")
|
||||
}
|
||||
|
||||
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
|
||||
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND dataset_id=$2`, id, dsID)
|
||||
s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return err
|
||||
}, "展示状态已切换")
|
||||
}
|
||||
|
||||
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
|
||||
// 按属主收口(专题是用户级的),**不要求有生效数据集**——没有数据时照样该能整理专题。
|
||||
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
|
||||
ctx := r.Context()
|
||||
ds, err := s.datasetFor(ctx)
|
||||
if err != nil || ds == nil {
|
||||
redirectTopics(w, r, "err", "还没有可编辑的数据集")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
@@ -393,7 +398,7 @@ func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(con
|
||||
redirectTopics(w, r, "err", "专题标识无效")
|
||||
return
|
||||
}
|
||||
if err := fn(ctx, ds.ID, id); err != nil {
|
||||
if err := fn(ctx, userFrom(ctx).ID, id); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -411,7 +416,7 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d := TopicListData{Page: p}
|
||||
if p.Dataset != nil {
|
||||
list, err := s.loadTopics(ctx, p.Dataset.ID, !p.IsAdmin())
|
||||
list, err := s.loadTopics(ctx, p.Dataset.UserID, !p.IsAdmin())
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
@@ -441,7 +446,7 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
var t Topic
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
|
||||
FROM topics WHERE id=$1 AND dataset_id=$2`, id, p.Dataset.ID).
|
||||
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
|
||||
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
Reference in New Issue
Block a user