feat:添加光栅卡的选择

This commit is contained in:
zerosaturation 2026-05-17 19:03:34 +08:00
parent cf8671b5bb
commit 45d3adc373
34 changed files with 4946 additions and 398 deletions

28
.claude/settings.json Normal file
View File

@ -0,0 +1,28 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph update --skip-flows --repo \"/Users/liulujian/Documents/code/TopFansByGithub\" || true",
"timeout": 30
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph status --repo \"/Users/liulujian/Documents/code/TopFansByGithub\" || echo 'Not a git repo, skipping'",
"timeout": 10
}
]
}
]
}
}

View File

@ -4,7 +4,12 @@
"Skill(superpowers:subagent-driven-development)",
"Skill(superpowers:subagent-driven-development:*)",
"Bash(go build:*)",
"Bash(go vet:*)"
"Bash(go vet:*)",
"mcp__code-review-graph__semantic_search_nodes_tool"
]
}
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"code-review-graph"
]
}

View File

@ -0,0 +1,27 @@
---
name: Debug Issue
description: Systematically debug issues using graph-powered code navigation
---
## Debug Issue
Use the knowledge graph to systematically trace and debug issues.
### Steps
1. Use `semantic_search_nodes` to find code related to the issue.
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
3. Use `get_flow` to see full execution paths through suspected areas.
4. Run `detect_changes` to check if recent changes caused the issue.
5. Use `get_impact_radius` on suspected files to see what else is affected.
### Tips
- Check both callers and callees to understand the full context.
- Look at affected flows to find the entry point that triggers the bug.
- Recent changes are the most common source of new issues.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

View File

@ -0,0 +1,28 @@
---
name: Explore Codebase
description: Navigate and understand codebase structure using the knowledge graph
---
## Explore Codebase
Use the code-review-graph MCP tools to explore and understand the codebase.
### Steps
1. Run `list_graph_stats` to see overall codebase metrics.
2. Run `get_architecture_overview` for high-level community structure.
3. Use `list_communities` to find major modules, then `get_community` for details.
4. Use `semantic_search_nodes` to find specific functions or classes.
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
6. Use `list_flows` and `get_flow` to understand execution paths.
### Tips
- Start broad (stats, architecture) then narrow down to specific areas.
- Use `children_of` on a file to see all its functions and classes.
- Use `find_large_functions` to identify complex code.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

View File

@ -0,0 +1,28 @@
---
name: Refactor Safely
description: Plan and execute safe refactoring using dependency analysis
---
## Refactor Safely
Use the knowledge graph to plan and execute refactoring with confidence.
### Steps
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
5. After changes, run `detect_changes` to verify the refactoring impact.
### Safety Checks
- Always preview before applying (rename mode gives you an edit list).
- Check `get_impact_radius` before major refactors.
- Use `get_affected_flows` to ensure no critical paths are broken.
- Run `find_large_functions` to identify decomposition targets.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

View File

@ -0,0 +1,29 @@
---
name: Review Changes
description: Perform a structured code review using change detection and impact
---
## Review Changes
Perform a thorough, risk-aware code review using the knowledge graph.
### Steps
1. Run `detect_changes` to get risk-scored change analysis.
2. Run `get_affected_flows` to find impacted execution paths.
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
4. Run `get_impact_radius` to understand the blast radius.
5. For any untested changes, suggest specific test cases.
### Output Format
Provide findings grouped by risk level (high/medium/low) with:
- What changed and why it matters
- Test coverage status
- Suggested improvements
- Overall merge recommendation
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

4
.gitignore vendored
View File

@ -7,4 +7,6 @@ frontend/.hbuilderx/launch.json
.idea
node_modules
package-lock.json
package-lock.json
# Added by code-review-graph
.code-review-graph/

13
.mcp.json Normal file
View File

@ -0,0 +1,13 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"cwd": "/Users/liulujian/Documents/code/TopFansByGithub",
"type": "stdio"
}
}
}

38
CLAUDE.md Normal file
View File

@ -0,0 +1,38 @@
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.

View File

@ -219,20 +219,26 @@ restart_service() {
}
# 启动文件监听器
# 用法: start_watcher name dir binary port use_db
# 用法: start_watcher name dir1:dir2:dir3 binary port use_db
# 注意: 多个目录用冒号分隔
start_watcher() {
local name=$1
local dir=$2
local dirs=$2
local binary=$3
local port=$4
local use_db=$5
local watch_path="$SCRIPT_DIR/$dir"
local watch_paths=()
local watch_path=""
local restart_marker="/tmp/dev_sh_${name}_restart"
if [ ! -d "$watch_path" ]; then
echo -e "${RED}❌ 监听目录不存在: $watch_path${NC}"
return 1
fi
# 分割多个目录
IFS=':' read -ra watch_paths <<< "$dirs"
for d in "${watch_paths[@]}"; do
if [ ! -d "$SCRIPT_DIR/$d" ]; then
echo -e "${RED}❌ 监听目录不存在: $SCRIPT_DIR/$d${NC}"
return 1
fi
done
# 清理可能遗留的重启标记
rm -f "$restart_marker"
@ -240,20 +246,31 @@ start_watcher() {
(
if [[ "$(uname)" == "Darwin" ]]; then
# 排除: .git目录, 测试文件, 二进制文件(无扩展名), .exe, bin/目录
fswatch -r "$watch_path" \
--exclude='\.git' \
--exclude='_test\.go$' \
--exclude='\.exe$' \
--exclude='gateway$' \
--exclude='userService$' \
--exclude='assetService$' \
--exclude='socialService$' \
--exclude='galleryService$' \
--exclude='activityService$' \
--exclude='taskService$' \
--exclude='starbookService$'
local exclude_args=""
for d in "${watch_paths[@]}"; do
if [[ "$d" == "pkg/proto"* ]] || [[ "$d" == "proto"* ]]; then
# proto 目录监听所有 proto 文件变化
fswatch -r "$SCRIPT_DIR/$d" \
--exclude='\.git' \
--exclude='_test\.go$' \
--exclude='\.exe$' &
else
fswatch -r "$SCRIPT_DIR/$d" \
--exclude='\.git' \
--exclude='_test\.go$' \
--exclude='\.exe$' \
--exclude='gateway$' \
--exclude='userService$' \
--exclude='assetService$' \
--exclude='socialService$' \
--exclude='galleryService$' \
--exclude='activityService$' \
--exclude='taskService$' \
--exclude='starbookService$' &
fi
done
else
inotifywait -r -m -e modify,create,write "$watch_path" \
inotifywait -r -m -e modify,create,write "${watch_paths[@]}" \
--exclude='\.git' \
--exclude='_test\.go$' \
--exclude='\.exe$' \
@ -264,16 +281,9 @@ start_watcher() {
--exclude='galleryService$' \
--exclude='activityService$' \
--exclude='taskService$' \
--exclude='starbookService$'
fi | while read event; do
# 时间戳防抖:每次事件更新标记文件
# Darwin/Windows 不支持 date +%s%N使用 python 获取纳秒时间戳
if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
python3 -c 'import time; print(int(time.time()*1e9))' > "$restart_marker"
else
date +%s%N > "$restart_marker"
fi
done
--exclude='starbookService$' &
fi
wait
) &
local watcher_pid=$!
echo "$watcher_pid" >> /tmp/dev_sh_watchers.tmp
@ -297,7 +307,25 @@ start_watcher() {
if (( elapsed >= 500000000 )); then
# 距上次事件已过 300ms执行重启
rm -f "$restart_marker"
restart_service "$name" "$dir" "$binary" "$port" "$use_db"
# 检查是否包含 proto 目录变化,包含则先重新编译 proto
local proto_changed=false
for d in "${watch_paths[@]}"; do
if [[ "$d" == "pkg/proto"* ]] || [[ "$d" == "proto"* ]]; then
proto_changed=true
break
fi
done
if [ "$proto_changed" = true ]; then
echo -e "${YELLOW}🔄 [Proto 文件变化] 重新编译 Proto...${NC}"
build_proto
# proto 变化时重启 gatewaygateway 是 proto 客户端的调用方)
echo -e "${YELLOW}🔄 [Proto 变化] 重启 gateway...${NC}"
restart_service "gateway" "gateway" "gateway/gateway" "8080" "0"
fi
restart_service "$name" "${watch_paths[0]}" "$binary" "$port" "$use_db"
fi
done
) &
@ -328,6 +356,21 @@ for service in gateway userService socialService assetService galleryService act
done
sleep 1
# 编译 proto 文件
build_proto() {
echo -e "${YELLOW}📦 编译 Proto 文件...${NC}"
if [ -f "$SCRIPT_DIR/scripts/compile-proto.sh" ]; then
bash "$SCRIPT_DIR/scripts/compile-proto.sh"
else
echo -e "${RED}❌ compile-proto.sh 不存在,跳过${NC}"
fi
}
# 先编译 proto 文件
echo ""
echo -e "${YELLOW}🔨 预编译 Proto 文件...${NC}"
build_proto
# 先构建所有服务
echo ""
echo -e "${YELLOW}🔨 预编译所有服务...${NC}"
@ -361,13 +404,13 @@ start_service "gateway" "gateway/gateway" 8080
# 启动所有文件监听器
echo ""
echo -e "${YELLOW}👁️ 启动所有文件监听器...${NC}"
start_watcher "gateway" "gateway" "gateway/gateway" 8080 0
start_watcher "userService" "services/userService" "services/userService/userService" 20000 1
start_watcher "assetService" "services/assetService" "services/assetService/assetService" 20003 1
start_watcher "socialService" "services/socialService" "services/socialService/socialService" 20002 1
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
start_watcher "gateway" "gateway:pkg/proto" "gateway/gateway" 8080 0
start_watcher "userService" "services/userService" "services/userService/userService" 20000 1
start_watcher "assetService" "services/assetService:pkg/proto/asset" "services/assetService/assetService" 20003 1
start_watcher "socialService" "services/socialService" "services/socialService/socialService" 20002 1
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
echo ""
echo -e "${GREEN}========================================${NC}"

View File

@ -228,6 +228,51 @@ func (ctrl *AssetController) PreCreateMintOrder(c *gin.Context) {
response.Success(c, data)
}
// EstimateMintCost 估算铸造费用
// @Summary 估算铸造费用
// @Description 在用户确认铸造前,显示本次铸造消耗水晶数和当前余额
// @Tags assets
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/v1/assets/mints/cost-estimate [post]
func (ctrl *AssetController) EstimateMintCost(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
starID, exists := c.Get("star_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.assetService.EstimateMintCost(ctx, &pbAsset.EstimateMintCostRequest{})
if err != nil {
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
data := map[string]interface{}{
"cost_crystal": resp.CostCrystal,
"current_balance": resp.CurrentBalance,
"balance_after": resp.BalanceAfter,
"mint_count": resp.MintCount,
"next_tier_hint": resp.NextTierHint,
}
response.Success(c, data)
}
// CreateMintOrder 创建铸造订单
// @Summary 创建铸造订单
// @Description 创建一个新的数字藏品铸造订单
@ -1619,9 +1664,18 @@ func (ctrl *AssetController) GetAssetMaterials(c *gin.Context) {
return
}
userID, _ := c.Get("user_id")
starID, _ := c.Get("star_id")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 设置 Dubbo attachments
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.assetService.GetAssetMaterials(ctx, &pbAsset.GetAssetMaterialsRequest{
AssetId: assetID,
})

View File

@ -187,6 +187,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
assets.GET("/oss/batch-presigned-urls", assetCtrl.GetOSSBatchPresignedURLs) // 批量获取 OSS 预签名URL用于读取目录下的图片
assets.POST("/upload", assetCtrl.UploadImage) // 上传图片
assets.POST("/mints/precreate", assetCtrl.PreCreateMintOrder) // 阶段一:预创建铸造订单(返回 order_id
assets.POST("/mints/cost-estimate", assetCtrl.EstimateMintCost) // 估算铸造费用(显示消耗和余额)
assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单
assets.GET("/mints/:order_id", assetCtrl.GetMintOrder) // 查询铸造订单状态
assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单

File diff suppressed because it is too large Load Diff

View File

@ -91,6 +91,7 @@ type AssetService interface {
GetAssetMaterials(ctx context.Context, req *GetAssetMaterialsRequest, opts ...client.CallOption) (*GetAssetMaterialsResponse, error)
UpdateMaterialLayerOrder(ctx context.Context, req *UpdateMaterialLayerOrderRequest, opts ...client.CallOption) (*UpdateMaterialLayerOrderResponse, error)
UnbindAssetMaterial(ctx context.Context, req *UnbindAssetMaterialRequest, opts ...client.CallOption) (*UnbindAssetMaterialResponse, error)
EstimateMintCost(ctx context.Context, req *EstimateMintCostRequest, opts ...client.CallOption) (*EstimateMintCostResponse, error)
}
// NewAssetService constructs a client for the asset.AssetService service.
@ -265,9 +266,17 @@ func (c *AssetServiceImpl) UnbindAssetMaterial(ctx context.Context, req *UnbindA
return resp, nil
}
func (c *AssetServiceImpl) EstimateMintCost(ctx context.Context, req *EstimateMintCostRequest, opts ...client.CallOption) (*EstimateMintCostResponse, error) {
resp := new(EstimateMintCostResponse)
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "EstimateMintCost", opts...); err != nil {
return nil, err
}
return resp, nil
}
var AssetService_ClientInfo = client.ClientInfo{
InterfaceName: "topfans.asset.AssetService",
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial"},
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "EstimateMintCost"},
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
dubboCli := dubboCliRaw.(*AssetServiceImpl)
dubboCli.conn = conn
@ -295,6 +304,7 @@ type AssetServiceHandler interface {
GetAssetMaterials(context.Context, *GetAssetMaterialsRequest) (*GetAssetMaterialsResponse, error)
UpdateMaterialLayerOrder(context.Context, *UpdateMaterialLayerOrderRequest) (*UpdateMaterialLayerOrderResponse, error)
UnbindAssetMaterial(context.Context, *UnbindAssetMaterialRequest) (*UnbindAssetMaterialResponse, error)
EstimateMintCost(context.Context, *EstimateMintCostRequest) (*EstimateMintCostResponse, error)
}
func RegisterAssetServiceHandler(srv *server.Server, hdlr AssetServiceHandler, opts ...server.ServiceOption) error {
@ -594,5 +604,20 @@ var AssetService_ServiceInfo = server.ServiceInfo{
return triple_protocol.NewResponse(res), nil
},
},
{
Name: "EstimateMintCost",
Type: constant.CallUnary,
ReqInitFunc: func() interface{} {
return new(EstimateMintCostRequest)
},
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
req := args[0].(*EstimateMintCostRequest)
res, err := handler.(AssetServiceHandler).EstimateMintCost(ctx, req)
if err != nil {
return nil, err
}
return triple_protocol.NewResponse(res), nil
},
},
},
}

View File

@ -112,6 +112,23 @@ message CreateMintOrderResponse {
topfans.common.BaseResponse base = 1;
MintOrder order = 2; //
Asset asset = 3; //
int64 cost_crystal = 4; //
int64 balance_after = 5; //
}
//
message EstimateMintCostRequest {
string trace_id = 1; // ID
}
//
message EstimateMintCostResponse {
topfans.common.BaseResponse base = 1;
int64 cost_crystal = 2; //
int64 current_balance = 3; //
int64 balance_after = 4; //
int32 mint_count = 5; //
string next_tier_hint = 6; //
}
// ==================== ====================
@ -428,6 +445,14 @@ service AssetService {
};
}
//
rpc EstimateMintCost(EstimateMintCostRequest) returns (EstimateMintCostResponse) {
option (google.api.http) = {
post: "/api/v1/assets/mints/cost-estimate"
body: "*"
};
}
//
rpc GetMyAssets(GetMyAssetsRequest) returns (GetMyAssetsResponse) {
option (google.api.http) = {

View File

@ -816,3 +816,43 @@ func (p *AssetProvider) CancelMintOrder(ctx context.Context, req *pb.CancelMintO
return resp, nil
}
// EstimateMintCost 估算铸造费用
func (p *AssetProvider) EstimateMintCost(ctx context.Context, req *pb.EstimateMintCostRequest) (*pb.EstimateMintCostResponse, error) {
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
if err != nil {
return &pb.EstimateMintCostResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED,
Message: "user authentication required",
},
}, err
}
estimate, err := p.mintService.EstimateMintCost(userID, starID)
if err != nil {
logger.Logger.Error("EstimateMintCost failed",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return &pb.EstimateMintCostResponse{
Base: &pbCommon.BaseResponse{
Code: appErrors.ToStatusCode(err),
Message: err.Error(),
},
}, err
}
return &pb.EstimateMintCostResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "ok",
},
CostCrystal: estimate.CostCrystal,
CurrentBalance: estimate.CurrentBalance,
BalanceAfter: estimate.AfterBalance,
MintCount: estimate.MintCount,
NextTierHint: estimate.NextTierHint,
}, nil
}

View File

@ -2,7 +2,13 @@ package provider
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aliyun/credentials-go/credentials"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/models"
@ -121,6 +127,13 @@ func (p *AssetProvider) GetAssetMaterials(ctx context.Context, req *pb.GetAssetM
MaterialType: rel.MaterialType,
LayerOrder: int32(rel.LayerOrder),
}
// 如果关联的 Material 有 OssKey生成预签名 URL
if rel.Material.OssKey != "" {
signedURL, err := generatePresignedURL(rel.Material.OssKey, 3600)
if err == nil {
pbRel.MaterialUrlSigned = signedURL
}
}
if rel.PosX != nil {
pbRel.PosX = *rel.PosX
}
@ -197,3 +210,75 @@ func doublePtr(v float64) *float64 {
}
return &v
}
// generatePresignedURL 生成预签名 URL
func generatePresignedURL(filePath string, expiresInSeconds int64) (string, error) {
region := os.Getenv("OSS_REGION")
bucketName := os.Getenv("OSS_BUCKET_NAME")
roleArn := os.Getenv("OSS_STS_ROLE_ARN")
accessKeyID := os.Getenv("OSS_ACCESS_KEY_ID")
accessKeySecret := os.Getenv("OSS_ACCESS_KEY_SECRET")
if region == "" || bucketName == "" || roleArn == "" || accessKeyID == "" || accessKeySecret == "" {
return "", fmt.Errorf("OSS 配置不完整")
}
credConfig := new(credentials.Config).
SetType("ram_role_arn").
SetAccessKeyId(accessKeyID).
SetAccessKeySecret(accessKeySecret).
SetRoleArn(roleArn).
SetRoleSessionName("topfans-download-session").
SetPolicy("").
SetRoleSessionExpiration(int(expiresInSeconds))
provider, err := credentials.NewCredential(credConfig)
if err != nil {
return "", fmt.Errorf("创建凭证提供器失败: %w", err)
}
cred, err := provider.GetCredential()
if err != nil {
return "", fmt.Errorf("获取临时凭证失败: %w", err)
}
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
oss.SecurityToken(*cred.SecurityToken))
if err != nil {
return "", fmt.Errorf("创建OSS客户端失败: %w", err)
}
bucket, err := client.Bucket(bucketName)
if err != nil {
return "", fmt.Errorf("获取Bucket失败: %w", err)
}
ossKey := filePath
if strings.HasPrefix(filePath, "https://") {
parts := strings.SplitN(filePath, ".oss-", 2)
if len(parts) == 2 {
keyParts := strings.SplitN(parts[1], "/", 2)
if len(keyParts) == 2 {
ossKey = keyParts[1]
}
}
}
signedURL, err := bucket.SignURL(ossKey, oss.HTTPGet, expiresInSeconds)
if err != nil {
return "", fmt.Errorf("生成预签名URL失败: %w", err)
}
if idx := strings.Index(signedURL, "?"); idx >= 0 {
signedURL = strings.ReplaceAll(signedURL[:idx], "%2F", "/") + signedURL[idx:]
} else {
signedURL = strings.ReplaceAll(signedURL, "%2F", "/")
}
if !strings.Contains(signedURL, "security-token") && cred.SecurityToken != nil && *cred.SecurityToken != "" {
signedURL = signedURL + "&security-token=" + url.QueryEscape(*cred.SecurityToken)
}
return signedURL, nil
}

View File

@ -50,6 +50,9 @@ type MintService interface {
// GetUserMintCount 获取用户累计铸爱次数
GetUserMintCount(userID, starID int64) (int32, error)
// EstimateMintCost 估算铸造费用(不实际扣款)
EstimateMintCost(userID, starID int64) (*MintCostEstimate, error)
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error
}
@ -62,7 +65,7 @@ type mintService struct {
db *gorm.DB
config *config.AssetConfig
registryRepo starbookRepo.AssetRegistryRepository // 资产索引仓库(用于星册体系)
mintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
localMintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库
}
@ -74,7 +77,7 @@ func NewMintService(
db *gorm.DB,
cfg *config.AssetConfig,
registryRepo starbookRepo.AssetRegistryRepository,
mintCostRepo repository.MintCostRepository,
localMintCostRepo repository.MintCostRepository,
userMintCountRepo repository.UserMintCountRepository,
) MintService {
return &mintService{
@ -84,7 +87,7 @@ func NewMintService(
db: db,
config: cfg,
registryRepo: registryRepo,
mintCostRepo: mintCostRepo,
localMintCostRepo: localMintCostRepo,
userMintCountRepo: userMintCountRepo,
}
}
@ -230,7 +233,12 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
// 3. 使用事务创建铸造订单(或将阶段一订单推进到 PROCESSING
var mintOrder *models.MintOrder
var asset *models.Asset
var newBalance int64
var capturedCostCrystal int64 // 捕获铸造消耗
err = s.db.Transaction(func(tx *gorm.DB) error {
// 用局部变量捕获事务内获取的 newBalance
var capturedBalance int64
defer func() { newBalance = capturedBalance }()
// 3.0 取出阶段一订单,并校验状态/所有者
logger.Logger.Info("[MintOrder] Step 3.0: 获取订单", zap.String("order_id", req.OrderId), zap.Int64("user_id", userID), zap.Int64("star_id", starID))
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
@ -281,19 +289,21 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
// 3.1 获取铸造消耗配置(阶梯计价)
// 本次铸造是第 currentMintCount+1 次
logger.Logger.Info("[MintOrder] Step 3.1: 获取铸造配置", zap.Int32("current_mint_count", currentMintCount))
mintCost, err := s.GetMintCost(currentMintCount + 1)
var localMintCost *models.MintCostConfig
localMintCost, err = s.GetMintCost(currentMintCount + 1)
if err != nil {
logger.Logger.Error("[MintOrder] Step 3.1 失败", zap.Error(err))
return fmt.Errorf("获取铸造消耗配置失败: %w", err)
}
logger.Logger.Info("[MintOrder] Step 3.1 完成", zap.Int64("cost_crystal", mintCost.CostCrystal))
capturedCostCrystal = localMintCost.CostCrystal // 捕获铸造消耗
logger.Logger.Info("[MintOrder] Step 3.1 完成", zap.Int64("cost_crystal", localMintCost.CostCrystal))
// 3.2 扣除水晶余额(调用 User Service RPC
logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", -mintCost.CostCrystal))
logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", -localMintCost.CostCrystal))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintCost.CostCrystal,
capturedBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -localMintCost.CostCrystal,
"mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId))
if err != nil {
logger.Logger.Error("[MintOrder] Step 3.2 失败: 扣除水晶错误", zap.Error(err))
@ -304,17 +314,17 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
// 3.3 检查是否触发保底(概率触发)
var boostBps int32 = 0
if mintCost.Probability > 0 && mintCost.RewardValue > 0 {
if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 {
// 随机判断是否触发
randomValue := time.Now().UnixNano() % 100
if randomValue < mintCost.Probability {
boostBps = int32(mintCost.RewardValue) // reward_value 单位是 bps
if randomValue < localMintCost.Probability {
boostBps = int32(localMintCost.RewardValue) // reward_value 单位是 bps
logger.Logger.Info("Mint guarantee triggered",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int32("mint_count", currentMintCount+1),
zap.Int32("boost_bps", boostBps),
zap.Int64("probability", mintCost.Probability))
zap.Int64("probability", localMintCost.Probability))
}
}
@ -397,7 +407,7 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
updates := map[string]interface{}{
"asset_id": asset.ID,
"status": models.MintOrderStatusSuccess, // 直接设为成功,无需异步处理
"cost_crystal": mintCost.CostCrystal,
"cost_crystal": localMintCost.CostCrystal,
"error_message": nil,
"material_url": getStringValue(mintOrder.MaterialURL),
"name": getStringValue(mintOrder.Name),
@ -415,7 +425,7 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
assetID := asset.ID
mintOrder.AssetID = &assetID
mintOrder.Status = models.MintOrderStatusSuccess
mintOrder.CostCrystal = mintCost.CostCrystal
mintOrder.CostCrystal = localMintCost.CostCrystal
logger.Logger.Info("Mint order created",
zap.String("order_id", mintOrder.OrderID),
@ -446,20 +456,25 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
}
// 6. 构建响应
// 获取本次铸造消耗(从事务内捕获的值)
response := &pb.CreateMintOrderResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Order: ModelToProtoMintOrder(mintOrder),
Asset: ModelToProtoAssetDetail(asset, ownerNickname, false, 0, 0, 0), // 新创建的资产is_liked 为 falsedisplay_status 默认为 0earnings 和 exhibitionExpireAt 为 0
Order: ModelToProtoMintOrder(mintOrder),
Asset: ModelToProtoAssetDetail(asset, ownerNickname, false, 0, 0, 0), // 新创建的资产is_liked 为 falsedisplay_status 默认为 0earnings 和 exhibitionExpireAt 为 0
CostCrystal: capturedCostCrystal,
BalanceAfter: newBalance,
}
logger.Logger.Info("Create mint order successful",
zap.String("order_id", mintOrder.OrderID),
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Int64("cost_crystal", capturedCostCrystal),
zap.Int64("balance_after", newBalance),
)
return response, nil
@ -785,7 +800,7 @@ func (s *mintService) GetMintCost(mintCount int32) (*models.MintCostConfig, erro
mintCount = 10
}
config, err := s.mintCostRepo.GetByMintCount(mintCount)
config, err := s.localMintCostRepo.GetByMintCount(mintCount)
if err != nil {
logger.Logger.Error("Failed to get mint cost config",
zap.Int32("mint_count", mintCount),
@ -805,6 +820,70 @@ func (s *mintService) GetUserMintCount(userID, starID int64) (int32, error) {
return record.MintCount, nil
}
// EstimateMintCost 估算铸造费用(不实际扣款)
func (s *mintService) EstimateMintCost(userID, starID int64) (*MintCostEstimate, error) {
// 获取当前累计铸爱次数
currentMintCount, err := s.GetUserMintCount(userID, starID)
if err != nil {
logger.Logger.Warn("Failed to get user mint count, using 0",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
currentMintCount = 0
}
// 获取铸造消耗配置(本次铸造是第 currentMintCount+1 次)
localMintCost, err := s.GetMintCost(currentMintCount + 1)
if err != nil {
return nil, fmt.Errorf("获取铸造消耗配置失败: %w", err)
}
// 获取当前水晶余额
profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID)
var currentBalance int64 = 0
if err != nil {
logger.Logger.Warn("Failed to get fan profile, balance will be 0",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err))
} else {
currentBalance = profile.CrystalBalance
}
// 计算铸造后余额
afterBalance := currentBalance - localMintCost.CostCrystal
if afterBalance < 0 {
afterBalance = 0
}
// 下一阶梯费用提示
var nextTierHint string
nextMintCount := currentMintCount + 2 // 下一次铸造的次数
if nextMintCount <= 10 {
nextCost, err := s.GetMintCost(nextMintCount)
if err == nil {
nextTierHint = fmt.Sprintf("下一阶梯 %d 水晶", nextCost.CostCrystal)
}
}
return &MintCostEstimate{
CostCrystal: localMintCost.CostCrystal,
CurrentBalance: currentBalance,
AfterBalance: afterBalance,
MintCount: currentMintCount + 1, // 本次将是第几次铸造
NextTierHint: nextTierHint,
}, nil
}
// MintCostEstimate 铸造费用估算结果
type MintCostEstimate struct {
CostCrystal int64 // 本次铸造消耗水晶
CurrentBalance int64 // 当前余额(铸造前)
AfterBalance int64 // 铸造后余额
MintCount int32 // 本次铸造是第几次
NextTierHint string // 下一阶梯提示
}
// UpdateMintCountAndBoost 更新铸爱次数和收益提升
// 在事务内调用tx 为nil时会创建新事务
func (s *mintService) UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error {

View File

@ -0,0 +1,315 @@
# 光栅卡铸造后写入 asset_registry 方案
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 确保光栅卡铸造完成后1) 两张图片main 主图 + bg 背景图)都注册到 `materials`2) `asset_registry` 有记录3) `asset_material_relations` 正确绑定两组图片
**Architecture:**
- 前端在 `lenticular-result.vue``selectAsset` 中调用 `submitCraftMintFromPath` 完成上传和铸造
- `submitCraftMintFromPath` 已经支持:上传主图(main) + 背景图(bg)、注册素材、创建订单、绑定多素材
- 问题:`lenticular-result.vue` 引用了未定义的 `isCraftLenticular``craftCoverUrl`,导致铸造流程断裂
**Tech Stack:** Vue3 + uni-app 前端Go 后端Supabase/PostgreSQL
---
## 数据库设计解析
### 核心表关系
```
asset_registry ← 资产索引表(用户有哪些藏品)
│ asset_id
assets ← 资产表(名称、封面、描述)
│ id
asset_material_relations ← 资产-素材关联表(同一资产有哪些图片)
│ asset_id, material_id
materials ← 素材表OSS key、MIME、尺寸
```
### `asset_registry` 表结构(不存图片)
```sql
CREATE TABLE public.asset_registry (
id bigint,
asset_id bigint, -- 关联 assets.ID
asset_type varchar(20), -- 'regular'|'collection'|'activity'
owner_uid bigint,
star_id bigint,
grade integer,
status integer, -- 0=待处理, 1=已激活
like_count integer,
display_status integer,
created_at bigint,
updated_at bigint
);
```
**注意:** `asset_registry` 只存储资产索引和元数据,**不存储任何图片 URL**。
### 图片通过 `asset_material_relations` 关联
光栅卡有**两张图片**main 主图 + bg 背景图),通过 `asset_material_relations` 表实现多对一关联:
```sql
-- 同一个 asset_id = 123 的光栅卡,有两条素材记录
INSERT INTO asset_material_relations (asset_id, material_id, material_type, layer_order) VALUES
(123, 456, 'main', 0), -- 主图(人物层)
(123, 789, 'bg', 1); -- 背景图
-- materials 表存储实际图片信息
INSERT INTO materials (id, oss_key, original_name, file_size, mime_type, hash, ...) VALUES
(456, 'asset/7/88/main/xxx.jpg', 'subject.jpg', 102400, 'image/jpeg', 'abc123', ...),
(789, 'asset/7/88/bg/xxx.jpg', 'bg.png', 204800, 'image/png', 'def456', ...);
```
### 封面图来源
铸造时,前端传入的 `material_url`main 素材的 oss_key会被设为 `assets.CoverURL`,用于列表页展示:
```
craftMintSubmit.js → createMintOrderApi({ material_url: main_oss_key })
mint_service.go:339 assets.CoverURL = materialURLValue
前端列表页通过 assets.CoverURL 获取封面图
```
### 完整数据流
```
submitCraftMintFromPath({ imagePath, bgImagePath, formData })
├─ uploadImageAndRegisterMaterial(imagePath, 'main') → materials.id=456
├─ uploadImageAndRegisterMaterial(bgImagePath, 'bg') → materials.id=789
├─ createMintOrderApi({ material_url: main_oss_key })
│ ↓
│ mint_service.go
│ ├── tx.Create(asset) → CoverURL = main_oss_key
│ ├── tx.Create(registry) → asset_registry (status=1)
│ └── 更新订单状态为 SUCCESS
└─ bindAssetMaterialsApi(asset_id, [
{ material_id: 456, material_type: 'main', layer_order: 0 },
{ material_id: 789, material_type: 'bg', layer_order: 1 }
])
asset_material_relations 写入两条记录
```
---
## 问题分析
### 当前代码问题(`lenticular-result.vue` 第 596-623 行)
```javascript
const selectAsset = async () => {
// line 596-625 是完整代码块
const imagePath =
isCraftLenticular.value // ❌ isCraftLenticular 未定义
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || craftCoverUrl.value
: craftCoverUrl.value; // ❌ craftCoverUrl 未定义
if (!imagePath) { ... }
const bgImagePath = isCraftLenticular.value // ❌ isCraftLenticular 未定义
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
: undefined;
try {
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
uni.navigateTo({ url: '/pages/castlove/success' });
return; // line 623 return 后不会执行
} catch (e) { ... }
// 以下代码 dead codereturn 后的逻辑)
if (!isLenticularDisplay.value && selectedIndex.value === -1) { ... }
if (isUploading.value) { ... }
// ... 上传到 OSS 并创建订单的旧代码 ...
};
```
### 现有正确流程(`craftMintSubmit.js` 的 `submitCraftMintFromPath`
```javascript
// 第 187-306 行
export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) {
// 1. 获取 OSS 签名
// 2. 上传主图并注册为 'main' 素材
// 3. 如果是光栅卡,上传背景图并注册为 'bg' 素材
// 4. 创建铸造订单(后端自动写入 asset_registry
// 5. 绑定多素材到资产main + bg
// 6. 返回
}
```
铸造流程本身是正确的,问题在前端调用方。
---
## 文件结构
| 文件 | 修改类型 | 职责 |
|------|----------|------|
| `frontend/pages/castlove/lenticular/lenticular-result.vue` | 修改 | 修复 `selectAsset` 函数,添加缺失的 computed |
| `frontend/utils/craftMintSubmit.js` | 已有 | 铸造流程(已正确,无需修改) |
---
## Task 1: 修复 `lenticular-result.vue``selectAsset` 函数
**Files:**
- Modify: `frontend/pages/castlove/lenticular/lenticular-result.vue:595-625`
- [ ] **Step 1: 确认缺失的 import 并添加**
`<script setup>` 块顶部已有:
```javascript
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
import { CASTLOVE_FORM_KEY, GENERATED_IMAGES_KEY, ... } from '@/utils/castloveGenerationFlow.js';
```
需要检查并补充以下 import参考 `asset-detail.vue` 第 371 行):
```javascript
import { CRAFT_LENTICULAR_CN, CRAFT_TAG_LENTICULAR } from '@/utils/castloveMintForm.js';
import { STUDIO_LENTICULAR } from '@/utils/castloveGenerationFlow.js';
```
- [ ] **Step 2: 添加缺失的 computed 属性**
`craftFormData` 之后添加:
```javascript
// 光栅卡类型判断
const isCraftLenticular = computed(() =>
craftFormData.value?.studio_kind === STUDIO_LENTICULAR
);
// 光栅封面 URL优先取中间层
const craftCoverUrl = computed(() =>
lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
);
```
- [ ] **Step 3: 清理 dead codeline 625-719**
`selectAsset` 函数的 return 后存在大量永不执行的代码(旧的上传 + 创建订单流程),需要删除:
- 删除 `if (!isLenticularDisplay.value && selectedIndex.value === -1)` 分支
- 删除 `if (isUploading.value)` 分支
- 删除 `uni.getStorageSync(CASTLOVE_FORM_KEY)` 及后续所有旧代码
- 保留 try-catch 和错误处理
修复后 `selectAsset` 函数结构:
```javascript
const selectAsset = async () => {
// 1. 获取主图路径
const imagePath =
isCraftLenticular.value
? craftCoverUrl.value
: craftCoverUrl.value;
if (!imagePath) {
uni.showToast({ title: '缺少作品图', icon: 'none' });
return;
}
// 2. 获取背景图路径(仅光栅卡)
const bgImagePath = isCraftLenticular.value
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
: undefined;
console.log('[lenticular-result] handleCraftMint', {
isCraftLenticular: isCraftLenticular.value,
imagePath: imagePath?.substring(0, 50),
bgImagePath: bgImagePath?.substring(0, 50),
});
// 3. 调用铸造流程
uni.showLoading({ title: '铸造中…', mask: true });
try {
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
uni.hideLoading();
uni.navigateTo({ url: '/pages/castlove/success' });
} catch (e) {
uni.hideLoading();
uni.showToast({ title: e.message || '铸造失败', icon: 'none' });
}
};
```
- [ ] **Step 4: 运行验证**
检查 `selectAsset` 函数:
1. `isCraftLenticular``craftCoverUrl` 已正确定义
2. `bgImagePath` 正确取 `base` 层图片
3. `submitCraftMintFromPath` 调用参数正确
4. dead code 已删除
---
## Task 2: 验证铸造全链路
**Files:**
- Test: 手动测试 `lenticular-result.vue` 的铸造流程
- [ ] **Step 1: 检查后端是否自动写入 asset_registry**
后端 `mint_service.go` 第 369-387 行在创建订单时已自动写入 `asset_registry`
```go
registry := &models.AssetRegistry{
AssetID: asset.ID,
AssetType: models.AssetTypeRegular,
OwnerUID: userID,
StarID: starID,
Grade: &grade,
Status: models.AssetRegistryStatusActive,
// ...
}
tx.Create(registry)
```
- [ ] **Step 2: 确认 bindAssetMaterialsApi 调用后素材正确绑定**
`craftMintSubmit.js` 第 268-286 行已调用 `bindAssetMaterialsApi`
```javascript
const materialsToBind = [
{ material_id: mainResult.materialId, material_type: 'main', layer_order: 0 }
]
if (bgMaterialId) {
materialsToBind.push({ material_id: bgMaterialId, material_type: 'bg', layer_order: 1 })
}
bindAssetMaterialsApi(assetId, materialsToBind)
```
---
## Task 3: 检查清单确认
- [ ] `asset_registry` 有记录asset_type = 'regular'status = 1
- [ ] `assets.CoverURL` = main 素材的 oss_key用于列表封面
- [ ] `materials` 表有两条记录material_type = 'main' 和 'bg'
- [ ] `asset_material_relations` 有两条绑定记录(同一个 asset_idlayer_order 0 和 1
- [ ] 前端 `selectAsset` 无编译错误isCraftLenticular、craftCoverUrl 已定义)
- [ ] 铸造后跳转成功页 `/pages/castlove/success`
---
## 总结
**核心理解:**
- `asset_registry` 是**索引表**,不存图片 — 它通过 `asset_id` 关联到 `assets`,再通过 `asset_material_relations` 关联到 `materials`
- **同一组光栅卡图片**靠 `asset_material_relations.asset_id` 相同 + `material_type` 不同('main'/'bg')来区分
- **封面图**来自 `assets.CoverURL`(即 main 素材的 oss_key
**问题根因:** `lenticular-result.vue` 第 596-623 行的 `selectAsset` 函数引用了未定义的 `isCraftLenticular``craftCoverUrl`,且 return 后存在大量 dead code。
**修复方案:**
1. 添加 `isCraftLenticular``craftCoverUrl` computed
2. 清理 dead codereturn 后的旧上传/订单代码)
3. 确保 `bgImagePath` 传入 `base` 层图片给 `submitCraftMintFromPath`
**已正确部分:** `submitCraftMintFromPath` 已完整实现:上传主图 + 背景图 → 注册素材 → 创建订单 → 绑定多素材,后端自动写 `asset_registry`

View File

@ -5,9 +5,35 @@
<view v-if="title" class="modal-title">
<text>{{ title }}</text>
</view>
<view v-else class="modal-title">
<text class="modal-title-text">铸造确认</text>
</view>
<!-- 铸造费用信息 -->
<view v-if="costCrystal > 0 || currentBalance > 0" class="cost-info">
<view class="cost-row">
<text class="cost-label">本次消耗</text>
<text class="cost-value cost-crystal">{{ costCrystal }} 水晶</text>
</view>
<view class="cost-row">
<text class="cost-label">当前余额</text>
<text class="cost-value">{{ currentBalance }} 水晶</text>
</view>
<view class="cost-row">
<text class="cost-label">铸造后余额</text>
<text class="cost-value balance-after">{{ balanceAfter }} 水晶</text>
</view>
<view v-if="mintCount > 0" class="cost-row">
<text class="cost-label">铸造次数</text>
<text class="cost-value"> {{ mintCount }} </text>
</view>
<view v-if="nextTierHint" class="next-tier-hint">
<text>{{ nextTierHint }}</text>
</view>
</view>
<!-- 内容 -->
<view class="modal-body">
<view v-else-if="content" class="modal-body">
<text class="modal-content">{{ content }}</text>
</view>
@ -55,6 +81,27 @@ const props = defineProps({
showCancel: {
type: Boolean,
default: true
},
//
costCrystal: {
type: Number,
default: 0
},
currentBalance: {
type: Number,
default: 0
},
balanceAfter: {
type: Number,
default: 0
},
mintCount: {
type: Number,
default: 0
},
nextTierHint: {
type: String,
default: ''
}
})
@ -90,9 +137,12 @@ function handleCancel() {
.modal-box {
width: 560rpx;
background: #fff;
/* background: #fff; */
background-image: url('/static/starbookcontent/beijing.png');
background-size: cover;
background-position: center bottom;
border-radius: 24rpx;
overflow: hidden;
overflow: visible;
padding: 48rpx 40rpx 32rpx;
display: flex;
flex-direction: column;
@ -102,10 +152,66 @@ function handleCancel() {
.modal-title {
font-size: 34rpx;
font-weight: bold;
color: #333;
color: #fff;
margin-bottom: 32rpx;
}
.cost-info {
width: 100%;
margin-bottom: 32rpx;
}
.cost-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
/* border-bottom: 1rpx solid rgba(0, 0, 0, 0.05); */
}
.cost-row:last-child {
border-bottom: none;
}
.cost-label {
font-size: 28rpx;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
.cost-value {
font-size: 28rpx;
color: #fff;
font-weight: 500;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
.cost-crystal {
color: #fff;
font-weight: bold;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
.balance-after {
color: #fff;
}
.next-tier-hint {
margin-top: 16rpx;
padding: 12rpx 16rpx;
background: rgba(255, 107, 157, 0.1);
border-radius: 12rpx;
text-align: center;
}
.next-tier-hint text {
font-size: 24rpx;
color: #FF6B9D;
}
.modal-body {
display: flex;
flex-direction: column;

View File

@ -20,6 +20,8 @@
class="card-layer-img"
:src="layer.src"
mode="aspectFill"
@error="onImageError"
@load="onImageLoad"
/>
<template v-else>
<view
@ -60,13 +62,20 @@ const props = defineProps({
shimmerMidOpacity: { type: Number, default: 0.1 },
})
const emit = defineEmits(['simulate'])
/** Vue3 + unicreateSelectorQuery().in() 必须传组件 public 实例proxy传 internal instance 会运行时报错导致整页白屏 */
const pageProxy = getCurrentInstance()?.proxy
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)
/** Vue3 + unicreateSelectorQuery().in() 必须传组件 public 实例proxy传 internal instance 会运行时报错导致整页白屏 */
const pageProxy = getCurrentInstance()?.proxy
function onImageError(e) {
// console.log('[LenticularCard] image error:', e)
}
function onImageLoad(e) {
// console.log('[LenticularCard] image loaded')
}
const MOTION_HIDE_PX = 0.45
@ -100,7 +109,7 @@ const cardRotateStyle = computed(() => {
const x = tiltVisualX.value
const rotateY = (x / 120) * 12
return {
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
// transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
}
})
@ -109,7 +118,7 @@ const shimmerStyle = computed(() => {
const angle = 135 + (x / 120) * 60
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
return {
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
// background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
}
})
@ -118,11 +127,11 @@ function getLayerStyle(layer) {
const baseOpacity = t ? t.opacity : layer.opacity
const x = t ? t.x : 0
// mix-blend-mode / WebView normal
return {
opacity: baseOpacity,
transform: `translate3d(${x}px, 0, 0)`,
background: layer.background || 'transparent',
}
// return {
// opacity: baseOpacity,
// transform: `translate3d(${x}px, 0, 0)`,
// background: layer.background || 'transparent',
// }
}
function getDotStyle(dot) {
@ -217,22 +226,21 @@ onMounted(() => {
align-items: center;
justify-content: center;
perspective: 1000px;
position: relative;
}
.card-frame {
position: relative;
height: 100%;
max-height: 100%;
aspect-ratio: 3 / 4;
width: auto;
max-width: min(420px, 94vw);
width: 100%;
overflow: visible;
}
.card-body {
position: absolute;
inset: 0;
border-radius: 24px;
overflow: hidden;
/* border-radius: 24px; */
/* overflow: hidden; */
transform-style: preserve-3d;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
@ -242,10 +250,10 @@ onMounted(() => {
.card-layer {
position: absolute;
top: -6%;
left: -6%;
width: 112%;
height: 112%;
/* top: -6%;
left: -6%; */
width: 100%;
height: 100%;
will-change: transform, opacity;
background-size: cover;
background-position: center;

View File

@ -1,9 +1,9 @@
{
"name" : "TopFans",
"appid" : "__UNI__8CBE431",
"appid" : "__UNI__F199FF4",
"description" : "",
"versionName" : "1.0.4",
"versionCode" : 105,
"versionName" : "1.1.0",
"versionCode" : 100,
"transformPx" : false,
/* 5+App */
"app-plus" : {

View File

@ -162,6 +162,33 @@
}
}
},
{
"path": "pages/castlove/lenticular/lenticular-create",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/castlove/lenticular/lenticular-thinking",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/castlove/lenticular/lenticular-result",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/castlove/mall",
"style": {

View File

@ -180,7 +180,7 @@
<!-- 收益 -->
<view class="earnings-area">
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
<text class="earnings-text">{{ assetData.earnings || 0 }}/</text>
<text class="earnings-text">{{ assetData.earnings || 5 }}/</text>
</view>
<!-- 倒计时如有 -->
@ -318,7 +318,7 @@
<script setup>
import { ref, computed, onUnmounted } from 'vue';
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi } from '@/utils/api.js';
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
@ -628,7 +628,7 @@ const loadData = async () => {
isLiked.value = res.data.asset.is_liked || res.data.is_liked || false;
likeCount.value = asset.like_count || 0;
//
// material_relations
if (asset.material_relations || asset.materials) {
loadStickersForAsset(asset.material_relations || asset.materials)
}
@ -637,20 +637,35 @@ const loadData = async () => {
startCountdown();
}
console.log(res.data)
//
console.log('开始加载封面图片:', asset.cover_url);
getAssetCoverRealUrl(asset.cover_url).then(url => {
getAssetCoverRealUrl(asset.cover_url).then(async (url) => {
console.log('封面图片加载成功:', url);
coverUrl.value = url;
if (isLenticularAsset.value) {
const materialUrl = asset.material_url || url
// success.vue bindAssetMaterialsApi
let subjectUrl = url
let bgUrl = ''
//
try {
const parsed = JSON.parse(materialUrl)
if (parsed.main) subjectUrl = parsed.main
if (parsed.bg) bgUrl = parsed.bg
} catch (e) { /* 非 JSON 格式,按单 URL 处理 */ }
const materialsRes = await getAssetMaterialsApi(assetId)
console.log('getAssetMaterialsApi 响应:', materialsRes)
if (materialsRes.code === 200 && materialsRes.data) {
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || []
// bg main
for (const mat of materialsList) {
if (mat.material_type === 'main' && mat.material_url_signed) {
subjectUrl = mat.material_url_signed
}
if (mat.material_type === 'bg' && mat.material_url_signed) {
bgUrl = mat.material_url_signed
}
}
}
} catch (e) {
console.error('[asset-detail] 获取素材列表失败:', e)
}
lenticularLayers.value = bgUrl
? buildLenticularLayersTwo(bgUrl, subjectUrl)
: buildLenticularLayers(subjectUrl)
@ -848,7 +863,15 @@ onUnmounted(() => {
/* Header */
.header-bar {
position: fixed;
top: 32rpx;
/* top: 80rpx; */
/* #ifdef APP-PLUS */
/* App 通用(兜底) */
top: 96rpx;
/* #endif */
/* #ifdef H5 */
/* H5 使用普通高度 */
top: 16rpx;
/* #endif */
left: 32rpx;
z-index: 100;
}
@ -1011,7 +1034,7 @@ onUnmounted(() => {
.content-wrapper {
min-height: 90%;
padding: 104rpx 40rpx 80rpx;
padding: 200rpx 40rpx 80rpx;
display: flex;
flex-direction: column;
align-items: center;
@ -1044,9 +1067,11 @@ onUnmounted(() => {
left: 50%;
top: 50%;
width: 78%;
height: 82%;
height: 96%;
border-radius: 64rpx;
transform: translate(-50%, -50%) rotate(-10deg);
z-index: 2;
overflow: hidden;
}
.detail-lenticular-card {

View File

@ -68,7 +68,7 @@ export default {
selectedIndex: 2,
// 便
cardRoutes: {
'光栅卡': '/pages/castlove/create',
'光栅卡': '/pages/castlove/lenticular/lenticular-create',
'拍立得': '/pages/castlove/create',
'镭射卡': '/pages/castlove/create',
'撕拉片': '/pages/castlove/create',
@ -133,27 +133,25 @@ export default {
*/
onCardFrameTap(index) {
const card = this.cardList[index];
if (!card || card.comingSoon) {
if (!card) {
return;
}
const pos = this.getCardStackPosition(index);
//
//
if (pos === 2) {
if (card.name === '撕拉片') {
const route = this.cardRoutes[card.name];
if (route) {
uni.navigateTo({
url: '/pages/castlove/mint/tear-card',
});
}
} else {
if (card.name === '光栅卡') {
const route = this.cardRoutes[card.name];
if (route) {
uni.navigateTo({
url: `${route}?name=${encodeURIComponent(card.name)}`,
});
}
} else {
uni.showToast({
title: '激情开发中',
icon: 'none',
});
}
return;
}

View File

@ -0,0 +1 @@
/* 已移除单图上传相关样式 */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,361 @@
<template>
<view class="generation-loading">
<image class="background-image" src="/static/background/exhibitionSuccess.png" mode="aspectFill" />
<view class="gift-box">
<image class="gift-image" src="/static/nft/lihe.png" mode="aspectFit" />
</view>
<view class="progress-container">
<view class="progress-bar-wrapper">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
</view>
<view class="progress-icon" :style="{ left: progress + '%' }">
<view class="icon-circle"></view>
</view>
</view>
<text class="progress-text">{{ Math.round(progress) }}%</text>
<text class="loading-text">Thinking</text>
</view>
<!-- 镭射批量导出离屏 -->
<canvas
canvas-id="laserBatchCanvas"
class="laser-batch-canvas"
:style="{ width: laserCanvasW + 'px', height: laserCanvasH + 'px' }"
/>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { imageGenerationApi } from '@/utils/api.js';
import {
consumeGenerationFlowPayload,
FLOW_MODE_PREFILLED,
FLOW_MODE_LENTICULAR,
FLOW_MODE_LASER,
GENERATED_IMAGES_KEY,
GENERATION_REQUEST_KEY,
CASTLOVE_FORM_KEY,
persistLenticularPreviewMeta,
persistLaserPreviewImages,
} from '@/utils/castloveGenerationFlow.js';
import {
generateLaserVariantBatch,
LASER_BATCH_CANVAS_SIZE,
} from '@/utils/laser-card/laserBatchExport.js';
const progress = ref(0);
const laserCanvasW = LASER_BATCH_CANVAS_SIZE.w;
const laserCanvasH = LASER_BATCH_CANVAS_SIZE.h;
let progressTimer = null;
let generationData = null;
let flowStartedAt = 0;
//
const simulateProgress = () => {
progressTimer = setInterval(() => {
if (progress.value < 90) {
// 90%
const increment = Math.random() * 5 + 2;
progress.value = Math.min(90, progress.value + increment);
}
}, 500);
};
//
const stopProgress = () => {
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
};
//
const completeProgress = () => {
stopProgress();
const finalInterval = setInterval(() => {
if (progress.value < 100) {
progress.value = Math.min(100, progress.value + 5);
} else {
clearInterval(finalInterval);
//
setTimeout(() => {
handleSuccess();
}, 500);
}
}, 50);
};
// 退
const revertProgress = () => {
stopProgress();
const revertInterval = setInterval(() => {
if (progress.value > 0) {
progress.value = Math.max(0, progress.value - 10);
} else {
clearInterval(revertInterval);
// 退
setTimeout(() => {
uni.navigateBack();
}, 500);
}
}, 100);
};
const waitMinDuration = async (minMs) => {
if (!minMs || minMs <= 0) return;
const elapsed = Date.now() - flowStartedAt;
if (elapsed < minMs) {
await new Promise((r) => setTimeout(r, minMs - elapsed));
}
};
const handleSuccess = () => {
uni.showToast({ title: '生成成功', icon: 'success', duration: 1200 });
setTimeout(() => {
uni.navigateTo({ url: '/pages/castlove/lenticular/lenticular-result' });
}, 1200);
};
const runPrefilledFlow = async (flow) => {
try {
const images = flow.images || [];
if (!images.length) throw new Error('无候选图');
await waitMinDuration(flow.minDurationMs ?? 1400);
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(images));
completeProgress();
} catch (err) {
console.error('[GenerationLoading] prefilled', err);
uni.showToast({ title: err.message || '加载失败', icon: 'none' });
revertProgress();
}
};
const runLenticularFlow = async (flow) => {
try {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
if (!formStr) throw new Error('缺少表单数据');
const formData = JSON.parse(formStr);
await waitMinDuration(flow.minDurationMs ?? 1600);
persistLenticularPreviewMeta(formData);
completeProgress();
} catch (err) {
console.error('[GenerationLoading] lenticular', err);
uni.showToast({ title: err.message || '光栅预览失败', icon: 'none' });
revertProgress();
}
};
const runLaserFlow = async (flow) => {
try {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
if (!formStr) throw new Error('缺少表单数据');
const formData = JSON.parse(formStr);
const imagePath = formData.image || formData.uploadedImage || '';
if (!imagePath) throw new Error('缺少上传图片');
const paths = await generateLaserVariantBatch(imagePath, 'laserBatchCanvas');
await waitMinDuration(flow.minDurationMs ?? 1600);
persistLaserPreviewImages(paths);
completeProgress();
} catch (err) {
console.error('[GenerationLoading] laser', err);
uni.showToast({ title: err.message || '镭射生成失败', icon: 'none' });
revertProgress();
}
};
const callImageGeneration = async () => {
try {
const res = await imageGenerationApi(generationData);
if (res.data?.images?.length > 0) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(res.data.images));
completeProgress();
} else {
uni.showToast({ title: '未生成图片', icon: 'none' });
revertProgress();
}
} catch (err) {
console.error('[GenerationLoading] API', err);
uni.showToast({ title: err.message || '生成失败', icon: 'none' });
revertProgress();
}
};
onMounted(() => {
flowStartedAt = Date.now();
simulateProgress();
try {
const flow = consumeGenerationFlowPayload();
if (flow?.mode === FLOW_MODE_LENTICULAR) {
runLenticularFlow(flow);
return;
}
if (flow?.mode === FLOW_MODE_LASER) {
runLaserFlow(flow);
return;
}
if (flow?.mode === FLOW_MODE_PREFILLED) {
runPrefilledFlow(flow);
return;
}
const requestDataStr = uni.getStorageSync(GENERATION_REQUEST_KEY);
if (!requestDataStr) {
uni.showToast({ title: '数据错误', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
return;
}
generationData = JSON.parse(requestDataStr);
const castloveDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
if (castloveDataStr) {
const castloveData = JSON.parse(castloveDataStr);
if (generationData.subject_reference?.[0]) {
generationData.subject_reference[0].image_file =
castloveData.imageBase64 ||
castloveData.lenticularSubjectBase64 ||
'';
}
}
uni.removeStorageSync(GENERATION_REQUEST_KEY);
callImageGeneration();
} catch (e) {
console.error('[GenerationLoading] mount', e);
uni.showToast({ title: '数据错误', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
}
});
</script>
<style scoped>
.generation-loading {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: #000;
}
.laser-batch-canvas {
position: fixed;
left: -9999px;
top: -9999px;
opacity: 0;
pointer-events: none;
}
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.gift-box {
position: absolute;
top: 25%;
left: 50%;
transform: translateX(-50%);
width: 480rpx;
height: 480rpx;
z-index: 2;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateX(-50%) translateY(0) rotate(0deg);
}
50% {
transform: translateX(-50%) translateY(-20rpx) rotate(2deg);
}
}
.gift-image {
width: 100%;
height: 100%;
}
.progress-container {
position: absolute;
bottom: 25%;
left: 50%;
transform: translateX(-50%);
width: 600rpx;
display: flex;
flex-direction: column;
align-items: center;
z-index: 3;
}
.progress-bar-wrapper {
position: relative;
width: 100%;
height: 40rpx;
margin-bottom: 20rpx;
}
.progress-bar {
width: 100%;
height: 40rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 20rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.5);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #ff6b9d 0%, #ffa8c5 50%, #ffb6d9 100%);
border-radius: 20rpx;
transition: width 0.3s ease;
}
.progress-icon {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 60rpx;
height: 60rpx;
transition: left 0.3s ease;
}
.icon-circle {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #ffb6d9 0%, #ff6b9d 100%);
border-radius: 50%;
border: 4rpx solid #ffffff;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
.progress-text {
font-size: 56rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 10rpx;
}
.loading-text {
font-size: 36rpx;
color: #ffffff;
letter-spacing: 6rpx;
font-weight: 500;
}
</style>

View File

@ -7,7 +7,7 @@
<view class="background-overlay"></view>
<!-- Header组件 -->
<Header :showBack="true" :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" backIconColor="#e6e6e6" />
<!-- <Header :showBack="true" :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" backIconColor="#e6e6e6" /> -->
<!-- 内容区域 -->
<view class="content-wrapper">
@ -18,12 +18,31 @@
<!-- 藏品展示区域 -->
<view class="nft-display-section">
<NftCard
:cover-image="nftData.image"
:width="280"
:height="280"
:custom-style="nftCardStyle"
/>
<!-- 光栅卡单张工作台预览陀螺仪 -->
<view v-if="isLenticular" class="lenticular-result-wrap">
<view class="lenticular-result-card">
<view class="card-wrapper craft-card-wrapper">
<image
class="card-frame"
src="/static/square/gerenzhongxincangpinkuang.png"
mode="aspectFit"
/>
<view class="craft-lenticular-slot">
<LenticularCard
v-if="lenticularLayers.length > 0"
class="craft-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
tilt-hint-text="晃动查看"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
</view>
</view>
</view>
</view>
<!-- 铸造成功角色图片 -->
@ -48,6 +67,9 @@ import { ref, onMounted } from 'vue';
import { onUnload } from '@dcloudio/uni-app';
import Header from '../components/Header.vue';
import NftCard from '../components/NftCard.vue';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import { buildLenticularLayersTwo } from '@/utils/castloveMintForm.js';
//
const nftData = ref({
@ -55,9 +77,24 @@ const nftData = ref({
name: '',
event: '',
remark: '',
materialType: ''
materialType: '',
is_lenticular: false,
bg_image: '',
});
//
const isLenticular = ref(false)
//
const lenticularLayers = ref([])
const {
layerTransforms,
simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers);
// NftCard
const nftCardStyle = {
position: 'relative',
@ -77,8 +114,16 @@ onMounted(() => {
name: data.name || '未命名藏品',
event: data.event || '',
remark: data.remark || '',
materialType: data.materialType || ''
materialType: data.materialType || '',
is_lenticular: data.is_lenticular || false,
bg_image: data.bg_image || '',
};
//
if (data.is_lenticular && data.image && data.bg_image) {
isLenticular.value = true
lenticularLayers.value = buildLenticularLayersTwo(data.bg_image, data.image)
scheduleTiltStart()
}
}
} catch (e) {
console.error('读取藏品数据失败:', e);
@ -89,6 +134,15 @@ onMounted(() => {
}
});
onUnload(() => {
stopTiltPreview()
try {
uni.removeStorageSync('temp_nft_data');
} catch (e) {
/* noop */
}
});
// temp_nft_data order_id
const handleViewDetails = () => {
let orderId = '';
@ -212,6 +266,20 @@ onUnload(() => {
animation: zoomIn 0.8s ease-out 0.2s both;
}
/* 光栅卡容器 */
.lenticular-card-wrap {
width: 420rpx;
height: 560rpx;
display: flex;
align-items: center;
justify-content: center;
}
.lenticular-preview {
width: 420rpx;
height: 560rpx;
}
@keyframes zoomIn {
from {
opacity: 0;
@ -296,4 +364,54 @@ onUnload(() => {
opacity: 0.9;
transform: scale(0.98);
}
/* 光栅卡样式 */
.lenticular-result-wrap {
display: flex;
justify-content: center;
z-index: 8;
}
.lenticular-result-card {
display: flex;
flex-direction: column;
align-items: center;
}
.lenticular-result-card .card-wrapper {
position: relative;
width: 352rpx;
height: 520rpx;
}
.lenticular-result-card .craft-card-wrapper {
margin-bottom: 32rpx;
}
.lenticular-result-card .card-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
transform: rotate(-10deg);
}
.lenticular-result-card .craft-lenticular-slot {
position: absolute;
left: 50%;
top: 50%;
width: 87%;
height: 96%;
border-radius: 48rpx;
transform: translate(-50%, -50%) rotate(-10deg);
z-index: 2;
overflow: hidden;
}
.lenticular-result-card .craft-lenticular-card {
width: 100%;
height: 100%;
}
</style>

View File

@ -558,6 +558,14 @@ export function getMintOrderDetailApi(orderId) {
})
}
// 估算铸造费用(显示消耗和余额)
export function estimateMintCostApi() {
return request({
url: '/api/v1/assets/mints/cost-estimate',
method: 'POST'
})
}
// 上传素材(返回 material_id
export function uploadMaterialApi(data) {
return request({
@ -576,6 +584,14 @@ export function bindAssetMaterialsApi(assetId, materials) {
})
}
// 获取资产素材列表
export function getAssetMaterialsApi(assetId) {
return request({
url: `/api/v1/assets/${assetId}/materials`,
method: 'GET'
})
}
// 图生图
export function imageGenerationApi(params) {
return request({

View File

@ -1,3 +1,5 @@
import { getOssPresignedUrlApi } from '@/utils/api.js'
/**
* 从URL提取文件名
* @param {String} url - URL字符串
@ -64,8 +66,8 @@ export function extractOssObjectPath(url) {
}
/**
* 获取藏品封面的真实URL - 直接使用后端返回的URL
* @param {String} coverUrl - 后端返回的cover_url
* 获取藏品封面的真实URL - 使用 OSS 预签名 URL
* @param {String} coverUrl - 后端返回的cover_url相对路径或完整URL
* @returns {Promise<String>} 真实可访问的URL
*/
export async function getAssetCoverRealUrl(coverUrl) {
@ -77,13 +79,27 @@ export async function getAssetCoverRealUrl(coverUrl) {
return coverUrl || DEFAULT_IMAGE;
}
// 直接使用后端返回的 URL
return coverUrl;
// 如果是完整的绝对URL包含 ://),直接返回
if (coverUrl.includes('://')) {
return coverUrl;
}
// 相对路径调用预签名接口获取可访问的URL
try {
const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset');
if (res && res.data && res.data.url) {
return res.data.url;
}
return coverUrl;
} catch (e) {
console.error('[assetImageHelper] get presigned url failed:', e);
return coverUrl;
}
}
/**
* 获取好友头像的真实URL - 直接使用后端返回的URL
* @param {String} avatarUrl - 后端返回的avatar_url完整URL
* 获取好友头像的真实URL - 使用 OSS 预签名 URL
* @param {String} avatarUrl - 后端返回的avatar_url
* @returns {Promise<String>} 真实可访问的URL失败返回空字符串
*/
export async function getFriendAvatarRealUrl(avatarUrl) {
@ -92,6 +108,21 @@ export async function getFriendAvatarRealUrl(avatarUrl) {
return avatarUrl || '';
}
// 直接使用后端返回的 URL
return avatarUrl;
}
// 如果是完整的绝对URL包含 :://),直接返回
if (avatarUrl.includes('://')) {
return avatarUrl;
}
// 相对路径调用预签名接口获取可访问的URL
try {
const res = await getOssPresignedUrlApi(avatarUrl, 3600, 'avatar');
if (res && res.data && res.data.url) {
return res.data.url;
}
// 预签名失败,返回原始路径
return avatarUrl;
} catch (e) {
console.error('[assetImageHelper] get avatar presigned url failed:', e);
return avatarUrl;
}
}

View File

@ -1,5 +1,8 @@
/**
* 铸爱 统一Thinking 选择 详情确认 铸造路由与 Storage
*
* 本模块管理铸爱castlove生成流程的页面跳转数据持久化和状态管理
* 支持四种模式API生成预填充选择光栅工作室镭射工作室
*/
import {
@ -7,29 +10,61 @@ import {
CASTLOVE_LASER_ENTRY_KEY,
} from '@/utils/castloveMintForm.js'
// ========== Storage Keys ==========
/** 生成流程的完整Payload包含mode、images等 */
export const GENERATION_FLOW_KEY = 'generation_flow_payload'
/** AI生成请求的数据prompt、model、aspect_ratio等 */
export const GENERATION_REQUEST_KEY = 'generation_request_data'
/** 已生成的图片列表预填充或AI生成的结果 */
export const GENERATED_IMAGES_KEY = 'generated_images'
/** 生成结果的元数据displayMode、imageCount */
export const GENERATION_RESULT_META_KEY = 'generation_result_meta'
/** 铸爱表单数据craft_name、typeName等 */
export const CASTLOVE_FORM_KEY = 'castlove_form_data'
/** 用户在选择界面选中的图片 */
export const CRAFT_SELECTED_IMAGE_KEY = 'craft_selected_image'
/** 用户在选择界面选中的图片索引 */
export const CRAFT_SELECTED_INDEX_KEY = 'craft_selected_index'
// ========== Flow Modes ==========
/** 模式API生成AI生成四图 */
export const FLOW_MODE_API = 'api'
/** 模式:预填充(用户已有所需图片,直接进入选择) */
export const FLOW_MODE_PREFILLED = 'prefilled'
/** 模式光栅工作室工作台生成不走AI四图 */
export const FLOW_MODE_LENTICULAR = 'lenticular'
/** 模式镭射工作室工作台生成不走AI四图 */
export const FLOW_MODE_LASER = 'laser'
// ========== After Select Actions ==========
/** 选择后的动作:直接进入铸造 */
export const AFTER_SELECT_MINT = 'mint'
/** 选择后的动作:进入详情确认 */
export const AFTER_SELECT_DETAIL = 'detail'
// ========== Studio Types ==========
/** 工作室类型:光栅 */
export const STUDIO_LENTICULAR = 'lenticular'
/** 工作室类型:镭射 */
export const STUDIO_LASER = 'laser'
// ========== Page URLs ==========
const LOADING_URL = '/pages/discover/generation-loading'
const RESULT_URL = '/pages/discover/generation-result'
const ASSET_DETAIL_URL = '/pages/asset-detail/asset-detail'
const LENTICULAR_THINKING_URL = '/pages/castlove/lenticular/lenticular-thinking'
// const ASSET_DETAIL_URL = '/pages/asset-detail/asset-detail'
// ========== Core Functions ==========
/**
* 将图片数组补足到最少指定数量
* - 如果图片数量已达到最低要求直接返回前minCount张
* - 否则循环追加图片直到达到最低数量用于保持UI四宫格布局
*
* @param {Array} images - 图片数组
* @param {number} minCount - 最少图片数量默认4
* @returns {Array} 补足后的图片数组
*/
export function padImagesForSelection(images, minCount = 4) {
if (!Array.isArray(images) || images.length === 0) {
return []
@ -39,17 +74,55 @@ export function padImagesForSelection(images, minCount = 4) {
}
const out = [...images]
while (out.length < minCount) {
// 循环追加:用已有图片填充空白位置
out.push(images[out.length % images.length])
}
return out
}
/**
* 将表单数据持久化到Storage
* 用于在页面跳转过程中保存用户填写的表单信息
*
* @param {Object|null} formData - 表单数据对象
*/
function persistFormData(formData) {
if (formData != null) {
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
try {
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
} catch (e) {
console.error('persistFormData failed:', e)
if (e.name === 'QuotaExceededError' || e.message?.includes('quota')) {
return {
success: false,
error: {
title: '存储空间不足',
content: '图片数据过大,请尝试压缩图片后重新提交'
}
}
} else {
return {
success: false,
error: {
title: '存储失败',
content: '表单数据保存失败,请重试'
}
}
}
}
}
return { success: true }
}
/**
* enrich丰富表单数据添加流程控制字段
* - generation_after: 选择后的动作mint/detail
* - studio_kind: 工作室类型lenticular/laser
*
* @param {Object|null} formData - 原始表单数据
* @param {Object} options - { afterSelect, studioKind }
* @returns {Object} 丰富后的表单数据
*/
function enrichFormData(formData, { afterSelect, studioKind } = {}) {
const next = { ...(formData || {}) }
if (afterSelect) {
@ -61,14 +134,26 @@ function enrichFormData(formData, { afterSelect, studioKind } = {}) {
return next
}
/**
* 将图片引用转换为可用的本地路径或Base64
* 支持三种形式
* 1. data:URL格式 直接返回base64
* 2. http/https URL 下载到本地临时文件
* 3. 本地路径 直接返回path
*
* @param {string} src - 图片引用URL路径或base64
* @returns {Promise<{path: string, base64: string}>}
*/
export function materializeImageRef(src) {
const s = String(src || '').trim()
if (!s) {
return Promise.resolve({ path: '', base64: '' })
}
// data:URL 格式(如 data:image/png;base64,XXXX
if (s.startsWith('data:')) {
return Promise.resolve({ path: '', base64: s })
}
// 网络URL需要下载
if (s.startsWith('http://') || s.startsWith('https://')) {
return new Promise((resolve, reject) => {
uni.downloadFile({
@ -84,13 +169,33 @@ export function materializeImageRef(src) {
})
})
}
// 本地路径
return Promise.resolve({ path: s, base64: '' })
}
/**
* 导航到加载页面生成中动画
*/
function navigateToLoading() {
uni.navigateTo({ url: LOADING_URL })
}
/**
* 启动AI图片生成流程
* 1. 构建生成请求数据promptmodelaspect_ratio等
* 2. 保存表单数据和请求数据到Storage
* 3. 设置流程模式为API模式
* 4. 跳转到加载页面
*
* @param {Object} options
* @param {string} options.prompt - 生成提示词
* @param {Object} options.formData - 表单数据craft_name等
* @param {number} options.n - 生成数量默认4
* @param {string} options.model - 模型默认'image-01'
* @param {string} options.aspectRatio - 宽高比默认'16:9'
* @param {string} options.afterSelect - 选择后动作默认mint
* @param {string} options.studioKind - 工作室类型
*/
export function startAiImageGenerationFlow({
prompt,
formData,
@ -122,14 +227,36 @@ export function startAiImageGenerationFlow({
navigateToLoading()
}
/** 光栅 / 镭射:工作台生成预览,不走通用 AI 四图 */
/**
* 导航到对应类型的加载页面生成中动画
* @param {string} studioKind - 工作室类型lenticular/laser
*/
function navigateToStudioLoading(studioKind) {
if (studioKind === STUDIO_LENTICULAR) {
uni.navigateTo({ url: LENTICULAR_THINKING_URL })
} else {
uni.navigateTo({ url: LOADING_URL })
}
}
/**
* 启动工作台生成流程光栅/镭射
* 与AI生成流程不同工作台生成不走通用AI四图而是直接在工作台生成预览
*
* @param {Object} options
* @param {Object} options.formData - 表单数据
* @param {string} options.studioKind - 工作室类型lenticular/laser
*/
export function startCraftGenerationFlow({ formData, studioKind }) {
const merged = enrichFormData(formData, {
afterSelect: AFTER_SELECT_DETAIL,
afterSelect: AFTER_SELECT_DETAIL, // 工作台模式默认选择后进入详情
studioKind,
})
persistFormData(merged)
uni.removeStorageSync(GENERATION_REQUEST_KEY)
const result = persistFormData(merged)
if (!result.success) {
return result // { success: false, error: { title, content } }
}
uni.removeStorageSync(GENERATION_REQUEST_KEY) // 不需要AI请求数据
const mode =
studioKind === STUDIO_LENTICULAR ? FLOW_MODE_LENTICULAR : FLOW_MODE_LASER
uni.setStorageSync(
@ -139,12 +266,24 @@ export function startCraftGenerationFlow({ formData, studioKind }) {
studioKind,
afterSelect: AFTER_SELECT_DETAIL,
craft: merged?.craft_name || merged?.typeName || '',
minDurationMs: 1600,
minDurationMs: 1600, // 至少展示1600ms的加载动画
})
)
navigateToLoading()
navigateToStudioLoading(studioKind)
}
/**
* 启动预填充选择流程用户已有所需图片
* 用户从相册或历史记录选择图片直接进入选择界面
*
* @param {Object} options
* @param {Array} options.images - 图片数组
* @param {Object} options.formData - 表单数据
* @param {number} options.minDurationMs - 加载动画最少展示时间默认1400ms
* @param {boolean} options.padToFour - 是否补足到4张默认true
* @param {string} options.afterSelect - 选择后动作默认mint
* @param {string} options.studioKind - 工作室类型
*/
export function startPrefilledSelectionFlow({
images,
formData,
@ -158,7 +297,10 @@ export function startPrefilledSelectionFlow({
throw new Error('startPrefilledSelectionFlow: images 不能为空')
}
const merged = enrichFormData(formData, { afterSelect, studioKind })
persistFormData(merged)
const result = persistFormData(merged)
if (!result.success) {
return result
}
uni.removeStorageSync(GENERATION_REQUEST_KEY)
uni.setStorageSync(
GENERATION_FLOW_KEY,
@ -174,6 +316,12 @@ export function startPrefilledSelectionFlow({
navigateToLoading()
}
/**
* 持久化光栅工作室预览元数据
* 在进入光栅结果页面前调用保存背景图主体图材质等信息
*
* @param {Object} formData - 包含lenticularBgImagelenticularSubjectImage等字段
*/
export function persistLenticularPreviewMeta(formData) {
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
@ -194,6 +342,12 @@ export function persistLenticularPreviewMeta(formData) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify([{ type: 'lenticular' }]))
}
/**
* 持久化镭射工作室预览图片
* 在进入镭射结果页面前调用保存生成的图片路径列表
*
* @param {Array} paths - 镭射生成的图片路径数组
*/
export function persistLaserPreviewImages(paths) {
uni.setStorageSync(
GENERATION_RESULT_META_KEY,
@ -202,6 +356,18 @@ export function persistLaserPreviewImages(paths) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(paths))
}
/**
* 完成图片选择并跳转到详情页
* 1. 将选中图片转换为可用的本地引用
* 2. 保存选中图片和索引到Storage
* 3. 根据工作室类型保存特定的元数据
* 4. 跳转到资产详情页
*
* @param {Object} options
* @param {string} options.selectedImage - 用户选中的图片
* @param {number} options.selectedIndex - 用户选中的图片索引
* @param {Object} options.formData - 表单数据包含studio_kind等
*/
export async function completeSelectionAndOpenDetail({
selectedImage,
selectedIndex,
@ -212,6 +378,7 @@ export async function completeSelectionAndOpenDetail({
uni.setStorageSync(CRAFT_SELECTED_IMAGE_KEY, storedImage)
uni.setStorageSync(CRAFT_SELECTED_INDEX_KEY, String(selectedIndex ?? 0))
// 根据工作室类型保存特定的预览元数据
if (formData?.studio_kind === STUDIO_LENTICULAR) {
const subjectRef = storedImage
uni.setStorageSync(
@ -241,23 +408,48 @@ export async function completeSelectionAndOpenDetail({
}
const kind = formData?.studio_kind || ''
uni.navigateTo({
url: `${ASSET_DETAIL_URL}?from=craft_confirm&studio_kind=${encodeURIComponent(kind)}`,
})
// uni.navigateTo({
// url: `${ASSET_DETAIL_URL}?from=craft_confirm&studio_kind=${encodeURIComponent(kind)}`,
// })
}
// ========== Query Helpers ==========
/**
* 判断选择后的动作是否为"详情确认"
* @param {Object} formData - 表单数据
* @returns {boolean}
*/
export function isDetailAfterSelect(formData) {
return formData?.generation_after === AFTER_SELECT_DETAIL
}
/**
* 判断是否为光栅工作室类型
* @param {Object} formData - 表单数据
* @returns {boolean}
*/
export function isLenticularKind(formData) {
return formData?.studio_kind === STUDIO_LENTICULAR
}
/**
* 判断是否为镭射工作室类型
* @param {Object} formData - 表单数据
* @returns {boolean}
*/
export function isLaserKind(formData) {
return formData?.studio_kind === STUDIO_LASER
}
// ========== Payload Consumer ==========
/**
* 消费读取并清除生成流程的Payload
* 在目标页面读取后自动清除保证一次性使用
*
* @returns {Object|null} 流程Payload包含modeimagescraft等
*/
export function consumeGenerationFlowPayload() {
try {
const raw = uni.getStorageSync(GENERATION_FLOW_KEY)
@ -272,4 +464,5 @@ export function consumeGenerationFlowPayload() {
}
}
export { RESULT_URL }
// ========== Exports ==========
export { RESULT_URL }

View File

@ -32,13 +32,14 @@ function uploadFileToOss(tempFilePath, ossData) {
}
/**
* 上传文件到 OSS 并返回文件大小用于注册素材
* 上传文件到 OSS 并返回文件大小hash用于注册素材
*/
function uploadFileToOssWithInfo(tempFilePath, ossData) {
return new Promise((resolve, reject) => {
console.log('[craftMintSubmit] uploadFileToOssWithInfo tempFilePath:', tempFilePath)
const fileName = `${Date.now()}.jpg`
uni.uploadFile({
url: ossData.host,
url: resolveH5OssPostUrl(ossData.host),
filePath: tempFilePath,
name: 'file',
formData: {
@ -52,6 +53,7 @@ function uploadFileToOssWithInfo(tempFilePath, ossData) {
'x-oss-signature-version': ossData.x_oss_signature_version,
},
success: async (res) => {
console.log('[craftMintSubmit] uploadFileToOssWithInfo statusCode:', res.statusCode)
if (res.statusCode === 200 || res.statusCode === 204) {
const url = `${ossData.host}/${ossData.dir}${fileName}`
// 获取文件大小
@ -68,30 +70,264 @@ function uploadFileToOssWithInfo(tempFilePath, ossData) {
} catch (e) {
console.warn('[craftMintSubmit] getFileInfo failed', e)
}
resolve({ url, size, hash: '' })
// 计算文件 hash
const hash = await computeHashFromPath(tempFilePath)
console.log('[craftMintSubmit] uploadFileToOssWithInfo hash:', hash ? hash.substring(0, 10) + '...' : 'empty')
resolve({ url, size, hash })
} else {
reject(new Error(`上传失败 ${res.statusCode}`))
}
},
fail: reject,
fail: (err) => {
console.error('[craftMintSubmit] uploadFileToOssWithInfo fail:', err)
reject(err)
},
})
})
}
/**
* JavaScript SHA256 实现用于 App 等不支持 crypto.subtle 的环境
*/
function sha256Sync(str) {
// SHA256 实现
const rotateRight = (n, s) => (n >>> s) | ((n << (32 - s)) >>> 0)
const choice = (x, y, z) => (x & y) ^ (~x & z)
const majority = (x, y, z) => (x & y) ^ (x & z) ^ (y & z)
const maj = (x, y, z) => (x & y) ^ (x & z) ^ (y & z)
const ch = (x, y, z) => (x & y) ^ (~x & z)
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0x49b40821, 0xfef9e05c, 0x4fd9c4a7, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814,
0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
]
// 转换为 bytes
const msgBytes = []
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
if (code < 0x80) {
msgBytes.push(code)
} else if (code < 0x800) {
msgBytes.push(0xc0 | (code >> 6))
msgBytes.push(0x80 | (code & 0x3f))
} else if (code < 0xd800 || code >= 0xe000) {
msgBytes.push(0xe0 | (code >> 12))
msgBytes.push(0x80 | ((code >> 6) & 0x3f))
msgBytes.push(0x80 | (code & 0x3f))
} else {
const cp = 0x10000 + ((code - 0xd800) << 10) | (str.charCodeAt(++i) - 0xdc00)
msgBytes.push(0xf0 | (cp >> 18))
msgBytes.push(0x80 | ((cp >> 12) & 0x3f))
msgBytes.push(0x80 | ((cp >> 6) & 0x3f))
msgBytes.push(0x80 | (cp & 0x3f))
}
}
// Padding
const msgLen = msgBytes.length
const bitLen = msgLen * 8
msgBytes.push(0x80)
while ((msgBytes.length % 64) !== 56) msgBytes.push(0)
for (let i = 7; i >= 0; i--) msgBytes.push((bitLen >>> (i * 8)) & 0xff)
// Initialize hash values
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
// Process blocks
for (let chunk = 0; chunk < msgBytes.length / 64; chunk++) {
const w = new Array(64)
for (let j = 0; j < 16; j++) {
w[j] = (msgBytes[chunk * 64 + j * 4] << 24) | (msgBytes[chunk * 64 + j * 4 + 1] << 16) |
(msgBytes[chunk * 64 + j * 4 + 2] << 8) | msgBytes[chunk * 64 + j * 4 + 3]
}
for (let j = 16; j < 64; j++) {
const s0 = rotateRight(w[j - 15], 7) ^ rotateRight(w[j - 15], 18) ^ (w[j - 15] >>> 3)
const s1 = rotateRight(w[j - 2], 17) ^ rotateRight(w[j - 2], 19) ^ (w[j - 2] >>> 10)
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0
}
let [ah0, ah1, ah2, ah3, ah4, ah5, ah6, ah7] = [h0, h1, h2, h3, h4, h5, h6, h7]
for (let j = 0; j < 64; j++) {
const S1 = rotateRight(ah6, 6) ^ rotateRight(ah6, 11) ^ rotateRight(ah6, 25)
const ch2 = ch(ah6, ah5, ah4)
const temp1 = (ah7 + S1 + ch2 + K[j] + w[j]) >>> 0
const S0 = rotateRight(ah0, 2) ^ rotateRight(ah0, 13) ^ rotateRight(ah0, 22)
const maj2 = maj(ah0, ah1, ah2)
const temp2 = (S0 + maj2) >>> 0
ah7 = (ah6 + temp1) >>> 0
ah6 = (ah5 + temp1) >>> 0
ah5 = (ah4 + temp1) >>> 0
ah4 = (ah3 + temp1) >>> 0
ah3 = (ah2 + temp1) >>> 0
ah2 = (ah1 + temp1) >>> 0
ah1 = (ah0 + temp1) >>> 0
ah0 = (temp1 + temp2) >>> 0
}
h0 = (h0 + ah0) >>> 0; h1 = (h1 + ah1) >>> 0; h2 = (h2 + ah2) >>> 0; h3 = (h3 + ah3) >>> 0
h4 = (h4 + ah4) >>> 0; h5 = (h5 + ah5) >>> 0; h6 = (h6 + ah6) >>> 0; h7 = (h7 + ah7) >>> 0
}
const toHex = (n) => n.toString(16).padStart(8, '0')
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7)
}
/**
* 计算文件 SHA256 哈希使用 Web Crypto API
*/
async function computeFileHash(file) {
if (typeof crypto !== 'undefined' && crypto.subtle) {
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
console.log('[craftMintSubmit] computeFileHash start, file type:', typeof file, file instanceof Blob ? `Blob(size=${file.size})` : '')
try {
if (typeof crypto !== 'undefined' && crypto.subtle) {
const buffer = await file.arrayBuffer()
console.log('[craftMintSubmit] computeFileHash arrayBuffer length:', buffer.byteLength)
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
console.log('[craftMintSubmit] computeFileHash result:', hash.substring(0, 20) + '...')
return hash
} else {
console.warn('[craftMintSubmit] computeFileHash: crypto.subtle not available')
}
} catch (e) {
console.error('[craftMintSubmit] computeFileHash failed:', e)
}
// Fallback: 返回空字符串,后端会重新计算
return ''
}
/**
* 从文件路径读取内容并计算 hash支持各端
*/
async function computeHashFromPath(filePath) {
console.log('[craftMintSubmit] computeHashFromPath start, path:', filePath ? filePath.substring(0, 80) : 'empty')
try {
// 处理 data:URL 格式
if (filePath && filePath.startsWith('data:')) {
console.log('[craftMintSubmit] computeHashFromPath data:URL format')
try {
const base64Data = filePath.split(',')[1] || ''
const binaryStr = atob(base64Data)
// 优先用 crypto.subtle失败则用纯JS
if (typeof crypto !== 'undefined' && crypto.subtle) {
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
console.log('[craftMintSubmit] computeHashFromPath data:URL hash:', hash.substring(0, 20) + '...')
return hash
} else {
// Fallback 纯JS SHA256
const hash = sha256Sync(binaryStr)
console.log('[craftMintSubmit] computeHashFromPath data:URL sha256Sync hash:', hash.substring(0, 20) + '...')
return hash
}
} catch (e) {
console.error('[craftMintSubmit] data:URL hash failed:', e)
}
return ''
}
let content = ''
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
console.log('[craftMintSubmit] computeHashFromPath in miniprogram')
try {
const fs = uni.getFileSystemManager()
content = await new Promise((resolve, reject) => {
fs.readFile({
filePath: filePath,
encoding: 'base64',
success: (res) => {
console.log('[craftMintSubmit] fs.readFile success, data length:', res.data?.length)
resolve(res.data)
},
fail: (err) => {
console.error('[craftMintSubmit] fs.readFile fail:', err)
reject(err)
}
})
})
} catch (e) {
console.error('[craftMintSubmit] 小程序读取文件失败:', e)
}
// #endif
// #ifdef APP-PLUS
if (!content) {
console.log('[craftMintSubmit] computeHashFromPath in app-plus')
try {
content = await new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
entry.file((file) => {
const reader = new plus.io.FileReader()
reader.onloadend = (e) => {
console.log('[craftMintSubmit] FileReader onloadend, result length:', e.target.result?.length)
resolve(e.target.result.split(',')[1] || '')
}
reader.onerror = (e) => {
console.error('[craftMintSubmit] FileReader error:', e)
reject(e)
}
reader.readAsDataURL(file)
}, (err) => {
console.error('[craftMintSubmit] getFile error:', err)
reject(err)
})
}, (err) => {
console.error('[craftMintSubmit] resolveLocalFileSystemURL error:', err)
reject(err)
})
})
} catch (e) {
console.error('[craftMintSubmit] App读取文件失败:', e)
}
}
// #endif
console.log('[craftMintSubmit] computeHashFromPath content length:', content?.length || 0)
console.log('[craftMintSubmit] computeHashFromPath crypto:', typeof crypto, crypto ? 'exists' : 'null', ', subtle:', crypto?.subtle ? 'exists' : 'null')
if (content && typeof crypto !== 'undefined' && crypto && crypto.subtle) {
try {
// 将 base64 转换为 ArrayBuffer 并计算 hash
const binary = atob(content)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
console.log('[craftMintSubmit] computeHashFromPath hash:', hash.substring(0, 20) + '...')
return hash
} catch (e) {
console.error('[craftMintSubmit] hash计算失败:', e)
}
} else {
// Fallback: 使用纯JS SHA256 实现 (App 环境 crypto.subtle 可能不可用)
try {
console.log('[craftMintSubmit] computeHashFromPath 使用纯JS SHA256 fallback, content length:', content.length)
// content 是 base64 字符串,需要先解码为 binary string
const binaryStr = atob(content)
const hash = sha256Sync(binaryStr)
console.log('[craftMintSubmit] computeHashFromPath sha256Sync hash:', hash.substring(0, 20) + '...')
return hash
} catch (e) {
console.error('[craftMintSubmit] SHA256 fallback failed:', e)
}
}
} catch (e) {
console.error('[craftMintSubmit] computeHashFromPath failed:', e)
}
console.log('[craftMintSubmit] computeHashFromPath return empty')
return ''
}
/**
* 获取文件 MIME 类型
*/
@ -119,9 +355,17 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
// #ifdef H5
if (imagePath.startsWith('data:')) {
console.log('[craftMintSubmit] H5 base64 upload')
const blob = await fetch(imagePath).then((r) => r.blob())
// H5: data:URL 需要手动转换 为 Blob
const base64Data = imagePath.split(',')[1] || ''
const binaryStr = atob(base64Data)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const blob = new Blob([bytes], { type: 'image/jpeg' })
uploadFileSize = blob.size
uploadHash = await computeFileHash(blob)
console.log('[craftMintSubmit] H5 base64 hash computed:', uploadHash.substring(0, 20) + '...')
const fd = new FormData()
const fileName = `${Date.now()}.jpg`
fd.append('key', ossData.dir + fileName)
@ -139,6 +383,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
throw new Error('上传失败')
}
imageUrl = `${ossData.host}/${ossData.dir}${fileName}`
console.log('[craftMintSubmit] H5 base64 upload complete, hash:', uploadHash.substring(0, 20) + '...')
} else {
console.log('[craftMintSubmit] 小程序/非H5上传')
const uploadResult = await uploadFileToOssWithInfo(imagePath, ossData)
@ -163,6 +408,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
const mimeType = getFileMimeType(imagePath)
// 4. 注册素材到后端
console.log('[craftMintSubmit] uploadMaterialApi ossKey:', ossKey, 'mimeType:', mimeType, 'fileSize:', uploadFileSize)
const materialRes = await uploadMaterialApi({
oss_key: ossKey,
original_name: originalName || `${materialType}.jpg`,
@ -171,6 +417,7 @@ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName,
hash: uploadHash,
material_type: materialType,
})
console.log('[craftMintSubmit] uploadMaterialApi response:', materialRes)
if (!materialRes || materialRes.code !== 200 || !materialRes.data) {
throw new Error(materialRes?.message || '素材注册失败')
@ -287,7 +534,7 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData
// 7. 构建 NFT 数据并跳转
const nftData = {
image: mainResult.ossKey, // oss_key
image: `${ossData.host}/${mainResult.ossKey}`, // 完整OSS URL
name: snap.name,
description: snap.description || '',
material_type: snap.material_type,
@ -296,7 +543,11 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData
asset_id: assetId,
info: snap.info,
event: snap.info,
...(bgMaterialId ? { bg_material_id: bgMaterialId } : {}),
}
// 如果是光栅卡,存储背景图路径供 success 页面展示
if (isLenticular && bgImagePath) {
nftData.bg_image = bgImagePath
nftData.is_lenticular = true
}
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
uni.removeStorageSync('castlove_form_data')