代喂改走真实订单,并补上打卡纪律与地址明文清除
支付原来是个占位:调一下接口任务就算"已付"。现在任务和喂养者保证金都 走会员那套订单与支付网关,一笔订单买到什么由 applyPaidOrderTx 统一决定, 沙箱和真实回调走同一段逻辑。退款能释放托管中的任务,但不会去动已经结算 的那一笔——钱已经在喂养者账上,那种情况必须有人看过再说。 通道接通之前,客服可以在管理端手工标记已托管,必须写明钱怎么收的,进审计日志。 另外补上三件方案里写了但代码没做的事:打卡必须按到达、喂食、离开的顺序, 喂食离到达太近视为摆拍;地址新增紧急联系人与常去医院,随门牌一起只发给 被选中的喂养者;任务结束七天后由后台任务抹掉地址明文,只留城市与街道。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
97351777ec
commit
9345805133
@@ -118,6 +118,7 @@ func (a *App) Run() {
|
||||
go a.runAIMaintenance(workerContext)
|
||||
go a.runChatMediaCleanupWorker(workerContext)
|
||||
a.startFeedSettlementWorker(workerContext)
|
||||
a.startAddressPurgeWorker(workerContext)
|
||||
server := rest.MustNewServer(rest.RestConf{
|
||||
Host: a.config.Host,
|
||||
Port: a.config.Port,
|
||||
@@ -267,7 +268,8 @@ func (a *App) userRoutes() []rest.Route {
|
||||
{Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.feedTasks)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.createFeedTask)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks/:id", Handler: auth(a.feedTaskDetail)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/pay", Handler: auth(a.escrowFeedTask)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/order", Handler: auth(a.createFeedTaskOrder)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/sitter/deposit", Handler: auth(a.createDepositOrder)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/apply", Handler: auth(a.applyFeedTask)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/assign", Handler: auth(a.assignFeedTask)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/confirm", Handler: auth(a.confirmFeedTask)},
|
||||
@@ -371,6 +373,7 @@ func (a *App) adminRoutes() []rest.Route {
|
||||
{Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("users:manage", a.adminEscrowFeedTask)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)},
|
||||
|
||||
@@ -193,11 +193,10 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var userID, planID int64
|
||||
var userID, productID int64
|
||||
var amountCent int
|
||||
var orderNo string
|
||||
var status string
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status)
|
||||
var orderNo, productType, status string
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_type,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &productType, &productID, &amountCent, &orderNo, &status)
|
||||
if err != nil {
|
||||
fail(w, 404, 30001, "订单不存在")
|
||||
return
|
||||
@@ -210,13 +209,9 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, 20001, "当前订单状态无法支付")
|
||||
return
|
||||
}
|
||||
var durationDays, level int
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil {
|
||||
fail(w, 400, 20001, "会员套餐已下架")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil {
|
||||
err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level)
|
||||
// 一笔订单买到什么,沙箱和真实回调走同一段逻辑,免得两边跑偏。
|
||||
err = a.applyPaidOrderTx(r.Context(), tx, id, userID, productType, productID, amountCent)
|
||||
}
|
||||
if err != nil || tx.Commit() != nil {
|
||||
fail(w, 500, 50001, "支付入账失败")
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One order table now buys three different things, so what an order *does* when
|
||||
// its money arrives lives here instead of being spelled out again in the
|
||||
// sandbox path and in the gateway callback. Both call the same two functions,
|
||||
// which is what keeps a sandbox payment and a real one from drifting apart.
|
||||
|
||||
const (
|
||||
productMembership = "membership"
|
||||
productPetFeed = "pet_feed"
|
||||
productPetDeposit = "pet_deposit"
|
||||
)
|
||||
|
||||
func (a *App) applyPaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID int64, productType string, productID int64, amountCent int) error {
|
||||
switch productType {
|
||||
case productMembership:
|
||||
var durationDays, level int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, productID).Scan(&durationDays, &level); err != nil {
|
||||
return fmt.Errorf("membership plan does not exist")
|
||||
}
|
||||
return grantOrderMembershipTx(ctx, tx, orderID, userID, productID, durationDays, level)
|
||||
|
||||
case productPetFeed:
|
||||
// The task only becomes visible to sitters once the money is held, and it
|
||||
// remembers which order is holding it — a refund has to find its way back.
|
||||
var owner int64
|
||||
var gross int
|
||||
var status string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT owner_user_id,gross_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&owner, &gross, &status); err != nil {
|
||||
return fmt.Errorf("feeding task does not exist")
|
||||
}
|
||||
if owner != userID || gross != amountCent {
|
||||
return fmt.Errorf("payment does not match the feeding task")
|
||||
}
|
||||
if status == "ESCROWED" {
|
||||
return nil
|
||||
}
|
||||
if status != "CREATED" {
|
||||
return fmt.Errorf("feeding task status does not allow payment")
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='ESCROWED',order_id=? WHERE id=? AND status='CREATED'`, orderID, productID)
|
||||
return err
|
||||
|
||||
case productPetDeposit:
|
||||
result, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=deposit_cent+? WHERE user_id=?`, amountCent, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return fmt.Errorf("sitter profile does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown product type: %s", productType)
|
||||
}
|
||||
|
||||
// revokePaidOrderTx undoes what the money bought. A feeding task that has
|
||||
// already been settled is deliberately not refundable here: the payout has left
|
||||
// the escrow, so that case has to be looked at by a person rather than quietly
|
||||
// reversed.
|
||||
func (a *App) revokePaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID int64, productType string, productID int64, amountCent int) error {
|
||||
switch productType {
|
||||
case productMembership:
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`,
|
||||
userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.recomputeMembershipTx(ctx, tx, userID)
|
||||
|
||||
case productPetFeed:
|
||||
var status string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&status); err != nil {
|
||||
return fmt.Errorf("feeding task does not exist")
|
||||
}
|
||||
if status == "SETTLED" || status == "ARBITRATED" {
|
||||
return fmt.Errorf("feeding task is already settled; refund it through the dispute flow")
|
||||
}
|
||||
if status == "REFUNDED" {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, productID)
|
||||
return err
|
||||
|
||||
case productPetDeposit:
|
||||
// Never below zero: part of a deposit may already have paid a claim.
|
||||
_, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=GREATEST(CAST(deposit_cent AS SIGNED)-?,0) WHERE user_id=?`, amountCent, userID)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unknown product type: %s", productType)
|
||||
}
|
||||
|
||||
// --- 下单 -----------------------------------------------------------------
|
||||
|
||||
// startOrder creates (or hands back) the pending order for one purchase. It is
|
||||
// shared by the pet products; membership keeps its own entry point because it
|
||||
// also has to look the plan up.
|
||||
func (a *App) startOrder(w http.ResponseWriter, r *http.Request, productType string, productID int64, amountCent int, channel string) {
|
||||
channels := a.availablePaymentChannels(r.Context())
|
||||
if channel == "" && len(channels) > 0 {
|
||||
channel, _ = channels[0]["code"].(string)
|
||||
}
|
||||
allowed := false
|
||||
for _, item := range channels {
|
||||
if item["code"] == channel {
|
||||
allowed = item["configured"] == true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
fail(w, http.StatusBadRequest, 20001, "支付渠道未启用或配置不完整")
|
||||
return
|
||||
}
|
||||
// A second tap on 支付 should reuse the order it already made, not stack up
|
||||
// unpaid orders for the same task.
|
||||
var existingID int64
|
||||
var existingNo string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT id,order_no FROM orders WHERE user_id=? AND product_type=? AND product_id=? AND status='CREATED' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`,
|
||||
current(r).ID, productType, productID).Scan(&existingID, &existingNo) == nil {
|
||||
reply(w, map[string]any{"id": existingID, "orderNo": existingNo, "amountCent": amountCent, "status": "CREATED", "channel": channel})
|
||||
return
|
||||
}
|
||||
orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8])
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,?,?,?,'CREATED',?)`,
|
||||
orderNo, current(r).ID, productType, productID, amountCent, channel)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建订单失败")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": "CREATED", "channel": channel})
|
||||
}
|
||||
|
||||
func (a *App) createFeedTaskOrder(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if r.ContentLength > 0 && decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
var gross int
|
||||
var status string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,gross_cent,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &gross, &status) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
||||
return
|
||||
}
|
||||
if owner != current(r).ID {
|
||||
fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以支付")
|
||||
return
|
||||
}
|
||||
if status != "CREATED" {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付")
|
||||
return
|
||||
}
|
||||
a.startOrder(w, r, productPetFeed, id, gross, strings.TrimSpace(req.Channel))
|
||||
}
|
||||
|
||||
func (a *App) createDepositOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if r.ContentLength > 0 && decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
||||
return
|
||||
}
|
||||
var paid int
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&paid) != nil {
|
||||
fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者")
|
||||
return
|
||||
}
|
||||
required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000)
|
||||
if paid >= required {
|
||||
fail(w, http.StatusBadRequest, 20001, "保证金已缴纳")
|
||||
return
|
||||
}
|
||||
a.startOrder(w, r, productPetDeposit, current(r).ID, required-paid, strings.TrimSpace(req.Channel))
|
||||
}
|
||||
|
||||
// adminEscrowFeedTask is the grey-release escape hatch: until a payment channel
|
||||
// is connected, an operator who has taken the money some other way marks the
|
||||
// task as funded. It is deliberately an admin action with an audit entry rather
|
||||
// than a button in the app.
|
||||
func (a *App) adminEscrowFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
||||
return
|
||||
}
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
if len([]rune(req.Note)) < 5 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请写明这笔钱是怎么收到的,至少 5 个字")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
var gross int
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,gross_cent FROM pet_feed_tasks WHERE id=? AND status='CREATED'`, id).Scan(&owner, &gross) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在或状态不允许")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ESCROWED' WHERE id=? AND status='CREATED'`, id)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务状态不允许")
|
||||
return
|
||||
}
|
||||
a.audit(r, "manual_escrow", "pet_feed_task", id, map[string]any{"grossCent": gross, "note": req.Note})
|
||||
a.notifyUser(r.Context(), owner, "system", "代喂任务已发布", "任务已进入代喂大厅,等待喂养者报名", "feed_task", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -183,10 +183,10 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var orderID, userID, planID int64
|
||||
var orderID, userID, productID int64
|
||||
var amountCent int
|
||||
var status, channel string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil {
|
||||
var status, channel, productType string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_type,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &productType, &productID, &amountCent, &status, &channel); err != nil {
|
||||
return fmt.Errorf("order does not exist")
|
||||
}
|
||||
if channel != notice.Channel || amountCent != notice.AmountCent {
|
||||
@@ -202,10 +202,7 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil {
|
||||
if err = a.revokePaidOrderTx(ctx, tx, orderID, userID, productType, productID, amountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -228,14 +225,10 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var durationDays, level int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil {
|
||||
return fmt.Errorf("membership plan does not exist")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil {
|
||||
if err = a.applyPaidOrderTx(ctx, tx, orderID, userID, productType, productID, notice.AmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The address table holds a stranger's door number and phone. Keeping that
|
||||
// after the job is over buys nothing and costs a lot if the database ever
|
||||
// leaks, so a finished task schedules its address for erasure and this worker
|
||||
// carries it out. The city and street stay: they are what the history reads
|
||||
// from, and they were public on the listing anyway.
|
||||
|
||||
func (a *App) scheduleAddressPurge(ctx context.Context) int64 {
|
||||
days := a.configInt(ctx, "pet.address_purge_days", 7)
|
||||
if days < 1 {
|
||||
days = 7
|
||||
}
|
||||
// 只给"没有任何进行中任务"的地址排期;主人常用的地址不会因为一次任务结束就被清空。
|
||||
result, err := a.db.ExecContext(ctx, `UPDATE pet_addresses addr
|
||||
JOIN (SELECT address_id,MAX(updated_at) AS finished FROM pet_feed_tasks
|
||||
WHERE status IN ('SETTLED','REFUNDED','CANCELLED','ARBITRATED') GROUP BY address_id) ended
|
||||
ON ended.address_id=addr.id
|
||||
SET addr.purge_at=DATE_ADD(ended.finished,INTERVAL ? DAY)
|
||||
WHERE addr.purge_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM pet_feed_tasks live WHERE live.address_id=addr.id
|
||||
AND live.status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED'))`, days)
|
||||
if err != nil {
|
||||
log.Printf("pet address purge scheduling failed: %v", err)
|
||||
return 0
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
return affected
|
||||
}
|
||||
|
||||
func (a *App) purgeDueAddresses(ctx context.Context) int64 {
|
||||
// 一个地址重新被用于新任务时会清掉 purge_at;这里再确认一次没有活任务,
|
||||
// 免得排期和复用之间抢到一起。
|
||||
result, err := a.db.ExecContext(ctx, `UPDATE pet_addresses addr
|
||||
SET addr.detail_cipher='',addr.contact_cipher='',addr.emergency_cipher=NULL,addr.vet_hospital='',
|
||||
addr.latitude=NULL,addr.longitude=NULL,addr.purge_at=NULL
|
||||
WHERE addr.purge_at IS NOT NULL AND addr.purge_at<=NOW(3)
|
||||
AND NOT EXISTS (SELECT 1 FROM pet_feed_tasks live WHERE live.address_id=addr.id
|
||||
AND live.status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED'))`)
|
||||
if err != nil {
|
||||
log.Printf("pet address purge failed: %v", err)
|
||||
return 0
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
log.Printf("pet address purge: cleared plaintext for %d addresses", affected)
|
||||
}
|
||||
return affected
|
||||
}
|
||||
|
||||
func (a *App) startAddressPurgeWorker(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.scheduleAddressPurge(ctx)
|
||||
a.purgeDueAddresses(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -42,6 +43,7 @@ type feedTaskView struct {
|
||||
Status string `json:"status"`
|
||||
SitterUserID int64 `json:"sitterUserId"`
|
||||
SitterName string `json:"sitterName"`
|
||||
Conversation int64 `json:"conversationId"`
|
||||
Applications int `json:"applicationsCount"`
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
Done int `json:"completedVisits"`
|
||||
@@ -68,13 +70,15 @@ func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout
|
||||
|
||||
func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CityCode string `json:"cityCode"`
|
||||
CityName string `json:"cityName"`
|
||||
AreaText string `json:"areaText"`
|
||||
Detail string `json:"detail"`
|
||||
Contact string `json:"contact"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
CityCode string `json:"cityCode"`
|
||||
CityName string `json:"cityName"`
|
||||
AreaText string `json:"areaText"`
|
||||
Detail string `json:"detail"`
|
||||
Contact string `json:"contact"`
|
||||
Emergency string `json:"emergency"`
|
||||
VetHospital string `json:"vetHospital"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "地址格式错误")
|
||||
@@ -83,11 +87,14 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
req.AreaText = strings.TrimSpace(req.AreaText)
|
||||
req.Detail = strings.TrimSpace(req.Detail)
|
||||
req.Contact = strings.TrimSpace(req.Contact)
|
||||
req.Emergency = strings.TrimSpace(req.Emergency)
|
||||
req.VetHospital = strings.TrimSpace(req.VetHospital)
|
||||
if req.AreaText == "" || req.Detail == "" || req.Contact == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "请填写完整的地址与联系方式")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 {
|
||||
if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 ||
|
||||
len([]rune(req.Emergency)) > 100 || len([]rune(req.VetHospital)) > 100 {
|
||||
fail(w, http.StatusBadRequest, 20001, "地址内容过长")
|
||||
return
|
||||
}
|
||||
@@ -107,8 +114,17 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,latitude,longitude)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, req.Latitude, req.Longitude)
|
||||
// 紧急联系人是选填的,但填了就和门牌一样加密。
|
||||
var emergency []byte
|
||||
if req.Emergency != "" {
|
||||
if emergency, err = a.encryptPhone(req.Emergency); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,emergency_cipher,vet_hospital,latitude,longitude)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText,
|
||||
detail, contact, emergency, req.VetHospital, req.Latitude, req.Longitude)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
@@ -269,10 +285,16 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
var cityCode string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`, req.AddressID, current(r).ID).Scan(&cityCode) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "服务地址不存在")
|
||||
var purgeAt sql.NullTime
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT city_code,purge_at FROM pet_addresses WHERE id=? AND user_id=? AND LENGTH(detail_cipher)>0`,
|
||||
req.AddressID, current(r).ID).Scan(&cityCode, &purgeAt) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "服务地址不存在,请重新填写")
|
||||
return
|
||||
}
|
||||
// 又用上了就别再排队清除。
|
||||
if purgeAt.Valid {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE pet_addresses SET purge_at=NULL WHERE id=?`, req.AddressID)
|
||||
}
|
||||
var petName string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT name FROM pets WHERE id=? AND owner_user_id=? AND status=1 AND deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&petName) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
@@ -323,30 +345,9 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits})
|
||||
}
|
||||
|
||||
// escrowFeedTask stands in for the payment callback: the task only becomes
|
||||
// visible to sitters once the money is held. Wiring it to the real gateway
|
||||
// replaces the body, not the state machine.
|
||||
func (a *App) escrowFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ESCROWED' WHERE id=? AND owner_user_id=? AND status='CREATED'`, id, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
const feedTaskColumns = `t.id,t.owner_user_id,owner.nickname,t.pet_id,p.name,p.species,p.avatar_url,addr.city_name,addr.area_text,
|
||||
t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.gross_cent,t.payout_cent,t.note,t.status,
|
||||
COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),t.applications_count,t.total_visits,t.completed_visits,t.created_at`
|
||||
COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),COALESCE(t.conversation_id,0),t.applications_count,t.total_visits,t.completed_visits,t.created_at`
|
||||
|
||||
func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) []feedTaskView {
|
||||
items := []feedTaskView{}
|
||||
@@ -359,7 +360,7 @@ func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) [
|
||||
if rows.Scan(&item.ID, &item.OwnerUserID, &item.OwnerName, &item.PetID, &item.PetName, &item.Species, &item.PetAvatar,
|
||||
&item.CityName, &item.AreaText, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent,
|
||||
&item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
|
||||
&item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
|
||||
&item.Conversation, &item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
|
||||
continue
|
||||
}
|
||||
item.SpeciesLabel = petSpecies[item.Species]
|
||||
@@ -463,8 +464,8 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// The exact address and phone number are released only to the person who has
|
||||
// to go there, and only once they have been chosen.
|
||||
if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") {
|
||||
detail, contact := a.decryptAddress(r.Context(), task.ID)
|
||||
view["address"] = map[string]any{"detail": detail, "contact": contact}
|
||||
address := a.decryptAddress(r.Context(), task.ID)
|
||||
view["address"] = address
|
||||
}
|
||||
if task.Mine {
|
||||
applications, _ := a.feedApplications(r.Context(), id)
|
||||
@@ -475,21 +476,31 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, view)
|
||||
}
|
||||
|
||||
func (a *App) decryptAddress(ctx context.Context, taskID int64) (string, string) {
|
||||
var detailCipher, contactCipher []byte
|
||||
if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID).
|
||||
Scan(&detailCipher, &contactCipher) != nil {
|
||||
return "", ""
|
||||
// decryptAddress hands the door number, the phone number and the emergency
|
||||
// contact to the one person who has to go there. An address whose plaintext has
|
||||
// already been purged comes back empty rather than as an error: by then the
|
||||
// task is long over.
|
||||
func (a *App) decryptAddress(ctx context.Context, taskID int64) map[string]any {
|
||||
var detailCipher, contactCipher, emergencyCipher []byte
|
||||
var hospital string
|
||||
if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher,addr.emergency_cipher,addr.vet_hospital
|
||||
FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID).
|
||||
Scan(&detailCipher, &contactCipher, &emergencyCipher, &hospital) != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
detail, err := a.decryptPhone(detailCipher)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
address := map[string]any{"vetHospital": hospital}
|
||||
if detail, err := a.decryptPhone(detailCipher); err == nil {
|
||||
address["detail"] = detail
|
||||
}
|
||||
contact, err := a.decryptPhone(contactCipher)
|
||||
if err != nil {
|
||||
return detail, ""
|
||||
if contact, err := a.decryptPhone(contactCipher); err == nil {
|
||||
address["contact"] = contact
|
||||
}
|
||||
return detail, contact
|
||||
if len(emergencyCipher) > 0 {
|
||||
if emergency, err := a.decryptPhone(emergencyCipher); err == nil {
|
||||
address["emergency"] = emergency
|
||||
}
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func (a *App) feedApplications(ctx context.Context, taskID int64) ([]map[string]any, error) {
|
||||
@@ -697,6 +708,55 @@ func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
// 双方的沟通要留在站内:出了纠纷,聊天记录和打卡照片是同一套证据。
|
||||
conversationID, convErr := a.bindTaskConversation(r.Context(), id, current(r).ID, sitter)
|
||||
if convErr != nil {
|
||||
log.Printf("pet feed: 绑定会话失败 task=%d: %v", id, convErr)
|
||||
}
|
||||
a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
reply(w, map[string]any{"success": true, "conversationId": conversationID})
|
||||
}
|
||||
|
||||
// bindTaskConversation makes sure the two sides have a direct conversation and
|
||||
// remembers it on the task. It deliberately skips the stranger and block rules
|
||||
// of the normal chat entry point: these two have a contract with each other,
|
||||
// and the platform needs the exchange to happen where it can be reviewed.
|
||||
func (a *App) bindTaskConversation(ctx context.Context, taskID, owner, sitter int64) (int64, error) {
|
||||
first, second := owner, sitter
|
||||
if first > second {
|
||||
first, second = second, first
|
||||
}
|
||||
var id int64
|
||||
err := a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id)
|
||||
if err != nil {
|
||||
tx, beginErr := a.db.BeginTx(ctx, nil)
|
||||
if beginErr != nil {
|
||||
return 0, beginErr
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, execErr := tx.ExecContext(ctx, `INSERT INTO im_conversations(conversation_type)VALUES(1)`)
|
||||
if execErr != nil {
|
||||
return 0, execErr
|
||||
}
|
||||
id, _ = result.LastInsertId()
|
||||
if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second); execErr != nil {
|
||||
// 并发下另一边可能刚建好,直接用那一条。
|
||||
if a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) != nil {
|
||||
return 0, execErr
|
||||
}
|
||||
return id, a.rememberTaskConversation(ctx, taskID, id)
|
||||
}
|
||||
if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second); execErr != nil {
|
||||
return 0, execErr
|
||||
}
|
||||
if execErr = tx.Commit(); execErr != nil {
|
||||
return 0, execErr
|
||||
}
|
||||
}
|
||||
return id, a.rememberTaskConversation(ctx, taskID, id)
|
||||
}
|
||||
|
||||
func (a *App) rememberTaskConversation(ctx context.Context, taskID, conversationID int64) error {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE pet_feed_tasks SET conversation_id=? WHERE id=?`, conversationID, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The feeding module moves real money, so these tests are written around the
|
||||
@@ -121,11 +122,7 @@ func TestPetFeedTaskMySQL(t *testing.T) {
|
||||
t.Fatalf("HTTP %d: %s", again.Code, again.Body.String())
|
||||
}
|
||||
// Pending review: still cannot take work.
|
||||
pay := httptest.NewRecorder()
|
||||
a.escrowFeedTask(pay, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/pay", taskID), ``))
|
||||
if pay.Code != 200 {
|
||||
t.Fatalf("支付失败: %s", pay.Body.String())
|
||||
}
|
||||
payFeedTask(t, a, owner, taskID)
|
||||
blocked := httptest.NewRecorder()
|
||||
a.applyFeedTask(blocked, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`))
|
||||
if blocked.Code != 403 {
|
||||
@@ -215,11 +212,14 @@ func TestPetFeedTaskMySQL(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("the whole service completes and pays exactly once", func(t *testing.T) {
|
||||
// 打卡要按顺序、并且喂食离到达有间隔,所以时间戳是分开给的。
|
||||
for _, visitID := range allVisits(t, db, taskID) {
|
||||
for _, step := range []string{"arrive", "feed", "leave"} {
|
||||
base := time.Now()
|
||||
for index, step := range []string{"arrive", "feed", "leave"} {
|
||||
w := httptest.NewRecorder()
|
||||
a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID),
|
||||
fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345}`, step, step)))
|
||||
fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`,
|
||||
step, step, base.Add(time.Duration(index*3)*time.Minute).Format(time.RFC3339))))
|
||||
// The first visit already has an 'arrive' from the previous case.
|
||||
if w.Code != 200 && w.Code != 400 {
|
||||
t.Fatalf("打卡失败: HTTP %d %s", w.Code, w.Body.String())
|
||||
@@ -362,6 +362,28 @@ func TestPetFeedDisputeMySQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// payFeedTask walks the real money path: create the order, pay it in sandbox,
|
||||
// and check the task is only funded because the payment landed.
|
||||
func payFeedTask(t *testing.T, a *App, owner, taskID int64) {
|
||||
t.Helper()
|
||||
order := httptest.NewRecorder()
|
||||
a.createFeedTaskOrder(order, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`))
|
||||
if order.Code != 200 {
|
||||
t.Fatalf("下单失败: %s", order.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(order.Body.Bytes(), &envelope)
|
||||
paid := httptest.NewRecorder()
|
||||
a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``))
|
||||
if paid.Code != 200 {
|
||||
t.Fatalf("支付失败: %s", paid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func firstVisit(t *testing.T, db *sql.DB, taskID int64) int64 {
|
||||
t.Helper()
|
||||
visits := allVisits(t, db, taskID)
|
||||
@@ -505,3 +527,374 @@ func TestPetOverviewMySQL(t *testing.T) {
|
||||
t.Fatalf("对账出现差异: %+v", settled)
|
||||
}
|
||||
}
|
||||
|
||||
// Payment is what actually funds a task now, so the states either side of it
|
||||
// matter: nothing before the money, no double effect after it, and a refund
|
||||
// that cannot quietly reverse a payout that has already been made.
|
||||
func TestPetOrderPaymentMySQL(t *testing.T) {
|
||||
db := isolatedIMDatabase(t)
|
||||
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
||||
a.config.ConfigEncryptionKey = strings.Repeat("k", 32)
|
||||
owner, sitter := int64(1), int64(2)
|
||||
|
||||
petID := petWithPhotos(t, a, owner, "汤圆", 3)
|
||||
address := httptest.NewRecorder()
|
||||
a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
|
||||
`{"cityName":"深圳","areaText":"福田区","detail":"5 栋 302","contact":"王女士 13900000000","latitude":22.5411,"longitude":113.9345}`))
|
||||
var addressEnvelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope)
|
||||
task := httptest.NewRecorder()
|
||||
a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
|
||||
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":2,
|
||||
"minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID)))
|
||||
if task.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", task.Code, task.Body.String())
|
||||
}
|
||||
var taskEnvelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope)
|
||||
taskID := taskEnvelope.Data.ID
|
||||
|
||||
orderID := int64(0)
|
||||
t.Run("the order carries the real price of the task and is reused, not duplicated", func(t *testing.T) {
|
||||
first := httptest.NewRecorder()
|
||||
a.createFeedTaskOrder(first, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`))
|
||||
if first.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", first.Code, first.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
AmountCent int64 `json:"amountCent"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(first.Body.Bytes(), &envelope)
|
||||
orderID = envelope.Data.ID
|
||||
if envelope.Data.AmountCent != 6000 {
|
||||
t.Fatalf("订单金额 = %d,应当等于任务总价", envelope.Data.AmountCent)
|
||||
}
|
||||
second := httptest.NewRecorder()
|
||||
a.createFeedTaskOrder(second, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`))
|
||||
var again struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(second.Body.Bytes(), &again)
|
||||
if again.Data.ID != orderID {
|
||||
t.Fatalf("重复下单应当复用未支付的订单: %d vs %d", again.Data.ID, orderID)
|
||||
}
|
||||
// 别人的任务不能替他付。
|
||||
stranger := httptest.NewRecorder()
|
||||
a.createFeedTaskOrder(stranger, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`))
|
||||
if stranger.Code != 403 {
|
||||
t.Fatalf("HTTP %d", stranger.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the task is funded by the payment, and paying twice changes nothing", func(t *testing.T) {
|
||||
var status string
|
||||
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status)
|
||||
if status != "CREATED" {
|
||||
t.Fatalf("下单还没付款,任务不该进入大厅: %s", status)
|
||||
}
|
||||
paid := httptest.NewRecorder()
|
||||
a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``))
|
||||
if paid.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String())
|
||||
}
|
||||
var linked int64
|
||||
if err := db.QueryRow(`SELECT status,COALESCE(order_id,0) FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &linked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "ESCROWED" || linked != orderID {
|
||||
t.Fatalf("付款后任务应当托管并记住订单: status=%s order=%d", status, linked)
|
||||
}
|
||||
twice := httptest.NewRecorder()
|
||||
a.payOrder(twice, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``))
|
||||
if twice.Code != 200 {
|
||||
t.Fatalf("重复支付应当幂等: HTTP %d %s", twice.Code, twice.Body.String())
|
||||
}
|
||||
var orders int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orders); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orders != 1 {
|
||||
t.Fatalf("同一个任务不该产生 %d 笔订单", orders)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a refund releases an escrowed task but never a settled one", func(t *testing.T) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err != nil {
|
||||
t.Fatalf("退款失败: %v", err)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status string
|
||||
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status)
|
||||
if status != "REFUNDED" {
|
||||
t.Fatalf("退款后任务应当关闭: %s", status)
|
||||
}
|
||||
// 已结算的任务:钱已经在喂养者账上,不能靠一条退款通知悄悄冲掉。
|
||||
if _, err = db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err = db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err == nil {
|
||||
t.Fatal("已结算的任务不该能直接退款")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the sitter deposit is paid the same way and lands on the profile", func(t *testing.T) {
|
||||
early := httptest.NewRecorder()
|
||||
a.createDepositOrder(early, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`))
|
||||
if early.Code != 403 {
|
||||
t.Fatalf("没有喂养者资料时不该能交保证金: HTTP %d", early.Code)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO user_verifications(user_id,verification_type,real_name,status,evidence_json) VALUES(?,'real_name','李四','VERIFIED','[]')`, sitter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apply := httptest.NewRecorder()
|
||||
a.applySitter(apply, imTestRequest(sitter, "POST", "/api/v1/pet/sitter",
|
||||
`{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`))
|
||||
if apply.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", apply.Code, apply.Body.String())
|
||||
}
|
||||
order := httptest.NewRecorder()
|
||||
a.createDepositOrder(order, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`))
|
||||
if order.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", order.Code, order.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
AmountCent int64 `json:"amountCent"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(order.Body.Bytes(), &envelope)
|
||||
if envelope.Data.AmountCent != 20000 {
|
||||
t.Fatalf("保证金金额 = %d,应当是配置的 200 元", envelope.Data.AmountCent)
|
||||
}
|
||||
paid := httptest.NewRecorder()
|
||||
a.payOrder(paid, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``))
|
||||
if paid.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String())
|
||||
}
|
||||
var deposit int
|
||||
if err := db.QueryRow(`SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, sitter).Scan(&deposit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deposit != 20000 {
|
||||
t.Fatalf("保证金 = %d", deposit)
|
||||
}
|
||||
full := httptest.NewRecorder()
|
||||
a.createDepositOrder(full, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`))
|
||||
if full.Code != 400 {
|
||||
t.Fatalf("交满之后不该能重复下单: HTTP %d", full.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an operator can fund a task by hand, and it is written down", func(t *testing.T) {
|
||||
manual := seedTaskAwaitingPayment(t, a, db, owner)
|
||||
thin := httptest.NewRecorder()
|
||||
a.adminEscrowFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual), `{"note":"收到"}`))
|
||||
if thin.Code != 400 {
|
||||
t.Fatalf("手工托管必须写清楚钱怎么收的: HTTP %d", thin.Code)
|
||||
}
|
||||
ok := httptest.NewRecorder()
|
||||
a.adminEscrowFeedTask(ok, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual),
|
||||
`{"note":"灰度期线下微信收款,流水号 20260907001"}`))
|
||||
if ok.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
var status string
|
||||
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, manual).Scan(&status)
|
||||
if status != "ESCROWED" {
|
||||
t.Fatalf("手工托管后状态 = %s", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedTaskAwaitingPayment(t *testing.T, a *App, db *sql.DB, owner int64) int64 {
|
||||
t.Helper()
|
||||
petID := petWithPhotos(t, a, owner, fmt.Sprintf("手工%d", time.Now().UnixNano()%1000), 3)
|
||||
address := httptest.NewRecorder()
|
||||
a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
|
||||
`{"cityName":"深圳","areaText":"罗湖区","detail":"1 栋 101","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`))
|
||||
var addressEnvelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope)
|
||||
task := httptest.NewRecorder()
|
||||
a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
|
||||
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-05","endDate":"2026-10-05","visitsPerDay":1,
|
||||
"minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID)))
|
||||
if task.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", task.Code, task.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(task.Body.Bytes(), &envelope)
|
||||
return envelope.Data.ID
|
||||
}
|
||||
|
||||
// The rules that make a check-in evidence rather than a formality: it has to
|
||||
// happen in order, and the feeding photo cannot be taken in the same breath as
|
||||
// the arrival one.
|
||||
func TestPetCheckinDisciplineMySQL(t *testing.T) {
|
||||
db := isolatedIMDatabase(t)
|
||||
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
||||
a.config.ConfigEncryptionKey = strings.Repeat("k", 32)
|
||||
owner, sitter := int64(1), int64(2)
|
||||
taskID := seedTaskAwaitingPayment(t, a, db, owner)
|
||||
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visitID := firstVisit(t, db, taskID)
|
||||
checkin := func(step string, at time.Time) (int, string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`,
|
||||
step, step, at.Format(time.RFC3339))
|
||||
a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), body))
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if status, body := checkin("feed", now); status != 400 || !strings.Contains(body, "到达") {
|
||||
t.Fatalf("没到门口就先拍喂食,应当被拦下: HTTP %d %s", status, body)
|
||||
}
|
||||
if status, body := checkin("arrive", now); status != 200 {
|
||||
t.Fatalf("到达打卡失败: HTTP %d %s", status, body)
|
||||
}
|
||||
if status, body := checkin("feed", now.Add(20*time.Second)); status != 400 || !strings.Contains(body, "间隔") {
|
||||
t.Fatalf("到达后 20 秒就拍喂食,应当被拦下: HTTP %d %s", status, body)
|
||||
}
|
||||
if status, body := checkin("leave", now.Add(5*time.Minute)); status != 400 || !strings.Contains(body, "喂食") {
|
||||
t.Fatalf("还没喂就走,应当被拦下: HTTP %d %s", status, body)
|
||||
}
|
||||
if status, body := checkin("feed", now.Add(3*time.Minute)); status != 200 {
|
||||
t.Fatalf("间隔够了应当放行: HTTP %d %s", status, body)
|
||||
}
|
||||
if status, body := checkin("leave", now.Add(20*time.Minute)); status != 200 {
|
||||
t.Fatalf("离开打卡失败: HTTP %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Two promises the schema makes and the code has to keep: the sitter gets the
|
||||
// door number only while the job is live, and the plaintext goes away after it.
|
||||
func TestPetAddressLifecycleMySQL(t *testing.T) {
|
||||
db := isolatedIMDatabase(t)
|
||||
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
||||
a.config.ConfigEncryptionKey = strings.Repeat("k", 32)
|
||||
owner, sitter := int64(1), int64(2)
|
||||
|
||||
create := httptest.NewRecorder()
|
||||
a.createPetAddress(create, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
|
||||
`{"cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","contact":"张先生 13800000000","emergency":"李阿姨 13700000000","vetHospital":"科苑宠物医院","latitude":22.5411,"longitude":113.9345}`))
|
||||
if create.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", create.Code, create.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(create.Body.Bytes(), &envelope)
|
||||
addressID := envelope.Data.ID
|
||||
|
||||
petID := petWithPhotos(t, a, owner, "球球", 3)
|
||||
task := httptest.NewRecorder()
|
||||
a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
|
||||
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":1,
|
||||
"minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID)))
|
||||
if task.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", task.Code, task.Body.String())
|
||||
}
|
||||
var taskEnvelope struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope)
|
||||
taskID := taskEnvelope.Data.ID
|
||||
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var detail struct {
|
||||
Address map[string]any `json:"address"`
|
||||
}
|
||||
if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Address["emergency"] != "李阿姨 13700000000" || detail.Address["vetHospital"] != "科苑宠物医院" {
|
||||
t.Fatalf("上门的人要能拿到紧急联系人和医院: %+v", detail.Address)
|
||||
}
|
||||
|
||||
// 任务还在进行时,什么都不该被清掉。
|
||||
a.scheduleAddressPurge(context.Background())
|
||||
var purge sql.NullTime
|
||||
if err := db.QueryRow(`SELECT purge_at FROM pet_addresses WHERE id=?`, addressID).Scan(&purge); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if purge.Valid {
|
||||
t.Fatal("进行中的任务不该给地址排清除")
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scheduled := a.scheduleAddressPurge(context.Background()); scheduled != 1 {
|
||||
t.Fatalf("任务结束后应当排一条清除: %d", scheduled)
|
||||
}
|
||||
if purged := a.purgeDueAddresses(context.Background()); purged != 0 {
|
||||
t.Fatal("还没到期就不该清")
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE pet_addresses SET purge_at=DATE_SUB(NOW(3),INTERVAL 1 HOUR) WHERE id=?`, addressID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if purged := a.purgeDueAddresses(context.Background()); purged != 1 {
|
||||
t.Fatalf("到期应当清除: %d", purged)
|
||||
}
|
||||
var detailCipher []byte
|
||||
var hospital, area string
|
||||
if err := db.QueryRow(`SELECT detail_cipher,vet_hospital,area_text FROM pet_addresses WHERE id=?`, addressID).Scan(&detailCipher, &hospital, &area); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(detailCipher) != 0 || hospital != "" {
|
||||
t.Fatalf("明文应当被抹掉: detail=%d hospital=%q", len(detailCipher), hospital)
|
||||
}
|
||||
if area != "南山区" {
|
||||
t.Fatalf("街道要留着,历史记录还要显示: %q", area)
|
||||
}
|
||||
// 被清空的地址不能再拿来发新任务,否则喂养者会拿到一个空门牌。
|
||||
reuse := httptest.NewRecorder()
|
||||
a.createFeedTask(reuse, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
|
||||
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-11-01","endDate":"2026-11-01","visitsPerDay":1,
|
||||
"minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID)))
|
||||
if reuse.Code != 404 {
|
||||
t.Fatalf("清空后的地址不该能继续下单: HTTP %d %s", reuse.Code, reuse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -76,6 +77,12 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) {
|
||||
if parsed, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(req.CapturedAt)); parseErr == nil {
|
||||
captured = parsed
|
||||
}
|
||||
// 顺序与间隔:一次上门要按到达、喂食、离开走完,喂食离到达太近
|
||||
// 说明照片是一口气摆拍的,而不是真的照顾了一会儿。
|
||||
if message := a.checkinOrderError(r.Context(), visitID, req.Step, captured); message != "" {
|
||||
fail(w, http.StatusBadRequest, 20001, message)
|
||||
return
|
||||
}
|
||||
verdict := "ok"
|
||||
var distance sql.NullInt64
|
||||
if addressLat.Valid && addressLng.Valid && req.Latitude != 0 && req.Longitude != 0 {
|
||||
@@ -129,12 +136,52 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) {
|
||||
var owner int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner)
|
||||
if owner > 0 {
|
||||
a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", "本次上门有异常情况,请尽快查看照片与说明", "feed_task", taskID)
|
||||
body := "本次上门有异常情况,请尽快查看照片与说明"
|
||||
if note := strings.TrimSpace(req.Note); note != "" {
|
||||
body = note
|
||||
}
|
||||
a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", body, "feed_task", taskID)
|
||||
// 主人可能在开会、在飞机上,所以异常同时落日志,值班的人能看到。
|
||||
log.Printf("pet feed abnormal: task=%d visit=%d sitter=%d note=%q", taskID, visitID, sitter, strings.TrimSpace(req.Note))
|
||||
}
|
||||
}
|
||||
reply(w, map[string]any{"verdict": verdict, "distanceM": distance.Int64})
|
||||
}
|
||||
|
||||
// checkinOrderError returns the reason this step cannot be photographed yet, or
|
||||
// an empty string when it can.
|
||||
func (a *App) checkinOrderError(ctx context.Context, visitID int64, step string, captured time.Time) string {
|
||||
done := map[string]time.Time{}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT step,captured_at FROM pet_feed_checkins WHERE visit_id=?`, visitID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var at time.Time
|
||||
if rows.Scan(&name, &at) == nil {
|
||||
done[name] = at
|
||||
}
|
||||
}
|
||||
arrived, hasArrived := done["arrive"]
|
||||
if step != "arrive" && !hasArrived {
|
||||
return "请先在门口完成到达打卡"
|
||||
}
|
||||
if step == "leave" {
|
||||
if _, fed := done["feed"]; !fed {
|
||||
return "离开前请先完成喂食打卡"
|
||||
}
|
||||
}
|
||||
if step == "feed" && hasArrived {
|
||||
gap := a.configInt(ctx, "pet.feed_checkin_min_gap_seconds", 120)
|
||||
if captured.Sub(arrived) < time.Duration(gap)*time.Second {
|
||||
return fmt.Sprintf("到达后请至少间隔 %d 分钟再拍喂食照片", (gap+59)/60)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nullableFloat(value float64) sql.NullFloat64 {
|
||||
return sql.NullFloat64{Float64: value, Valid: value != 0}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 紧急联系人与常去的医院:喂养者在别人家里发现宠物不对劲时,
|
||||
-- 需要立刻能联系到人,而不是等主人看手机。和门牌一样加密存放,
|
||||
-- 只在选定喂养者之后随地址一起下发。
|
||||
ALTER TABLE pet_addresses
|
||||
ADD COLUMN emergency_cipher VARBINARY(512) NULL COMMENT '紧急联系人与电话,AES-GCM' AFTER contact_cipher,
|
||||
ADD COLUMN vet_hospital VARCHAR(200) NOT NULL DEFAULT '' COMMENT '常去的宠物医院' AFTER emergency_cipher;
|
||||
|
||||
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
|
||||
('pet.feed_checkin_min_gap_seconds','120','integer','到达与喂食打卡之间的最小间隔(秒),用于防止一次性摆拍'),
|
||||
('pet.address_purge_days','7','integer','任务结束后多少天抹掉地址明文')
|
||||
ON DUPLICATE KEY UPDATE description=VALUES(description);
|
||||
Reference in New Issue
Block a user