diff --git a/alembic/versions/0073_event_signup_upgrade.py b/alembic/versions/0073_event_signup_upgrade.py new file mode 100644 index 0000000..20879ed --- /dev/null +++ b/alembic/versions/0073_event_signup_upgrade.py @@ -0,0 +1,52 @@ +"""活动报名体系升级:自定义表单 / 报名截止 / 活动管理员 / 隐藏信息 / 审核与提醒短信 + +events 表新增: + - form_fields_json 报名表单字段定义(JSON 数组,空数组=旧行为) + - activity_admins_json 活动管理员列表 [{phone,name,added_at}](含共同审核权限) + - secret_json 审核通过后可见信息 {text,image,url,urlLabel} + - remind_before_min 活动前提醒提前分钟数(默认 1440=24h;0=关闭) + - signup_deadline 报名截止时间(ISO;空=不限制,默认截止=开始时间) + +bookings 表新增: + - form_data_json 报名自定义字段值 {f_id: value} + - audit_by / audit_at / audit_comment 审核记录 + - audit_sms_at 审核短信已发送时间(幂等) + - reminded_at 活动提醒短信已发送时间(幂等) + +存量回填:全部为默认值,旧数据零迁移成本;只加列不删列。 +Revision ID: 0073_event_signup_upgrade +Revises: 0072_events_add_created_at +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0073_event_signup_upgrade" +down_revision = "0072_events_add_created_at" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # events:5 列(MySQL 不允许 TEXT 带 server_default → TEXT 可空,代码层兜底默认值) + op.add_column("events", sa.Column("form_fields_json", sa.Text(), nullable=True)) + op.add_column("events", sa.Column("activity_admins_json", sa.Text(), nullable=True)) + op.add_column("events", sa.Column("secret_json", sa.Text(), nullable=True)) + op.add_column("events", sa.Column("signup_deadline", sa.String(length=64), nullable=True)) + op.add_column("events", sa.Column("remind_before_min", sa.Integer(), nullable=False, server_default="1440")) + + # bookings:6 列(TEXT 可空,代码层兜底) + op.add_column("bookings", sa.Column("form_data_json", sa.Text(), nullable=True)) + op.add_column("bookings", sa.Column("audit_by", sa.String(length=128), nullable=True)) + op.add_column("bookings", sa.Column("audit_at", sa.String(length=64), nullable=True)) + op.add_column("bookings", sa.Column("audit_comment", sa.String(length=500), nullable=True)) + op.add_column("bookings", sa.Column("audit_sms_at", sa.String(length=64), nullable=True)) + op.add_column("bookings", sa.Column("reminded_at", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + for col in ("reminded_at", "audit_sms_at", "audit_comment", "audit_at", "audit_by", "form_data_json"): + op.drop_column("bookings", col) + for col in ("remind_before_min", "signup_deadline", "secret_json", "activity_admins_json", "form_fields_json"): + op.drop_column("events", col) diff --git a/app/api/routers/rbac_ecosystem.py b/app/api/routers/rbac_ecosystem.py index df7a1fa..338f314 100644 --- a/app/api/routers/rbac_ecosystem.py +++ b/app/api/routers/rbac_ecosystem.py @@ -21,6 +21,47 @@ router = APIRouter(tags=["ecosystem"]) _ANY = ("opc_member", "carrier", "operator") +def _normalize_activity_ext(payload: dict) -> dict: + """运营方/成员端发布活动:校验并规范化报名体系升级字段(JSON 列)。""" + from ..training import main as training_main + if payload.get("form_fields_json") is not None: + try: + raw = json.loads(payload["form_fields_json"]) if isinstance(payload["form_fields_json"], str) else payload["form_fields_json"] + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="form_fields_json 不是合法 JSON") + fields, err = training_main.normalize_form_fields(raw) + if err: + raise HTTPException(status_code=400, detail=err) + payload["form_fields_json"] = json.dumps(fields, ensure_ascii=False) + if payload.get("activity_admins_json") is not None: + try: + raw = json.loads(payload["activity_admins_json"]) if isinstance(payload["activity_admins_json"], str) else payload["activity_admins_json"] + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="activity_admins_json 不是合法 JSON") + admins, err = training_main.normalize_activity_admins(raw) + if err: + raise HTTPException(status_code=400, detail=err) + payload["activity_admins_json"] = json.dumps(admins, ensure_ascii=False) + if payload.get("secret_json") is not None: + try: + raw = json.loads(payload["secret_json"]) if isinstance(payload["secret_json"], str) else payload["secret_json"] + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="secret_json 不是合法 JSON") + payload["secret_json"] = json.dumps(training_main.normalize_secret(raw), ensure_ascii=False) + if payload.get("remind_before_min") is not None: + remind = int(payload["remind_before_min"] or 0) + if remind < 0 or remind > 10080: + raise HTTPException(status_code=400, detail="提醒提前量须在 0~10080 分钟之间") + payload["remind_before_min"] = remind + if payload.get("signup_deadline") is not None: + dl = str(payload["signup_deadline"] or "").strip() + if dl: + from ..training.main import _parse_ms + if _parse_ms(dl) <= 0: + raise HTTPException(status_code=400, detail="报名截止时间格式不正确") + return payload + + # ── 通知中心(实时通知体系)────────────────────────────────────────────── @router.get("/notifications", summary="我的通知(分页/分类/已读筛选)") async def list_notifications( @@ -280,6 +321,7 @@ class BookingCreateRequest(BaseModel): name: str = "" contact: str = "" want: str = "" + form_data: dict | None = None # 自定义报名表单值 {f_id: value} @router.get("/activities", summary="活动列表(OPC 用户)") @@ -315,7 +357,7 @@ async def create_activity_public( db: Database = Depends(get_db), actor: dict = Depends(require_roles(*_ANY)), ): - payload = req.model_dump(exclude_none=True) + payload = _normalize_activity_ext(req.model_dump(exclude_none=True)) payload["publisher_id"] = actor["id"] payload["publisher_name"] = actor.get("username") or actor.get("nickname") or "" payload["review_status"] = "approved" # 演示环境直接通过;如需审核可改为 pending @@ -336,6 +378,9 @@ async def register_activity_public( booking = training.register_event(event_id, actor["id"], actor.get("username") or "", req.model_dump(exclude_none=True)) if booking is None: raise HTTPException(status_code=404, detail="活动不存在") + if booking.get("error"): + err = booking["error"] + raise HTTPException(status_code=400, detail=err) await write_audit(db, action="booking.create", resource="booking", resource_id=booking["booking"]["id"], detail=event_id, user=actor, request=request) return booking @@ -349,9 +394,8 @@ async def update_activity_owned( db: Database = Depends(get_db), actor: dict = Depends(require_roles(*_ANY)), ): - activity = training.update_activity_owned( - event_id, req.model_dump(exclude_unset=True, exclude_none=True), actor["id"], - ) + payload = _normalize_activity_ext(req.model_dump(exclude_unset=True, exclude_none=True)) + activity = training.update_activity_owned(event_id, payload, actor["id"]) if activity is None: raise HTTPException(status_code=404, detail="活动不存在或无权修改") await write_audit(db, action="activity.update", resource="activity", resource_id=event_id, diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index 37dd044..1436ee9 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -1108,6 +1108,19 @@ async def update_booking( db: Database = Depends(get_db), actor: dict = Depends(require_permission("action:booking.manage")), ): + # 审核动作:走训练域统一审核入口(状态联动 + 审核记录 + 短信通知,幂等) + if req.audit_status in ("approved", "rejected"): + from ..training import main as training_main + payload = {"user_id": actor.get("id", ""), "username": actor.get("username", ""), + "sub": actor.get("id", "")} + bk = await training_main._apply_booking_audit( + booking_id, req.audit_status, req.audit_comment or req.note or "", payload, dict(actor)) + if bk is None: + raise HTTPException(status_code=404, detail="Booking not found") + await write_audit(db, action="booking.review", resource="booking", resource_id=booking_id, + detail=f"decision={req.audit_status} comment={req.audit_comment or ''}", + user=actor, request=request) + return training.pack_booking(bk) booking = training.update_booking(booking_id, req.model_dump(exclude_none=True)) if booking is None: raise HTTPException(status_code=404, detail="Booking not found") diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 9e569be..ce38a71 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -243,6 +243,12 @@ class ActivityCreateRequest(BaseModel): notice: str | None = None # 活动注意事项 external_url: str | None = None signup_mode: str | None = None # mini|external + # ── 报名体系升级 ── + form_fields_json: str | None = None # 报名表单字段定义(JSON 数组) + activity_admins_json: str | None = None # 活动管理员(JSON 数组,手机号绑定) + secret_json: str | None = None # 审核通过后可见信息(JSON 对象) + signup_deadline: str | None = None # 报名截止(ISO;空=不限制) + remind_before_min: int | None = None # 活动前提醒提前分钟数(0=关闭) class ActivityStatusRequest(BaseModel): @@ -265,12 +271,22 @@ class ActivityUpdateRequest(BaseModel): duration_min: int | None = None capacity: int | None = None price_fen: int | None = None # 定价(分),0=免费 + # ── 报名体系升级 ── + form_fields_json: str | None = None + activity_admins_json: str | None = None + secret_json: str | None = None + signup_deadline: str | None = None + remind_before_min: int | None = None class BookingUpdateRequest(BaseModel): status: str | None = None audit_status: str | None = None note: str | None = None + # ── 报名体系升级 ── + audit_comment: str | None = None # 审核备注(与小程序 /review 口径一致) + contact: str | None = None + name: str | None = None class TestCreateRequest(BaseModel): diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index fe8745d..a3a924c 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -977,6 +977,12 @@ class Event(Base): publisher_id: Mapped[str | None] = mapped_column(String, default="") # 发布者 publisher_name: Mapped[str | None] = mapped_column(String, default="") created_at: Mapped[str | None] = mapped_column(String, default="") # 发布/创建时间(按此排序) + # ── 报名体系升级 ── + form_fields_json: Mapped[str | None] = mapped_column(Text, default="[]") # 报名表单字段定义(JSON 数组) + activity_admins_json: Mapped[str | None] = mapped_column(Text, default="[]") # 活动管理员(JSON 数组,手机号绑定) + secret_json: Mapped[str | None] = mapped_column(Text, default="{}") # 审核通过后可见信息(JSON 对象) + signup_deadline: Mapped[str | None] = mapped_column(String, default="") # 报名截止(ISO;空=不限制) + remind_before_min: Mapped[int | None] = mapped_column(Integer, default=1440) # 活动前提醒提前分钟数(0=关闭) class Booking(Base): @@ -1002,6 +1008,13 @@ class Booking(Base): appeal_status: Mapped[str | None] = mapped_column(String, default="") # ''|pending|approved|rejected(爽约申诉) appeal_reason: Mapped[str | None] = mapped_column(Text, default="") appeal_at: Mapped[str | None] = mapped_column(String, default="") + # ── 报名体系升级 ── + form_data_json: Mapped[str | None] = mapped_column(Text, default="{}") # 报名自定义表单值 {f_id: value} + audit_by: Mapped[str | None] = mapped_column(String, default="") # 审核人(nickname/username) + audit_at: Mapped[str | None] = mapped_column(String, default="") # 审核时间 + audit_comment: Mapped[str | None] = mapped_column(String, default="") # 审核备注/驳回原因 + audit_sms_at: Mapped[str | None] = mapped_column(String, default="") # 审核短信发送时间(幂等) + reminded_at: Mapped[str | None] = mapped_column(String, default="") # 活动前提醒短信发送时间(幂等) class Test(Base): diff --git a/app/infrastructure/seed.py b/app/infrastructure/seed.py index b7546b9..5d7bafc 100644 --- a/app/infrastructure/seed.py +++ b/app/infrastructure/seed.py @@ -253,6 +253,10 @@ _DEFAULT_SYSTEM_CONFIGS: list[tuple[str, str, str]] = [ "订单状态变动模板(PineSound 无匹配模板,暂留空;变量:order_no 订单号, status 状态)"), ("sms.template.appointment", '{"template_id":"","variables":["service_name","schedule_at","contact"]}', "服务预约模板(PineSound 无匹配模板,暂留空;变量:service_name 服务名, schedule_at 预约时间, contact 联系方式)"), + ("sms.template.booking_audit", '{"template_id":"","variables":["event_title","status","reason","time","location"]}', + "报名审核结果通知模板(暂留空;变量:event_title 活动名, status 通过/未通过, reason 原因, time 时间, location 地点)"), + ("sms.template.event_reminder", '{"template_id":"","variables":["event_title","start_at","location"]}', + "活动开始提醒模板(暂留空;变量:event_title 活动名, start_at 开始时间, location 地点)"), ] diff --git a/app/services/sms.py b/app/services/sms.py index 377cf71..9952d29 100644 --- a/app/services/sms.py +++ b/app/services/sms.py @@ -53,13 +53,16 @@ _CODE_TTL = config.SMS_CODE_TTL_SECONDS _MAX_ATTEMPTS = config.SMS_RATE_LIMIT # 短信生态支持的全部场景(admin 端按此枚举展示/维护模板)。 -SMS_SCENES = ("login", "register", "notification", "order_status", "appointment") +SMS_SCENES = ("login", "register", "notification", "order_status", "appointment", + "booking_audit", "event_reminder") SMS_SCENE_LABELS = { "login": "登录验证码", "register": "注册验证码", "notification": "通知提醒", "order_status": "订单状态变动", "appointment": "服务预约", + "booking_audit": "报名审核结果通知", + "event_reminder": "活动开始提醒", } diff --git a/app/services/training_admin_bridge.py b/app/services/training_admin_bridge.py index d0af3c2..25cd278 100644 --- a/app/services/training_admin_bridge.py +++ b/app/services/training_admin_bridge.py @@ -102,7 +102,11 @@ def list_activities_public(user_id: str | None = None, status: str | None = None def register_event(event_id: str, user_id: str, username: str, data: dict) -> dict | None: - """报名活动:写入 bookings(按 username+event_id 幂等)。""" + """报名活动:写入 bookings(按 username+event_id 幂等)。 + + 支持自定义表单值(form_data):复用训练域校验引擎逐字段校验, + 校验失败返回 {"error": {...}}(调用方转 HTTP 400)。 + """ ev = tdb.fetch_by_id("events", event_id) if ev is None: return None @@ -112,6 +116,17 @@ def register_event(event_id: str, user_id: str, username: str, data: dict) -> di ] if existing: return {"already": True, "booking": existing[0]} + # 自定义表单值:懒导入校验引擎(避免启动耦合) + form_data_json = "{}" + if data.get("form_data") is not None: + from ..training.main import _load_json, normalize_form_data + form_fields = _load_json(ev.get("form_fields_json") or "", []) + form_fields = form_fields if isinstance(form_fields, list) else [] + form_values, form_errors = normalize_form_data(form_fields, data.get("form_data")) + if form_errors: + fkey = next(iter(form_errors)) + return {"error": {"code": "FIELD_INVALID", "field": fkey, "message": form_errors[fkey]}} + form_data_json = json.dumps(form_values, ensure_ascii=False) row = { "id": tdb.gen_id("B-"), "created_at": _now(), @@ -124,6 +139,7 @@ def register_event(event_id: str, user_id: str, username: str, data: dict) -> di "event_id": event_id, "event_title": ev.get("title", ""), "event_start": ev.get("start_at", ""), + "form_data_json": form_data_json, } tdb.insert("bookings", row) return {"already": False, "booking": row} @@ -258,13 +274,19 @@ def pack_booking(r: dict) -> dict: "pay_status": r.get("pay_status") or "", "note": r.get("question"), "created_at": r.get("created_at"), + # ── 报名体系升级 ── + "form_data": _parse_json(r.get("form_data_json") or "{}") or {}, + "audit_by": r.get("audit_by") or "", + "audit_at": r.get("audit_at") or "", + "audit_comment": r.get("audit_comment") or "", + "contact": r.get("contact") or "", } def update_booking(booking_id: str, patch: dict) -> dict | None: if tdb.fetch_by_id("bookings", booking_id) is None: return None - allowed = {k: v for k, v in patch.items() if k in ("status", "audit_status", "note", "question")} + allowed = {k: v for k, v in patch.items() if k in ("status", "audit_status", "note", "question", "contact", "name")} if "note" in allowed: allowed["question"] = allowed.pop("note") # 状态联动:审核结果变化时同步生命周期状态,保证「状态」与「审核」两列一致 @@ -273,6 +295,9 @@ def update_booking(booking_id: str, patch: dict) -> dict | None: allowed["status"] = "confirmed" # 审核通过 → 已确认(待参加) elif allowed["audit_status"] == "rejected": allowed["status"] = "rejected" # 驳回 → 已驳回(终态,不计入有效报名) + # 审核备注:统一写入 audit_comment(与小程序 /review 口径一致) + if "audit_comment" in patch: + allowed["audit_comment"] = str(patch["audit_comment"] or "")[:500] if allowed: tdb.update_row("bookings", booking_id, allowed) return pack_booking(tdb.fetch_by_id("bookings", booking_id)) diff --git a/app/training/main.py b/app/training/main.py index bcb2098..28d178a 100644 --- a/app/training/main.py +++ b/app/training/main.py @@ -7,6 +7,7 @@ import asyncio import json from datetime import datetime, timedelta import os +import re import secrets import time from pathlib import Path @@ -568,7 +569,351 @@ def _parse_price_fen(b: dict): return None -def event_out(e, detail: bool = False): +# ================= 报名体系升级:自定义表单 / 截止 / 管理员 / 隐藏信息 / 短信 ================= + +FORM_FIELD_TYPES = ("text", "textarea", "number", "phone", "select", "radio", "checkbox", "date") +FORM_FIELDS_MAX = 20 +FORM_DATA_MAX_BYTES = 65536 +ADMIN_PHONES_MAX = 5 +EVENT_REMINDER_TOKEN = os.environ.get("EVENT_REMINDER_TOKEN", "pine-event-reminder-2026") + + +def _load_json(raw, default): + """安全 JSON 解析(Text 列统一存储 JSON 字符串)。""" + try: + v = json.loads(raw or "") + return v + except (TypeError, ValueError): + return default + + +def normalize_form_fields(raw) -> tuple[list, str]: + """校验并规范化报名表单字段定义(events.form_fields_json)。 + + 支持 8 种类型;必填/选填/hidden 标记;select/radio/checkbox 需 1~20 个选项; + 最多 20 个字段,按 sort 排序返回。返回 (fields, error),error 为空表示通过。 + """ + if not raw: + return [], "" + if not isinstance(raw, list) or len(raw) > FORM_FIELDS_MAX: + return [], f"报名表单字段最多 {FORM_FIELDS_MAX} 个" + seen = set() + out = [] + for i, f in enumerate(raw): + if not isinstance(f, dict): + return [], f"字段 {i + 1} 格式不合法" + fid = str(f.get("id") or "").strip() or f"f_{i + 1}" + if fid in seen: + return [], f"字段 ID 重复:{fid}" + seen.add(fid) + ftype = f.get("type") + if ftype not in FORM_FIELD_TYPES: + return [], f"字段 {fid} 类型不支持:{ftype}" + label = str(f.get("label") or "").strip() + if not label or len(label) > 50: + return [], f"字段 {fid} 缺少标题或标题过长(≤50 字)" + opts = f.get("options") + if ftype in ("select", "radio", "checkbox"): + if not isinstance(opts, list) or not opts or len(opts) > 20 or any(not str(o).strip() for o in opts): + return [], f"字段 {fid} 的选项须为 1~20 个非空字符串" + opts = [str(o).strip() for o in opts] + else: + opts = [] + out.append({ + "id": fid, + "type": ftype, + "label": label, + "required": bool(f.get("required")), + "hidden": bool(f.get("hidden")), + "placeholder": str(f.get("placeholder") or "")[:100], + "options": opts, + "sort": int(f.get("sort") or 0), + }) + out.sort(key=lambda x: x["sort"]) + return out, "" + + +def normalize_form_data(form_fields: list, raw) -> tuple[dict, dict]: + """校验并归一化报名表单值(bookings.form_data_json)。 + + - 值与定义解耦存储:值为 {f_id: value};读取时按定义渲染。 + - 值类型按字段类型强制:text/textarea/date/phone/number/select/radio → string; + checkbox → 去重字符串数组(≤10 项)。 + - 返回 (values, errors);errors 为 {f_id: message},空表示通过。 + """ + if not form_fields: + return {}, {} + raw = raw if isinstance(raw, dict) else {} + values: dict[str, object] = {} + errors: dict[str, str] = {} + for f in form_fields: + fid = f["id"] + val = raw.get(fid, "") + if f["type"] == "checkbox": + v = val if isinstance(val, list) else ([val] if val not in ("", None) else []) + v = [str(x).strip() for x in v][:10] + v = list(dict.fromkeys(v)) + if f["required"] and not v: + errors[fid] = "请选择至少一项" + continue + if any(x not in f["options"] for x in v): + errors[fid] = "选项不合法" + continue + values[fid] = v + continue + s = "" if val is None else str(val).strip() + if f["required"] and not s: + errors[fid] = "此项必填" + continue + if not s: + values[fid] = "" + continue + if f["type"] == "number": + if not re.fullmatch(r"\d{1,12}(\.\d{1,2})?", s): + errors[fid] = "请输入有效数字(最多 12 位整数、2 位小数)" + continue + elif f["type"] == "phone": + if not PHONE_RE.match(s): + errors[fid] = "请输入正确的 11 位手机号" + continue + elif f["type"] == "text": + if len(s) > 200: + errors[fid] = "单行文本最长 200 字" + continue + elif f["type"] == "textarea": + if len(s) > 2000: + errors[fid] = "多行文本最长 2000 字" + continue + elif f["type"] == "date": + try: + datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + errors[fid] = "日期格式不正确" + continue + elif f["type"] in ("select", "radio") and f["options"] and s not in f["options"]: + errors[fid] = "选项不合法" + continue + values[fid] = s + # 总量闸:整体 ≤ 64KB,防滥用 + try: + if len(json.dumps(values, ensure_ascii=False).encode("utf-8")) > FORM_DATA_MAX_BYTES: + return {}, {"__global__": "报名信息过长,请精简后提交"} + except Exception: + pass + return values, errors + + +def normalize_activity_admins(raw) -> tuple[list, str]: + """校验活动管理员(activity_admins_json):手机号绑定 1~5 人,支持 {phone,name} 或裸手机号。""" + if not raw: + return [], "" + if not isinstance(raw, list) or not raw or len(raw) > ADMIN_PHONES_MAX: + return [], f"活动管理员最多 {ADMIN_PHONES_MAX} 人" + out = [] + seen = set() + for a in raw: + phone = str((a or {}).get("phone") if isinstance(a, dict) else a or "").strip() + if not PHONE_RE.match(phone): + return [], f"管理员手机号不合法:{phone or '空'}" + if phone in seen: + return [], f"管理员手机号重复:{phone}" + seen.add(phone) + out.append({ + "phone": phone, + "name": str((a or {}).get("name") if isinstance(a, dict) else "")[:50], + }) + return out, "" + + +def normalize_secret(raw) -> dict: + """校验活动隐藏信息(secret_json):{text, image, url, urlLabel} 均可选。""" + if not isinstance(raw, dict): + return {} + return { + "text": str(raw.get("text") or "")[:2000], + "image": str(raw.get("image") or "").strip(), + "url": str(raw.get("url") or "").strip(), + "urlLabel": str(raw.get("urlLabel") or "")[:50], + } + + +def _signup_state(e) -> str: + """报名状态派生:设置 signup_deadline 且已过 → closed;未设置或未到 → open。""" + dl = e.get("signup_deadline") or "" + if not dl or _parse_ms(dl) <= 0: + return "open" + return "closed" if int(time.time() * 1000) > _parse_ms(dl) else "open" + + +async def _can_manage_event(payload: dict | None, acct: dict | None, e: dict) -> bool: + """报名/活动管理权限:发布者 / 活动管理员(手机号绑定)/ 平台运营方 / 本园区管理员。""" + if not payload: + return False + uid = payload.get("sub") or payload.get("user_id") or "" + if uid and e.get("publisher_id") == uid: + return True + phone = (acct or {}).get("phone") or "" + if phone: + admins = _load_json(e.get("activity_admins_json") or "", []) + if isinstance(admins, list) and any((a or {}).get("phone") == phone for a in admins): + return True + role = (acct or {}).get("role") or "" + if _is_platform_operator(role): + return True + if (e.get("scope") or "public") == "park": + my_park = await _carrier_park_id(payload) + if my_park and e.get("target_tenant_id") == my_park: + return True + return False + + +def _secret_for(e: dict, viewer: dict | None, acct: dict | None, can_manage: bool) -> dict | None: + """活动隐藏信息可见性:管理者直接可见;报名者仅审核通过(approved/confirmed)后本人可见。""" + secret = _load_json(e.get("secret_json") or "", {}) + if not isinstance(secret, dict) or not (secret.get("text") or secret.get("image") or secret.get("url")): + return None + if can_manage: + return secret + if not viewer: + return None + username = viewer.get("sub") or viewer.get("username") or "" + phone = (acct or {}).get("phone") or "" + if not username and not phone: + return None + conn = db.get_conn() + try: + rows = conn.execute( + "SELECT username, contact, audit_status, status FROM bookings WHERE event_id=?", + (e.get("id"),)).fetchall() + finally: + conn.close() + for r in rows: + if r["username"] in (username, phone) or r["contact"] in (username, phone): + if (r["audit_status"] or "") in ("approved", "confirmed") and r["status"] != "cancelled": + return secret + return None + return None + + +async def _apply_booking_audit(bid: str, decision: str, comment: str, payload: dict, acct: dict) -> dict: + """报名审核统一收口:权限校验 + 状态联动 + 审核记录 + 审核短信(幂等)。 + + 审核动作必须经此入口(PATCH 与 /review 共用),杜绝绕过权限的直改。 + """ + if decision not in ("approved", "rejected"): + raise HTTPException(400, "审核状态仅支持 approved/rejected") + bk = await asyncio.to_thread(db.fetch_by_id, "bookings", bid) + if not bk: + raise HTTPException(404, "报名记录不存在") + ev = await asyncio.to_thread(db.fetch_by_id, "events", bk.get("event_id", "")) + if not ev: + raise HTTPException(404, "活动不存在") + if not await _can_manage_event(payload, acct, ev): + raise HTTPException(403, "仅发布者/活动管理员/运营方可审核该活动报名") + if (bk.get("audit_status") or "pending") == decision and bk.get("audit_at"): + return bk # 幂等:同结果不重复发短信 + from ..infrastructure.repositories import utcnow_iso + # 状态联动:通过 → confirmed(付费未支付除外,保持待支付);驳回 → rejected + status_new = bk.get("status") + if decision == "approved": + pay_required = int(ev.get("price_fen") or 0) > 0 + if not (pay_required and bk.get("pay_status") != "paid"): + status_new = "confirmed" + else: + status_new = "rejected" + patch = { + "audit_status": decision, + "audit_by": (acct or {}).get("nickname") or payload.get("username", ""), + "audit_at": utcnow_iso(), + "audit_comment": str(comment or "")[:500], + "status": status_new, + } + await asyncio.to_thread(db.update_row, "bookings", bid, patch) + # 审核短信(幂等:audit_sms_at 为空才发送;失败不阻断审核) + if not bk.get("audit_sms_at"): + sent = False + try: + pdb = Database() + try: + cfg = await platform_sms.load_sms_config(pdb) + phone = str(bk.get("contact") or "") + if phone and PHONE_RE.match(phone): + await platform_sms.send_template(cfg, "booking_audit", phone, { + "event_title": ev.get("title") or "", + "status": "通过" if decision == "approved" else "未通过", + "reason": str(comment or ""), + "time": ev.get("start_at") or "", + "location": ev.get("location") or "", + }) + sent = True + finally: + await pdb.close() + except Exception as exc: # noqa: BLE001 - 短信失败不阻断审核 + logging.getLogger(__name__).warning("booking_audit sms 失败: %s", exc) + if sent: + await asyncio.to_thread(db.update_row, "bookings", bid, {"audit_sms_at": now_iso()}) + return await asyncio.to_thread(db.fetch_by_id, "bookings", bid) + + +async def scan_event_reminders() -> dict: + """活动前提醒扫描(定时任务入口):对「开始时间 − remind_before_min 窗口内」 + 且审核通过(approved/confirmed)、未取消、未提醒过的报名,发送 event_reminder 短信。 + """ + stats = {"scanned": 0, "sent": 0, "skipped": 0} + now_ms = int(time.time() * 1000) + events = await asyncio.to_thread(db.list_all, "events") + conn = await asyncio.to_thread(db.get_conn, ) + try: + bookings = conn.execute("SELECT * FROM bookings").fetchall() + finally: + conn.close() + for ev in events: + if (ev.get("review_status") or "approved") != "approved": + continue + remind_min = int(ev.get("remind_before_min") or 0) + if remind_min <= 0: + continue + start_ms = _parse_ms(ev.get("start_at") or "") + if start_ms <= 0: + continue + # 窗口:开始前 remind_before_min 至开始时刻(提前量负值/越界自然跳过) + window_open = start_ms - remind_min * 60000 + if not (window_open <= now_ms <= start_ms): + continue + for b in bookings: + if b.get("event_id") != ev["id"]: + continue + if (b.get("audit_status") or "") not in ("approved", "confirmed") or b.get("status") == "cancelled": + continue + if b.get("reminded_at"): + continue + phone = str(b.get("contact") or "") + if not PHONE_RE.match(phone): + continue + sent = False + try: + pdb = Database() + try: + cfg = await platform_sms.load_sms_config(pdb) + await platform_sms.send_template(cfg, "event_reminder", phone, { + "event_title": ev.get("title") or "", + "start_at": ev.get("start_at") or "", + "location": ev.get("location") or "", + }) + sent = True + finally: + await pdb.close() + except Exception as exc: # noqa: BLE001 + logging.getLogger(__name__).warning("event_reminder sms 失败: %s", exc) + if sent: + await asyncio.to_thread(db.update_row, "bookings", b["id"], {"reminded_at": now_iso()}) + stats["sent"] += 1 + stats["scanned"] += 1 + return stats + + +def event_out(e, detail: bool = False, viewer: dict | None = None, + acct: dict | None = None, can_manage: bool = False): """SQLite 行(snake_case) → 前端契约(camelCase) 统一映射。 detail=True(详情页)才返回 body 富文本大字段,列表不返以免拖垮接口。 @@ -605,10 +950,20 @@ def event_out(e, detail: bool = False): "publisherId": e.get("publisher_id") or "", "publisherName": e.get("publisher_name") or "", "enrolled": _count_enrolled(e.get("id")), + # ── 报名体系升级 ── + "signupDeadline": e.get("signup_deadline") or "", + "signupState": _signup_state(e), + "remindBeforeMin": int(e.get("remind_before_min") or 1440), + "canManage": can_manage, } if detail: out["body"] = e.get("body") or "" out["notice"] = e.get("notice") or "" + out["formFields"] = _load_json(e.get("form_fields_json") or "", []) + if not isinstance(out["formFields"], list): + out["formFields"] = [] + out["secret"] = _secret_for(e, viewer, acct, can_manage) + out["activityAdmins"] = _load_json(e.get("activity_admins_json") or "", []) if can_manage else [] return out @@ -721,6 +1076,24 @@ async def my_events(authorization: str = Header(default="")): return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in mine]} +@app.get("/api/events/managed") +async def managed_events(authorization: str = Header(default="")): + """我管理的活动(活动管理员视角):绑定手机号命中 activity_admins 的活动。""" + payload = require_auth(authorization) + acct = await _current_user_async(payload) + phone = (acct or {}).get("phone") or "" + if not phone: + return {"ok": True, "list": []} + all_events = await asyncio.to_thread(db.list_all, "events") + mine = [] + for e in all_events: + admins = _load_json(e.get("activity_admins_json") or "", []) + if isinstance(admins, list) and any((a or {}).get("phone") == phone for a in admins): + mine.append(e) + mine.sort(key=lambda e: e.get("created_at") or "", reverse=True) + return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in mine]} + + @app.get("/api/events/{eid}") async def event_detail(eid: str, authorization: str = Header(default="")): e = await asyncio.to_thread(db.fetch_by_id, "events", eid) @@ -729,7 +1102,8 @@ async def event_detail(eid: str, authorization: str = Header(default="")): # 未过审活动仅发布者可见(详情携带 body 富文本) token = (authorization or "").removeprefix("Bearer ").strip() payload = decode_access_token(token) if token else None - is_publisher = bool(payload) and payload.get("sub") == e.get("publisher_id") + acct = await _current_user_async(payload) if payload else None + is_publisher = bool(payload) and ((payload.get("sub") or payload.get("user_id")) == e.get("publisher_id")) # 未过审:仅发布者可见 if (e.get("review_status") or "approved") != "approved" and not is_publisher: raise HTTPException(404, "活动不存在") @@ -738,7 +1112,9 @@ async def event_detail(eid: str, authorization: str = Header(default="")): vis_parks = await _visible_park_ids(payload) if "*" not in vis_parks and e.get("target_tenant_id") not in vis_parks: raise HTTPException(404, "活动不存在") - return {"ok": True, "event": await asyncio.to_thread(event_out, e, detail=True)} + can_manage = bool(payload) and await _can_manage_event(payload, acct, e) + return {"ok": True, "event": await asyncio.to_thread( + event_out, e, detail=True, viewer=payload, acct=acct, can_manage=can_manage)} @app.post("/api/events") @@ -764,6 +1140,23 @@ async def create_event(req: Request, authorization: str = Header(default="")): raise HTTPException(400, "本园区活动须选择目标园区") if not b.get("endAt"): raise HTTPException(400, "请填写结束时间") + # ── 报名体系升级字段校验 ── + form_fields, ff_err = normalize_form_fields(b.get("formFields")) + if ff_err: + raise HTTPException(400, ff_err) + admins, adm_err = normalize_activity_admins(b.get("activityAdmins")) + if adm_err: + raise HTTPException(400, adm_err) + secret = normalize_secret(b.get("secret")) + try: + remind = int(b.get("remindBeforeMin") if b.get("remindBeforeMin") is not None else 1440) + except (TypeError, ValueError): + remind = 1440 + if remind < 0 or remind > 10080: + raise HTTPException(400, "提醒提前量须在 0~10080 分钟之间") + deadline = str(b.get("signupDeadline", "") or "").strip() + if deadline and _parse_ms(deadline) <= 0: + raise HTTPException(400, "报名截止时间格式不正确") # 发布者(任意登录账号均可发布) user = await _current_user_async(payload) entry = { @@ -797,6 +1190,12 @@ async def create_event(req: Request, authorization: str = Header(default="")): "review_status": _review_status_of(b), "publisher_id": payload.get("user_id", ""), "publisher_name": (user or {}).get("nickname") or payload.get("username", ""), + # ── 报名体系升级 ── + "form_fields_json": json.dumps(form_fields, ensure_ascii=False), + "activity_admins_json": json.dumps(admins, ensure_ascii=False), + "secret_json": json.dumps(secret, ensure_ascii=False), + "signup_deadline": deadline, + "remind_before_min": remind, } await asyncio.to_thread(db.insert, "events", entry) return {"ok": True, "entry": await asyncio.to_thread(event_out, entry, detail=True)} @@ -857,6 +1256,32 @@ async def update_event(eid: str, req: Request, authorization: str = Header(defau patch["audit_mode"] = "manual" if b["auditMode"] == "manual" else "auto" if b.get("showCapacity") is not None: patch["show_capacity"] = 1 if b["showCapacity"] else 0 + # ── 报名体系升级字段 ── + if b.get("formFields") is not None: + ff, ff_err = normalize_form_fields(b.get("formFields")) + if ff_err: + raise HTTPException(400, ff_err) + patch["form_fields_json"] = json.dumps(ff, ensure_ascii=False) + if b.get("activityAdmins") is not None: + adm, adm_err = normalize_activity_admins(b.get("activityAdmins")) + if adm_err: + raise HTTPException(400, adm_err) + patch["activity_admins_json"] = json.dumps(adm, ensure_ascii=False) + if b.get("secret") is not None: + patch["secret_json"] = json.dumps(normalize_secret(b.get("secret")), ensure_ascii=False) + if b.get("remindBeforeMin") is not None: + try: + remind = int(b.get("remindBeforeMin")) + except (TypeError, ValueError): + raise HTTPException(400, "提醒提前量须为整数分钟") + if remind < 0 or remind > 10080: + raise HTTPException(400, "提醒提前量须在 0~10080 分钟之间") + patch["remind_before_min"] = remind + if b.get("signupDeadline") is not None: + dl = str(b.get("signupDeadline") or "").strip() + if dl and _parse_ms(dl) <= 0: + raise HTTPException(400, "报名截止时间格式不正确") + patch["signup_deadline"] = dl pf = _parse_price_fen(b) if pf is not None: patch["price_fen"] = pf @@ -998,6 +1423,9 @@ async def create_booking(req: Request, authorization: str = Header(default="")): # 审核闸:未通过审核的活动不可报名 if (event.get("review_status") or "approved") != "approved": raise HTTPException(400, "活动未通过审核,暂不可报名") + # 报名截止闸:设置了截止时间且已过 → 拒绝报名 + if _signup_state(event) == "closed": + raise HTTPException(400, "本场报名已截止") # 报名方式闸:外链报名活动不出站内报名 if (event.get("signup_mode") or "mini") == "external": raise HTTPException(400, "该活动为外部报名,请前往报名链接") @@ -1024,6 +1452,14 @@ async def create_booking(req: Request, authorization: str = Header(default="")): # 人工审核 / 付费活动 → 先挂起(pending),审核通过或支付成功后再由相应流程置 confirmed pay_required = int(event.get("price_fen") or 0) > 0 audit_auto = event.get("audit_mode") != "manual" + # 自定义表单:按活动定义逐字段校验(必填/类型/枚举/长度),错误返回字段级提示 + form_fields = _load_json(event.get("form_fields_json") or "", []) + if not isinstance(form_fields, list): + form_fields = [] + form_values, form_errors = normalize_form_data(form_fields, b.get("formData")) + if form_errors: + fkey = next(iter(form_errors)) + raise HTTPException(400, {"code": "FIELD_INVALID", "field": fkey, "message": form_errors[fkey]}) entry = { "id": db.gen_id("B"), "created_at": now_iso(), "status": "confirmed" if (not pay_required and audit_auto) else "pending", @@ -1034,6 +1470,8 @@ async def create_booking(req: Request, authorization: str = Header(default="")): "topics": json.dumps(topics_val, ensure_ascii=False), "question": b.get("question", ""), "source": source, "audit_status": "approved" if (not pay_required and audit_auto) else "pending", + # ── 报名体系升级:自定义表单值(与定义解耦,定义在 events.form_fields_json) ── + "form_data_json": json.dumps(form_values, ensure_ascii=False), } # 付费活动:报名先挂起(unpaid/不自动通过),支付成功后由回调置 paid/confirmed/approved if pay_required: @@ -1174,6 +1612,16 @@ def _decode_bk(row): d["orderNo"] = d.get("order_no") or "" d["appealStatus"] = d.get("appeal_status") or "" d["appealReason"] = d.get("appeal_reason") or "" + # ── 报名体系升级:自定义表单值 / 审核记录 / 隐藏信息可见性 ── + try: + d["formData"] = json.loads(d.get("form_data_json") or "{}") + except Exception: + d["formData"] = {} + d["auditBy"] = d.get("audit_by") or "" + d["auditAt"] = d.get("audit_at") or "" + d["auditComment"] = d.get("audit_comment") or "" + d["auditSmsAt"] = d.get("audit_sms_at") or "" + d["canViewSecret"] = (d.get("audit_status") or "") in ("approved", "confirmed") return d @@ -1309,11 +1757,16 @@ async def my_bookings(authorization: str = Header(default="")): x["event_checkin_at"] = ev.get("checkin_at") x["priceFen"] = int(ev.get("price_fen") or 0) x["can_checkin"] = bool(open_ms <= now_ms <= end_ms and ev.get("status") != "done") + # 报名体系升级:附表单定义(供按定义渲染 formData 与隐藏字段) + ff = _load_json(ev.get("form_fields_json") or "", []) + x["formFields"] = ff if isinstance(ff, list) else [] + x["eventSignupDeadline"] = ev.get("signup_deadline") or "" else: x["event_status"] = None x["event_end"] = None x["event_checkin_at"] = None x["can_checkin"] = False + x["formFields"] = [] # ── 生命周期派生 ── ended_ev = x.get("event_status") == "done" or (x.get("event_end") and _parse_ms(x.get("event_end")) and _parse_ms(x.get("event_end")) < now_ms) x["no_show"] = bool(ended_ev and not x.get("checkin_at") @@ -1366,7 +1819,9 @@ async def my_booking_for_event(event_id: str, authorization: str = Header(defaul return {"ok": True, "booked": False, "auditStatus": None} return {"ok": True, "booked": True, "id": hit["id"], "auditStatus": hit.get("audit_status") or "pending", "status": hit.get("status"), "checkinAt": hit.get("checkin_at"), - "payStatus": hit.get("pay_status") or "", "orderNo": hit.get("order_no") or ""} + "payStatus": hit.get("pay_status") or "", "orderNo": hit.get("order_no") or "", + "auditComment": hit.get("auditComment") or "", + "canViewSecret": bool(hit.get("canViewSecret"))} @app.post("/api/checkins") @@ -1415,20 +1870,102 @@ async def admin_bookings(authorization: str = Header(default=""), status: Option @app.patch("/api/bookings/{bid}") async def update_booking(bid: str, req: Request, authorization: str = Header(default="")): - require_auth(authorization) + """更新报名单(管理视角):发布者/活动管理员/运营方可编辑资料或执行审核(触发短信)。 + + 原实现仅 require_auth 即可任意改审核状态(越权风险);现收口到 + _apply_booking_audit 统一权限校验 + 状态联动 + 审计 + 短信幂等。 + """ + payload = require_auth(authorization) + acct = await _current_user_async(payload) + if not acct: + raise HTTPException(401, "登录状态异常,请重新登录") b = await req.json() - if not await asyncio.to_thread(db.fetch_by_id, "bookings", bid): + bk = await asyncio.to_thread(db.fetch_by_id, "bookings", bid) + if not bk: raise HTTPException(404, "预约不存在") + ev = await asyncio.to_thread(db.fetch_by_id, "events", bk.get("event_id", "")) + if not ev: + raise HTTPException(404, "活动不存在") + if not await _can_manage_event(payload, acct, ev): + raise HTTPException(403, "仅发布者/活动管理员/运营方可操作该活动报名") patch = {} - if b.get("status") in ("pending", "confirmed", "arrived", "converted"): + if b.get("status") in ("pending", "confirmed", "arrived", "converted", "rejected", "cancelled"): patch["status"] = b["status"] - if b.get("auditStatus") in ("pending", "approved", "rejected"): - patch["audit_status"] = b["auditStatus"] + if b.get("name") is not None: + patch["name"] = str(b.get("name") or "").strip()[:100] + if b.get("contact") is not None: + patch["contact"] = str(b.get("contact") or "").strip()[:30] if patch: await asyncio.to_thread(db.update_row, "bookings", bid, patch) + # 审核动作:auditStatus 变化 → 统一审核入口(状态联动 + 审计 + 短信) + if b.get("auditStatus") in ("approved", "rejected") and b.get("auditStatus") != (bk.get("audit_status") or "pending"): + await _apply_booking_audit(bid, b["auditStatus"], b.get("auditComment") or "", payload, acct) return {"ok": True, "entry": _decode_bk(await asyncio.to_thread(db.fetch_by_id, "bookings", bid))} +@app.post("/api/bookings/{bid}/review") +async def review_booking(bid: str, req: Request, authorization: str = Header(default="")): + """报名审核统一入口(小程序/管理端共用):approved/rejected + 原因,触发审核短信。 + + 权限:发布者 / 活动管理员(手机号绑定)/ 平台运营方 / 本园区管理员。 + """ + payload = require_auth(authorization) + acct = await _current_user_async(payload) + if not acct: + raise HTTPException(401, "登录状态异常,请重新登录") + b = await req.json() + bk = await _apply_booking_audit(bid, b.get("status"), b.get("comment") or "", payload, acct) + return {"ok": True, "entry": _decode_bk(bk)} + + +@app.get("/api/events/{eid}/bookings") +async def event_bookings(eid: str, authorization: str = Header(default=""), + audit: Optional[str] = None, status: Optional[str] = None): + """活动报名列表(管理视角):发布者/活动管理员/运营方/园区管理员。 + + 返回自定义表单值(formData)与审核记录(auditBy/auditAt/auditComment), + 附活动表单定义(formFields)供前端按定义渲染。 + """ + payload = require_auth(authorization) + acct = await _current_user_async(payload) + if not acct: + raise HTTPException(401, "登录状态异常,请重新登录") + e = await asyncio.to_thread(db.fetch_by_id, "events", eid) + if not e: + raise HTTPException(404, "活动不存在") + if not await _can_manage_event(payload, acct, e): + raise HTTPException(403, "仅发布者/活动管理员/运营方可查看该活动报名") + conn = await asyncio.to_thread(db.get_conn, ) + try: + rows = conn.execute("SELECT * FROM bookings WHERE event_id=?", (eid,)).fetchall() + finally: + conn.close() + lst = [_decode_bk(r) for r in rows] + lst.sort(key=lambda x: x["created_at"] or "", reverse=True) + if audit: + lst = [x for x in lst if x.get("audit_status") == audit] + if status: + lst = [x for x in lst if x["status"] == status] + form_fields = _load_json(e.get("form_fields_json") or "", []) + if not isinstance(form_fields, list): + form_fields = [] + return {"ok": True, "list": lst, "formFields": form_fields, + "event": {"id": e["id"], "title": e.get("title", ""), "signupDeadline": e.get("signup_deadline") or ""}} + + +@app.post("/api/events/reminders/scan") +async def event_reminders_scan(request: Request, x_internal_token: str = Header(default="")): + """活动前提醒短信扫描(定时任务入口,幂等)。 + + 调用方须携带 X-Internal-Token(ENV EVENT_REMINDER_TOKEN,默认开发值), + 由外部 cron 或部署脚本周期性调用;窗口内已提醒的报名不会重复发送。 + """ + if x_internal_token != EVENT_REMINDER_TOKEN: + raise HTTPException(403, "内部令牌校验失败") + stats = await scan_event_reminders() + return {"ok": True, **stats} + + @app.delete("/api/bookings/{bid}") async def delete_booking(bid: str, authorization: str = Header(default="")): require_auth(authorization)